@webda/auth 0.0.0-stage → 4.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +166 -0
- package/README.md +26 -2
- package/lib/authentication.service.d.ts +286 -0
- package/lib/authentication.service.d.ts.map +1 -0
- package/lib/authentication.service.js +798 -0
- package/lib/authentication.service.js.map +1 -0
- package/lib/compat/upgrade.d.ts +71 -0
- package/lib/compat/upgrade.d.ts.map +1 -0
- package/lib/compat/upgrade.js +185 -0
- package/lib/compat/upgrade.js.map +1 -0
- package/lib/email/emailpassword.service.d.ts +241 -0
- package/lib/email/emailpassword.service.d.ts.map +1 -0
- package/lib/email/emailpassword.service.js +718 -0
- package/lib/email/emailpassword.service.js.map +1 -0
- package/lib/email/tokens.d.ts +36 -0
- package/lib/email/tokens.d.ts.map +1 -0
- package/lib/email/tokens.js +46 -0
- package/lib/email/tokens.js.map +1 -0
- package/lib/errors.d.ts +53 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +73 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +9 -0
- package/lib/index.js.map +1 -0
- package/lib/oauth/oauth.service.d.ts +293 -0
- package/lib/oauth/oauth.service.d.ts.map +1 -0
- package/lib/oauth/oauth.service.js +557 -0
- package/lib/oauth/oauth.service.js.map +1 -0
- package/lib/provider.d.ts +53 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/provider.js +55 -0
- package/lib/provider.js.map +1 -0
- package/lib/test/authtest.d.ts +39 -0
- package/lib/test/authtest.d.ts.map +1 -0
- package/lib/test/authtest.js +62 -0
- package/lib/test/authtest.js.map +1 -0
- package/lib/test/emailtest.d.ts +25 -0
- package/lib/test/emailtest.d.ts.map +1 -0
- package/lib/test/emailtest.js +39 -0
- package/lib/test/emailtest.js.map +1 -0
- package/lib/test/fakeoauth.service.d.ts +45 -0
- package/lib/test/fakeoauth.service.d.ts.map +1 -0
- package/lib/test/fakeoauth.service.js +78 -0
- package/lib/test/fakeoauth.service.js.map +1 -0
- package/lib/test/stubprovider.js +37 -0
- package/lib/test/stubprovider.js.map +1 -0
- package/lib/test/v3.d.ts +29 -0
- package/lib/test/v3.d.ts.map +1 -0
- package/lib/test/v3.js +47 -0
- package/lib/test/v3.js.map +1 -0
- package/lib/throttle.d.ts +40 -0
- package/lib/throttle.d.ts.map +1 -0
- package/lib/throttle.js +47 -0
- package/lib/throttle.js.map +1 -0
- package/package.json +65 -3
- package/test/config.json +20 -0
- package/webda.module.json +636 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
+
var useValue = arguments.length > 2;
|
|
3
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
+
}
|
|
6
|
+
return useValue ? value : void 0;
|
|
7
|
+
};
|
|
8
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
+
var _, done = false;
|
|
14
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
+
var context = {};
|
|
16
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
+
if (kind === "accessor") {
|
|
21
|
+
if (result === void 0) continue;
|
|
22
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
+
}
|
|
27
|
+
else if (_ = accept(result)) {
|
|
28
|
+
if (kind === "field") initializers.unshift(_);
|
|
29
|
+
else descriptor[key] = _;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
+
done = true;
|
|
34
|
+
};
|
|
35
|
+
import { Ident, Operation, Password, Route, registerPasswordPolicy, runAsSystem, Service, ServiceParameters, useContext, useDynamicService, useModelMetadata, WebdaError } from "@webda/core";
|
|
36
|
+
import { WEBDA_PRIMARY_KEY } from "@webda/models";
|
|
37
|
+
import { useAuthentication } from "../provider.js";
|
|
38
|
+
import { AccountExists, IdentLinkedElsewhere, InvalidCredentials, Throttled, TokenInvalid } from "../errors.js";
|
|
39
|
+
import { signEmailToken, verifyEmailToken } from "./tokens.js";
|
|
40
|
+
import { canSend, isLocked, markSent } from "../throttle.js";
|
|
41
|
+
/** EmailPasswordProvider parameters */
|
|
42
|
+
export class EmailPasswordParameters extends ServiceParameters {
|
|
43
|
+
/**
|
|
44
|
+
* @param params - raw parameters
|
|
45
|
+
* @returns this
|
|
46
|
+
*/
|
|
47
|
+
load(params = {}) {
|
|
48
|
+
super.load(params);
|
|
49
|
+
this.mailer ?? (this.mailer = "Mailer");
|
|
50
|
+
this.verification ?? (this.verification = "before");
|
|
51
|
+
this.password = { policy: ".{8,}", ...(this.password ?? {}) };
|
|
52
|
+
this.throttle = { resendDelay: 14400000, failedBeforeDelay: 3, lockout: 900000, ...(this.throttle ?? {}) };
|
|
53
|
+
this.redirects ?? (this.redirects = {});
|
|
54
|
+
this.url ?? (this.url = "/auth/email");
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// bcrypt cost-10 hash of a throwaway string: compared against for unknown emails so timing matches
|
|
59
|
+
const DUMMY_HASH = "$2b$10$Loq148myoR.Yp2D2x5zkj.Z0FQq2afzaddIc2PGWzzdJCOEL.FZ4W";
|
|
60
|
+
/**
|
|
61
|
+
* Profile attributes a client may set: attributes of the user model input schema, minus behaviors, relations,
|
|
62
|
+
* primary key fields and private (`_`-prefixed) fields
|
|
63
|
+
* @param model - user model
|
|
64
|
+
* @returns the allowed attribute names
|
|
65
|
+
*/
|
|
66
|
+
function allowedProfileKeys(model) {
|
|
67
|
+
const meta = useModelMetadata(model);
|
|
68
|
+
const relations = meta?.Relations ?? {};
|
|
69
|
+
const denied = new Set([
|
|
70
|
+
...(model.prototype?.[WEBDA_PRIMARY_KEY] ?? ["uuid"]),
|
|
71
|
+
...(relations.behaviors ?? []).map((r) => r.attribute),
|
|
72
|
+
...(relations.links ?? []).map((r) => r.attribute),
|
|
73
|
+
...(relations.queries ?? []).map((r) => r.attribute),
|
|
74
|
+
...(relations.maps ?? []).map((r) => r.attribute),
|
|
75
|
+
...(relations.binaries ?? []).map((r) => r.attribute),
|
|
76
|
+
...(relations.parent ? [relations.parent.attribute] : [])
|
|
77
|
+
]);
|
|
78
|
+
const props = Object.keys(meta?.Schemas?.Input?.properties ?? {});
|
|
79
|
+
return new Set(props.filter(k => !denied.has(k) && !k.startsWith("_")));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* @param model - user model
|
|
83
|
+
* @param profile - client supplied profile
|
|
84
|
+
* @returns the profile restricted to the attributes the model allows
|
|
85
|
+
*/
|
|
86
|
+
function sanitizeProfile(model, profile) {
|
|
87
|
+
const safe = {};
|
|
88
|
+
if (!profile || typeof profile !== "object" || Array.isArray(profile))
|
|
89
|
+
return safe;
|
|
90
|
+
const allowed = allowedProfileKeys(model);
|
|
91
|
+
for (const [key, value] of Object.entries(profile)) {
|
|
92
|
+
if (allowed.has(key))
|
|
93
|
+
safe[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return safe;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Email + password login method
|
|
99
|
+
*
|
|
100
|
+
* The password policy is process-wide: resolve() registers the one of this provider and stop() restores the
|
|
101
|
+
* default. With several instances the last one resolved wins.
|
|
102
|
+
*
|
|
103
|
+
* Known residual exposure: Auth.Email.Register answers AccountExists for a registered email (enumeration), and
|
|
104
|
+
* login timing differs slightly for known emails. An unverified squatter owning an email blocks its registration
|
|
105
|
+
* until the real owner goes through account recovery.
|
|
106
|
+
*
|
|
107
|
+
* Under `allowedEmailDomains` an email must be verified: with `verification` "after" or "none" a new registration is
|
|
108
|
+
* unverified and therefore refused (EMAIL_DOMAIN_NOT_ALLOWED); use "before".
|
|
109
|
+
* @WebdaModda EmailPasswordProvider
|
|
110
|
+
*/
|
|
111
|
+
let EmailPasswordProvider = (() => {
|
|
112
|
+
var _a;
|
|
113
|
+
let _classSuper = Service;
|
|
114
|
+
let _instanceExtraInitializers = [];
|
|
115
|
+
let _login_decorators;
|
|
116
|
+
let _register_decorators;
|
|
117
|
+
let _startVerification_decorators;
|
|
118
|
+
let _verify_decorators;
|
|
119
|
+
let _startRecovery_decorators;
|
|
120
|
+
let _recover_decorators;
|
|
121
|
+
let _changePassword_decorators;
|
|
122
|
+
let _recoverRedirect_decorators;
|
|
123
|
+
let _verifyRedirect_decorators;
|
|
124
|
+
return _a = class EmailPasswordProvider extends _classSuper {
|
|
125
|
+
constructor() {
|
|
126
|
+
super(...arguments);
|
|
127
|
+
this.providerName = (__runInitializers(this, _instanceExtraInitializers), "email");
|
|
128
|
+
/** Mails sent without being awaited */
|
|
129
|
+
this.pendingMails = new Set();
|
|
130
|
+
}
|
|
131
|
+
/** @override */
|
|
132
|
+
resolve() {
|
|
133
|
+
super.resolve();
|
|
134
|
+
const verifierName = this.parameters.password.verifier;
|
|
135
|
+
const regexp = new RegExp(this.parameters.password.policy);
|
|
136
|
+
registerPasswordPolicy(verifierName
|
|
137
|
+
? { validate: (p, u) => useDynamicService(verifierName).validate(p, u) }
|
|
138
|
+
: { validate: async (p) => regexp.test(p) });
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
/** @override */
|
|
142
|
+
async init() {
|
|
143
|
+
await super.init();
|
|
144
|
+
if (this.parameters.verification !== "none" && !useDynamicService(this.parameters.mailer)) {
|
|
145
|
+
throw new Error(`EmailPasswordProvider requires a Mailer service '${this.parameters.mailer}'`);
|
|
146
|
+
}
|
|
147
|
+
const required = [
|
|
148
|
+
"failure",
|
|
149
|
+
"recover",
|
|
150
|
+
...(this.parameters.verification !== "none" ? ["verified", "register"] : [])
|
|
151
|
+
];
|
|
152
|
+
for (const key of required) {
|
|
153
|
+
if (!this.parameters.redirects?.[key]) {
|
|
154
|
+
throw new Error(`EmailPasswordProvider '${this.getName()}' requires the 'redirects.${key}' parameter`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return this;
|
|
158
|
+
}
|
|
159
|
+
/** @override */
|
|
160
|
+
async stop() {
|
|
161
|
+
registerPasswordPolicy(undefined);
|
|
162
|
+
await super.stop();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* @returns public info
|
|
166
|
+
*/
|
|
167
|
+
getPublicInfo() {
|
|
168
|
+
return { name: "email", type: "password" };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* @param email - normalised email
|
|
172
|
+
* @returns the email ident
|
|
173
|
+
*/
|
|
174
|
+
async getIdent(email) {
|
|
175
|
+
return runAsSystem(() => useAuthentication().findIdent("email", email));
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Absolute link for an emailed token
|
|
179
|
+
* @param path - path under the provider url
|
|
180
|
+
* @param token - token
|
|
181
|
+
* @returns url
|
|
182
|
+
*/
|
|
183
|
+
buildLink(path, token) {
|
|
184
|
+
const ctx = useContext();
|
|
185
|
+
const relative = `${this.parameters.url}${path}?token=${encodeURIComponent(token)}`;
|
|
186
|
+
return ctx?.getHttpContext ? ctx.getHttpContext().getAbsoluteUrl(relative) : relative;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* @param template - mail template
|
|
190
|
+
* @param to - recipient
|
|
191
|
+
* @param url - link
|
|
192
|
+
* @param token - token
|
|
193
|
+
*/
|
|
194
|
+
async sendMail(template, to, url, token) {
|
|
195
|
+
const ctx = useContext();
|
|
196
|
+
await useDynamicService(this.parameters.mailer).send({
|
|
197
|
+
to,
|
|
198
|
+
locale: ctx?.getLocale?.(),
|
|
199
|
+
template,
|
|
200
|
+
replacements: { url, token, to }
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Count a login attempt before verifying it and decide whether the ident is locked
|
|
205
|
+
*
|
|
206
|
+
* Rule, equivalent to `isLocked(ident, failedBeforeDelay, lockout)` on the state before this attempt:
|
|
207
|
+
* `_loginAttempts` is incremented first (atomically; the new value is returned by the memory repository and
|
|
208
|
+
* re-read otherwise), so this attempt is locked when the count of PREVIOUS attempts (`_loginAttempts - 1`) is at
|
|
209
|
+
* least `failedBeforeDelay`, unless that lock has expired, ie `_lastLoginAttemptAt` as read when this call started
|
|
210
|
+
* is older than `lockout`. `_lastLoginAttemptAt` is only refreshed by attempts that are not refused, so hammering
|
|
211
|
+
* a locked ident does not extend the lock; a count reaching the threshold without timestamp is an expired lock.
|
|
212
|
+
* Both are top-level attributes: stores implement atomic increment and
|
|
213
|
+
* single attribute set on them (nested-path atomic operations are not portable across stores). The timestamp
|
|
214
|
+
* is a plain set (last writer wins); the counter is never written back from a snapshot. A burst starting exactly
|
|
215
|
+
* when an expired lock is read is verified as a whole: it is bounded to one burst per lock window.
|
|
216
|
+
* @param ident - the ident
|
|
217
|
+
* @returns true when this attempt must be refused without checking the password
|
|
218
|
+
*/
|
|
219
|
+
async countAttempt(ident) {
|
|
220
|
+
const { failedBeforeDelay, lockout } = this.parameters.throttle;
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
// A lock whose last attempt is older than the lockout is over: attempts restart a window. A count without any
|
|
223
|
+
// timestamp (v3 `_failedLogin` upgraded without `_lastFailedLogin`) is an expired lock too: v4 always stamps an
|
|
224
|
+
// attempt it verifies, so only such data reaches the threshold unstamped
|
|
225
|
+
const expired = (ident._loginAttempts ?? 0) >= failedBeforeDelay &&
|
|
226
|
+
(!ident._lastLoginAttemptAt || !isLocked(ident, failedBeforeDelay, lockout, now));
|
|
227
|
+
const ref = ident.ref();
|
|
228
|
+
const updated = await runAsSystem(() => ref.incrementAttribute("_loginAttempts"));
|
|
229
|
+
const attempts = updated?.["_loginAttempts"] ?? (await runAsSystem(() => ref.get()))._loginAttempts;
|
|
230
|
+
const locked = !expired && attempts - 1 >= failedBeforeDelay;
|
|
231
|
+
if (!locked) {
|
|
232
|
+
await runAsSystem(() => ref.setAttribute("_lastLoginAttemptAt", now));
|
|
233
|
+
}
|
|
234
|
+
return locked;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Login with email and password
|
|
238
|
+
* @param email - email
|
|
239
|
+
* @param password - password
|
|
240
|
+
* @returns the auth result
|
|
241
|
+
*/
|
|
242
|
+
// Schemas are compiled per class: name them explicitly so any service instance name works
|
|
243
|
+
async login(email, password) {
|
|
244
|
+
const normalized = Ident.normalizeEmail(email);
|
|
245
|
+
const ctx = useContext();
|
|
246
|
+
const auth = useAuthentication();
|
|
247
|
+
const ident = await this.getIdent(normalized);
|
|
248
|
+
const userId = ident?.getUser()?.toString();
|
|
249
|
+
const user = userId
|
|
250
|
+
? await runAsSystem(() => auth.getUserModel().ref(userId).get()).catch(() => undefined)
|
|
251
|
+
: undefined;
|
|
252
|
+
// Count before verifying: parallel guesses are each counted and refused once the threshold is crossed
|
|
253
|
+
if (ident && (await this.countAttempt(ident))) {
|
|
254
|
+
throw new Throttled();
|
|
255
|
+
}
|
|
256
|
+
let ok;
|
|
257
|
+
if (user && user.password) {
|
|
258
|
+
ok = await user.password.verify(password);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Same cost as a real check so timing does not reveal unknown emails
|
|
262
|
+
const dummy = new Password();
|
|
263
|
+
dummy.setHash(DUMMY_HASH);
|
|
264
|
+
await dummy.verify(`${password}`);
|
|
265
|
+
ok = false;
|
|
266
|
+
}
|
|
267
|
+
if (!ok) {
|
|
268
|
+
await auth.emit("Authentication.LoginFailed", { context: ctx, user });
|
|
269
|
+
throw new InvalidCredentials();
|
|
270
|
+
}
|
|
271
|
+
// A success always follows a counted attempt (a user implies an ident), so the counter is non-zero here
|
|
272
|
+
await runAsSystem(() => ident.ref().setAttribute("_loginAttempts", 0));
|
|
273
|
+
return auth.complete({
|
|
274
|
+
provider: "email",
|
|
275
|
+
providerUid: normalized,
|
|
276
|
+
email: normalized,
|
|
277
|
+
emailVerified: ident.isVerified(),
|
|
278
|
+
amr: ["pwd"],
|
|
279
|
+
user
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Register with email and password
|
|
284
|
+
* @param email - email
|
|
285
|
+
* @param password - password
|
|
286
|
+
* @param token - register token from the emailed link (verification "before")
|
|
287
|
+
* @param profile - extra user fields
|
|
288
|
+
* @returns the auth result, or verification_sent
|
|
289
|
+
*/
|
|
290
|
+
// Schemas are compiled per class: name them explicitly so any service instance name works
|
|
291
|
+
async register(email, password, token, profile) {
|
|
292
|
+
const ctx = useContext();
|
|
293
|
+
if (ctx.getSession()?.isLogged()) {
|
|
294
|
+
throw new WebdaError.Gone("Already logged in");
|
|
295
|
+
}
|
|
296
|
+
const normalized = Ident.normalizeEmail(email);
|
|
297
|
+
const mode = this.parameters.verification;
|
|
298
|
+
const auth = useAuthentication();
|
|
299
|
+
const existing = await this.getIdent(normalized);
|
|
300
|
+
// Any owned email ident refuses: logging in as its owner here would skip the password check
|
|
301
|
+
if (existing?.getUser()) {
|
|
302
|
+
throw new AccountExists();
|
|
303
|
+
}
|
|
304
|
+
let verified = false;
|
|
305
|
+
if (mode === "before") {
|
|
306
|
+
if (!token) {
|
|
307
|
+
// Refuse early what the policy would refuse later
|
|
308
|
+
auth.applyPolicy({
|
|
309
|
+
provider: "email",
|
|
310
|
+
providerUid: normalized,
|
|
311
|
+
email: normalized,
|
|
312
|
+
emailVerified: true,
|
|
313
|
+
amr: ["pwd"]
|
|
314
|
+
});
|
|
315
|
+
// Same silent throttle as a logged-out StartVerification: the answer never depends on it
|
|
316
|
+
await this.sendUnownedLink(normalized);
|
|
317
|
+
return { status: "verification_sent" };
|
|
318
|
+
}
|
|
319
|
+
const claims = await verifyEmailToken(token, "register");
|
|
320
|
+
if (claims.email !== normalized) {
|
|
321
|
+
throw new TokenInvalid();
|
|
322
|
+
}
|
|
323
|
+
verified = true;
|
|
324
|
+
}
|
|
325
|
+
const base = {
|
|
326
|
+
provider: "email",
|
|
327
|
+
providerUid: normalized,
|
|
328
|
+
email: normalized,
|
|
329
|
+
emailVerified: verified,
|
|
330
|
+
amr: ["pwd"]
|
|
331
|
+
};
|
|
332
|
+
// Nothing is created when the email policy refuses
|
|
333
|
+
auth.applyPolicy(base);
|
|
334
|
+
const safeProfile = sanitizeProfile(auth.getUserModel(), profile);
|
|
335
|
+
const user = await runAsSystem(() => auth.getUserModel().create({ ...safeProfile, email: normalized }, false));
|
|
336
|
+
await user.password.set(password);
|
|
337
|
+
const identity = { ...base, user };
|
|
338
|
+
await auth.emit("Authentication.PasswordCreate", {
|
|
339
|
+
context: ctx,
|
|
340
|
+
user,
|
|
341
|
+
password: user.password.__hash
|
|
342
|
+
});
|
|
343
|
+
await auth.emit("Authentication.Register", {
|
|
344
|
+
context: ctx,
|
|
345
|
+
user,
|
|
346
|
+
data: safeProfile,
|
|
347
|
+
identId: `${normalized}:email`,
|
|
348
|
+
identity
|
|
349
|
+
});
|
|
350
|
+
await runAsSystem(() => user.save());
|
|
351
|
+
let result;
|
|
352
|
+
try {
|
|
353
|
+
result = await auth.complete(identity, { newUser: true });
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
await runAsSystem(() => user.delete());
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
if (mode === "after") {
|
|
360
|
+
const t = await signEmailToken("verify", { email: normalized, sub: user.getUUID() });
|
|
361
|
+
await this.sendMail("EMAIL_REGISTER", normalized, this.buildLink("/verify", t), t);
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Send (or resend) a verification link
|
|
367
|
+
*
|
|
368
|
+
* Logged in: the link proves the email for the current account (`sub` is only in the token, the ident stays
|
|
369
|
+
* unowned until Auth.Email.Verify is called by the same session). Logged out: always answers with no content and
|
|
370
|
+
* never reveals anything; a link is only sent for an unowned or unknown email.
|
|
371
|
+
* @param email - email
|
|
372
|
+
*/
|
|
373
|
+
async startVerification(email) {
|
|
374
|
+
const ctx = useContext();
|
|
375
|
+
const normalized = Ident.normalizeEmail(email);
|
|
376
|
+
const userId = ctx.getSession()?.isLogged() ? ctx.getCurrentUserId() : undefined;
|
|
377
|
+
if (!userId) {
|
|
378
|
+
await this.sendUnownedLink(normalized);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const token = await runAsSystem(() => this.prepareLink(normalized, userId));
|
|
382
|
+
await this.sendMail("EMAIL_REGISTER", normalized, this.buildLink("/verify", token), token);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Logged-out link: a register link is emailed, without awaiting the mail, only for an unknown or unowned and
|
|
386
|
+
* unverified email whose send throttle allows it; anything else is silently ignored
|
|
387
|
+
* @param normalized - normalised email
|
|
388
|
+
*/
|
|
389
|
+
async sendUnownedLink(normalized) {
|
|
390
|
+
const token = await runAsSystem(() => this.prepareLink(normalized, undefined));
|
|
391
|
+
if (!token)
|
|
392
|
+
return;
|
|
393
|
+
// Not awaited: the answer must not depend on whether a mail was sent. The link is built in the request context
|
|
394
|
+
this.sendMailDetached("EMAIL_REGISTER", normalized, this.buildLink("/verify", token), token);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Check and mark the send throttle of an email ident, then sign the token to email; runs as system
|
|
398
|
+
* @param normalized - normalised email
|
|
399
|
+
* @param userId - logged user (verify token), undefined for a logged-out register token
|
|
400
|
+
* @returns the token to send, undefined when nothing must be sent (logged out only)
|
|
401
|
+
* @throws IdentLinkedElsewhere / PreconditionFailed / Throttled (logged in only)
|
|
402
|
+
*/
|
|
403
|
+
async prepareLink(normalized, userId) {
|
|
404
|
+
const { resendDelay } = this.parameters.throttle;
|
|
405
|
+
const auth = useAuthentication();
|
|
406
|
+
let ident = await this.getIdent(normalized);
|
|
407
|
+
const owner = ident?.getUser()?.toString();
|
|
408
|
+
if (!userId && (owner || ident?.isVerified())) {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
if (userId) {
|
|
412
|
+
if (owner && owner !== userId) {
|
|
413
|
+
throw new IdentLinkedElsewhere();
|
|
414
|
+
}
|
|
415
|
+
if (ident?.isVerified()) {
|
|
416
|
+
throw new WebdaError.PreconditionFailed("Email already verified");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (ident && !canSend(ident._throttle, resendDelay)) {
|
|
420
|
+
if (userId)
|
|
421
|
+
throw new Throttled();
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
if (!ident) {
|
|
425
|
+
// Unowned: only tracks the send throttle
|
|
426
|
+
ident = new (auth.getIdentModel())({ ...Ident.key(normalized, "email"), email: normalized });
|
|
427
|
+
ident._throttle = markSent(undefined);
|
|
428
|
+
try {
|
|
429
|
+
await ident.getRepository().create(ident);
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
// A concurrent request just created it, and sends the mail: stay silent when logged out
|
|
433
|
+
if (userId)
|
|
434
|
+
throw err;
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
await ident.ref().patch({ _throttle: markSent(ident._throttle) });
|
|
440
|
+
}
|
|
441
|
+
return signEmailToken(userId ? "verify" : "register", { email: normalized, sub: userId });
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Send a mail without awaiting it; failures are logged without any secret
|
|
445
|
+
* @param template - mail template
|
|
446
|
+
* @param to - recipient
|
|
447
|
+
* @param url - link
|
|
448
|
+
* @param token - token
|
|
449
|
+
*/
|
|
450
|
+
sendMailDetached(template, to, url, token) {
|
|
451
|
+
const pending = this.sendMail(template, to, url, token)
|
|
452
|
+
.catch(err => this.log("ERROR", `Cannot send ${template} mail`, err?.code ?? err?.name))
|
|
453
|
+
.finally(() => this.pendingMails.delete(pending));
|
|
454
|
+
this.pendingMails.add(pending);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Wait for the mails sent without being awaited (logged out verification, recovery)
|
|
458
|
+
*/
|
|
459
|
+
async flushMails() {
|
|
460
|
+
while (this.pendingMails.size) {
|
|
461
|
+
await Promise.all([...this.pendingMails]);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Mark an email as verified: only the session of the user named by the token can complete it
|
|
466
|
+
* @param token - verify token
|
|
467
|
+
* @returns status
|
|
468
|
+
*/
|
|
469
|
+
async verify(token) {
|
|
470
|
+
return this.verifyWith(token, useContext());
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Verify with an explicit context
|
|
474
|
+
* @param token - verify token
|
|
475
|
+
* @param ctx - context whose session must match the token
|
|
476
|
+
* @returns status
|
|
477
|
+
*/
|
|
478
|
+
async verifyWith(token, ctx) {
|
|
479
|
+
const claims = await verifyEmailToken(token, "verify");
|
|
480
|
+
await this.completeVerify(claims, ctx);
|
|
481
|
+
return { status: "verified" };
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* @param claims - verified token claims
|
|
485
|
+
* @param claims.sub - user id
|
|
486
|
+
* @param ctx - context
|
|
487
|
+
* @returns true when the session is logged as the user of the token
|
|
488
|
+
*/
|
|
489
|
+
sessionMatches(claims, ctx) {
|
|
490
|
+
return !!claims.sub && !!ctx?.getSession?.()?.isLogged() && ctx.getCurrentUserId() === claims.sub;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Complete a verification; nothing changes unless every check passes
|
|
494
|
+
* @param claims - verified token claims
|
|
495
|
+
* @param claims.email - email of the token
|
|
496
|
+
* @param claims.sub - user id
|
|
497
|
+
* @param ctx - context
|
|
498
|
+
*/
|
|
499
|
+
async completeVerify(claims, ctx) {
|
|
500
|
+
if (!ctx?.getSession?.()?.isLogged()) {
|
|
501
|
+
throw new WebdaError.Unauthorized("Login required");
|
|
502
|
+
}
|
|
503
|
+
if (!this.sessionMatches(claims, ctx)) {
|
|
504
|
+
throw new IdentLinkedElsewhere();
|
|
505
|
+
}
|
|
506
|
+
await runAsSystem(async () => {
|
|
507
|
+
const ident = await this.getIdent(claims.email);
|
|
508
|
+
if (!ident) {
|
|
509
|
+
throw new TokenInvalid();
|
|
510
|
+
}
|
|
511
|
+
const owner = ident.getUser()?.toString();
|
|
512
|
+
if (owner && owner !== claims.sub) {
|
|
513
|
+
throw new IdentLinkedElsewhere();
|
|
514
|
+
}
|
|
515
|
+
const patch = {};
|
|
516
|
+
if (!ident.isVerified())
|
|
517
|
+
patch.verifiedAt = new Date();
|
|
518
|
+
if (!owner) {
|
|
519
|
+
ident.setUser(claims.sub);
|
|
520
|
+
patch._user = ident._user;
|
|
521
|
+
}
|
|
522
|
+
if (Object.keys(patch).length)
|
|
523
|
+
await ident.ref().patch(patch);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Start password recovery; always succeeds to avoid revealing accounts
|
|
528
|
+
* @param email - email
|
|
529
|
+
*/
|
|
530
|
+
async startRecovery(email) {
|
|
531
|
+
const normalized = Ident.normalizeEmail(email);
|
|
532
|
+
await runAsSystem(async () => {
|
|
533
|
+
const ident = await this.getIdent(normalized);
|
|
534
|
+
if (!ident?.getUser() || !canSend(ident._throttle, this.parameters.throttle.resendDelay))
|
|
535
|
+
return;
|
|
536
|
+
const user = await useAuthentication()
|
|
537
|
+
.getUserModel()
|
|
538
|
+
.ref(ident.getUser().toString())
|
|
539
|
+
.get()
|
|
540
|
+
.catch(() => undefined);
|
|
541
|
+
if (!user?.password?.hasPassword())
|
|
542
|
+
return;
|
|
543
|
+
await ident.ref().patch({ _throttle: markSent(ident._throttle) });
|
|
544
|
+
const t = await signEmailToken("recover", {
|
|
545
|
+
email: normalized,
|
|
546
|
+
sub: user.getUUID(),
|
|
547
|
+
pwdAt: user.password.changedAt
|
|
548
|
+
});
|
|
549
|
+
this.sendMailDetached("EMAIL_RECOVERY", normalized, this.buildLink("/recover", t), t);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Set a new password from a recovery link (does not log in): revokes the refresh tokens and ends every session of
|
|
554
|
+
* the user (see `Session.authAt`), resets the login attempts of the email ident
|
|
555
|
+
* @param token - recover token
|
|
556
|
+
* @param password - new password
|
|
557
|
+
*/
|
|
558
|
+
async recover(token, password) {
|
|
559
|
+
const claims = await verifyEmailToken(token, "recover");
|
|
560
|
+
await runAsSystem(async () => {
|
|
561
|
+
const auth = useAuthentication();
|
|
562
|
+
const user = await auth
|
|
563
|
+
.getUserModel()
|
|
564
|
+
.ref(claims.sub)
|
|
565
|
+
.get()
|
|
566
|
+
.catch(() => undefined);
|
|
567
|
+
if (!user || !user.password || user.password.changedAt !== claims.pwdAt) {
|
|
568
|
+
throw new TokenInvalid();
|
|
569
|
+
}
|
|
570
|
+
// Checked before anything changes: a recovery that cannot revoke the sessions must not set the password
|
|
571
|
+
const tokens = useDynamicService("TokenService");
|
|
572
|
+
if (!tokens) {
|
|
573
|
+
throw new Error("Password recovery requires the TokenService to revoke sessions");
|
|
574
|
+
}
|
|
575
|
+
await user.password.set(password);
|
|
576
|
+
await user.save();
|
|
577
|
+
await tokens.revokeUser(user.getUUID());
|
|
578
|
+
// Receiving the mail proves possession of the address; the new password also ends any login lock
|
|
579
|
+
const ident = await this.getIdent(claims.email);
|
|
580
|
+
if (ident && ident.getUser()?.toString() === user.getUUID()) {
|
|
581
|
+
await ident.ref().setAttribute("_loginAttempts", 0);
|
|
582
|
+
if (!ident.isVerified())
|
|
583
|
+
await ident.ref().patch({ verifiedAt: new Date() });
|
|
584
|
+
}
|
|
585
|
+
await auth.emit("Authentication.PasswordUpdate", {
|
|
586
|
+
context: useContext(),
|
|
587
|
+
user,
|
|
588
|
+
password: user.password.__hash
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Change the current user's password: revokes the refresh tokens and ends the other sessions of the user, the
|
|
594
|
+
* calling cookie session is re-stamped and stays logged in
|
|
595
|
+
* @param current - current password
|
|
596
|
+
* @param next - new password
|
|
597
|
+
*/
|
|
598
|
+
async changePassword(current, next) {
|
|
599
|
+
const ctx = useContext();
|
|
600
|
+
if (!ctx.getSession()?.isLogged()) {
|
|
601
|
+
throw new WebdaError.Unauthorized("Login required");
|
|
602
|
+
}
|
|
603
|
+
const user = await ctx.getCurrentUser();
|
|
604
|
+
await runAsSystem(() => user.password.change(current, next));
|
|
605
|
+
// Every session authenticated before the change ends, except this one (a Bearer caller logs in again)
|
|
606
|
+
ctx.getSession().authAt = Date.now();
|
|
607
|
+
await useAuthentication().emit("Authentication.PasswordUpdate", {
|
|
608
|
+
context: ctx,
|
|
609
|
+
user,
|
|
610
|
+
password: user.password.__hash
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Browser landing for recovery links: redirects a valid recover token to `redirects.recover` without consuming it,
|
|
615
|
+
* anything else to `redirects.failure`; never throws
|
|
616
|
+
* @param ctx - web context
|
|
617
|
+
*/
|
|
618
|
+
async recoverRedirect(ctx) {
|
|
619
|
+
const { recover, failure } = this.parameters.redirects;
|
|
620
|
+
const token = ctx.parameter("token");
|
|
621
|
+
try {
|
|
622
|
+
await verifyEmailToken(token, "recover");
|
|
623
|
+
ctx.writeHead(302, { Location: `${recover}?token=${encodeURIComponent(token)}` });
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
ctx.writeHead(302, {
|
|
627
|
+
Location: `${failure}?reason=${encodeURIComponent(err?.code ?? "TOKEN_INVALID")}`
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Browser landing for emailed links: verifies then redirects, never throws
|
|
633
|
+
* @param ctx - web context
|
|
634
|
+
*/
|
|
635
|
+
async verifyRedirect(ctx) {
|
|
636
|
+
const { verified, failure, register } = this.parameters.redirects;
|
|
637
|
+
const confirm = this.parameters.redirects.confirm ?? failure;
|
|
638
|
+
const token = ctx.parameter("token");
|
|
639
|
+
const redirect = (url) => ctx.writeHead(302, { Location: url });
|
|
640
|
+
try {
|
|
641
|
+
const claims = await verifyEmailToken(token, "verify");
|
|
642
|
+
if (!this.sessionMatches(claims, ctx)) {
|
|
643
|
+
// Nothing changes: a mail scanner prefetching the link must not verify anything
|
|
644
|
+
redirect(`${confirm}?reason=LOGIN_REQUIRED&token=${encodeURIComponent(token)}`);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
await this.completeVerify(claims, ctx);
|
|
648
|
+
redirect(`${verified}?validation=email`);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
try {
|
|
652
|
+
const claims = await verifyEmailToken(token, "register");
|
|
653
|
+
redirect(`${register}?token=${encodeURIComponent(token)}&email=${encodeURIComponent(claims.email)}`);
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
redirect(`${failure}?reason=${encodeURIComponent(err?.code ?? "TOKEN_INVALID")}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
(() => {
|
|
662
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
663
|
+
_login_decorators = [Operation({
|
|
664
|
+
id: "Auth.Email.Login",
|
|
665
|
+
input: "EmailPasswordProvider.login.input",
|
|
666
|
+
output: "EmailPasswordProvider.login.output",
|
|
667
|
+
rest: { method: "post", path: "auth/email/login" }
|
|
668
|
+
})];
|
|
669
|
+
_register_decorators = [Operation({
|
|
670
|
+
id: "Auth.Email.Register",
|
|
671
|
+
input: "EmailPasswordProvider.register.input",
|
|
672
|
+
output: "EmailPasswordProvider.register.output",
|
|
673
|
+
rest: { method: "post", path: "auth/email/register" }
|
|
674
|
+
})];
|
|
675
|
+
_startVerification_decorators = [Operation({
|
|
676
|
+
id: "Auth.Email.StartVerification",
|
|
677
|
+
input: "EmailPasswordProvider.startVerification.input",
|
|
678
|
+
rest: { method: "post", path: "auth/email/verification" }
|
|
679
|
+
})];
|
|
680
|
+
_verify_decorators = [Operation({
|
|
681
|
+
id: "Auth.Email.Verify",
|
|
682
|
+
input: "EmailPasswordProvider.verify.input",
|
|
683
|
+
output: "EmailPasswordProvider.verify.output",
|
|
684
|
+
rest: { method: "post", path: "auth/email/verify" }
|
|
685
|
+
})];
|
|
686
|
+
_startRecovery_decorators = [Operation({
|
|
687
|
+
id: "Auth.Password.StartRecovery",
|
|
688
|
+
input: "EmailPasswordProvider.startRecovery.input",
|
|
689
|
+
rest: { method: "post", path: "auth/password/recovery" }
|
|
690
|
+
})];
|
|
691
|
+
_recover_decorators = [Operation({
|
|
692
|
+
id: "Auth.Password.Recover",
|
|
693
|
+
input: "EmailPasswordProvider.recover.input",
|
|
694
|
+
rest: { method: "post", path: "auth/password/recover" }
|
|
695
|
+
})];
|
|
696
|
+
_changePassword_decorators = [Operation({
|
|
697
|
+
id: "Auth.Password.Change",
|
|
698
|
+
input: "EmailPasswordProvider.changePassword.input",
|
|
699
|
+
rest: { method: "post", path: "auth/password/change" }
|
|
700
|
+
})];
|
|
701
|
+
_recoverRedirect_decorators = [Route("./recover{?token}", ["GET"], { hidden: true })];
|
|
702
|
+
_verifyRedirect_decorators = [Route("./verify{?token}", ["GET"], { hidden: true })];
|
|
703
|
+
__esDecorate(_a, null, _login_decorators, { kind: "method", name: "login", static: false, private: false, access: { has: obj => "login" in obj, get: obj => obj.login }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
704
|
+
__esDecorate(_a, null, _register_decorators, { kind: "method", name: "register", static: false, private: false, access: { has: obj => "register" in obj, get: obj => obj.register }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
705
|
+
__esDecorate(_a, null, _startVerification_decorators, { kind: "method", name: "startVerification", static: false, private: false, access: { has: obj => "startVerification" in obj, get: obj => obj.startVerification }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
706
|
+
__esDecorate(_a, null, _verify_decorators, { kind: "method", name: "verify", static: false, private: false, access: { has: obj => "verify" in obj, get: obj => obj.verify }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
707
|
+
__esDecorate(_a, null, _startRecovery_decorators, { kind: "method", name: "startRecovery", static: false, private: false, access: { has: obj => "startRecovery" in obj, get: obj => obj.startRecovery }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
708
|
+
__esDecorate(_a, null, _recover_decorators, { kind: "method", name: "recover", static: false, private: false, access: { has: obj => "recover" in obj, get: obj => obj.recover }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
709
|
+
__esDecorate(_a, null, _changePassword_decorators, { kind: "method", name: "changePassword", static: false, private: false, access: { has: obj => "changePassword" in obj, get: obj => obj.changePassword }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
710
|
+
__esDecorate(_a, null, _recoverRedirect_decorators, { kind: "method", name: "recoverRedirect", static: false, private: false, access: { has: obj => "recoverRedirect" in obj, get: obj => obj.recoverRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
711
|
+
__esDecorate(_a, null, _verifyRedirect_decorators, { kind: "method", name: "verifyRedirect", static: false, private: false, access: { has: obj => "verifyRedirect" in obj, get: obj => obj.verifyRedirect }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
712
|
+
if (_metadata) Object.defineProperty(_a, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
713
|
+
})(),
|
|
714
|
+
_a.Parameters = EmailPasswordParameters,
|
|
715
|
+
_a;
|
|
716
|
+
})();
|
|
717
|
+
export { EmailPasswordProvider };
|
|
718
|
+
//# sourceMappingURL=emailpassword.service.js.map
|