@aooth/user 0.1.2 → 0.1.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/dist/atscript-db.cjs +25 -1
- package/dist/atscript-db.d.cts +11 -1
- package/dist/atscript-db.d.mts +11 -1
- package/dist/atscript-db.mjs +25 -1
- package/dist/index.cjs +167 -68
- package/dist/index.d.cts +64 -14
- package/dist/index.d.mts +64 -14
- package/dist/index.mjs +167 -69
- package/dist/{user-store-Dbc9unW3.d.mts → user-store-B3EStUfT.d.cts} +65 -6
- package/dist/{user-store-CdWrTeqR.cjs → user-store-BPZVAboN.cjs} +2 -1
- package/dist/{user-store-B_l9vqlQ.mjs → user-store-BaBmH13V.mjs} +2 -1
- package/dist/{user-store-VJPWNgdp.d.cts → user-store-C1lxahSB.d.mts} +65 -6
- package/package.json +10 -11
- package/src/atscript-db/user-credentials.as +6 -1
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
1
|
+
import { C as UserServiceConfig, S as UserCredentials, _ as PolicyCheckResult, a as LockStatus, b as TrustedDeviceRecord, c as MfaData, d as PasswordConfig, f as PasswordData, g as PasswordPolicyInstance, h as PasswordPolicyEvalFn, i as DeepPartial, l as MfaMethod, m as PasswordPolicyDef, n as WithCasOptions, o as LockoutConfig, p as PasswordPolicyContext, r as AccountData, s as LoginResult, t as UserStore, u as MfaMethodInfo, v as TotpConfig, w as UserStoreUpdate, x as UserAuthErrorType, y as TransferablePolicy } from "./user-store-C1lxahSB.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/errors.d.ts
|
|
4
4
|
declare class UserAuthError extends Error {
|
|
@@ -23,12 +23,39 @@ declare class PasswordHasher {
|
|
|
23
23
|
//#endregion
|
|
24
24
|
//#region src/password/policy.d.ts
|
|
25
25
|
declare function normalizePolicies(policies?: (PasswordPolicyDef | PasswordPolicyInstance)[]): PasswordPolicy[];
|
|
26
|
+
/**
|
|
27
|
+
* Helper that builds a `PasswordPolicyDef` from a real backend function plus
|
|
28
|
+
* its bound positional arguments. The function runs directly on the server
|
|
29
|
+
* (no sandbox, no eval); `serialized` is auto-derived as
|
|
30
|
+
* `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
|
|
31
|
+
* so the same constraint can ship to the frontend without re-implementing it.
|
|
32
|
+
*
|
|
33
|
+
* Bundler-safe: positional invocation means renamed parameters inside the
|
|
34
|
+
* function body stay consistent — the call site only relies on argument
|
|
35
|
+
* ORDER, not identifier preservation.
|
|
36
|
+
*
|
|
37
|
+
* Constraint on the rule body: it MUST reference only its own parameters.
|
|
38
|
+
* No closures over module imports, no `this`, no helpers from outer scope —
|
|
39
|
+
* `rule.toString()` ships the literal source; any free identifier the
|
|
40
|
+
* frontend cannot resolve breaks transferability. (`String.prototype.match`,
|
|
41
|
+
* regex literals, plain JS globals are fine.)
|
|
42
|
+
*
|
|
43
|
+
* Omit `args` to mark the policy backend-only — `serialized` stays
|
|
44
|
+
* `undefined` and `transferable` is false; frontend pre-validation skips it
|
|
45
|
+
* and only the server check enforces the rule.
|
|
46
|
+
*/
|
|
47
|
+
declare function definePasswordPolicy<A extends readonly unknown[]>(opts: {
|
|
48
|
+
rule: (v: string, ...args: A) => boolean | Promise<boolean>;
|
|
49
|
+
args?: A;
|
|
50
|
+
description?: string;
|
|
51
|
+
errorMessage?: string;
|
|
52
|
+
}): PasswordPolicyDef;
|
|
26
53
|
declare class PasswordPolicy implements PasswordPolicyInstance {
|
|
27
|
-
readonly rule:
|
|
54
|
+
readonly rule: PasswordPolicyEvalFn;
|
|
55
|
+
readonly serialized?: string;
|
|
28
56
|
readonly description: string;
|
|
29
57
|
readonly errorMessage: string;
|
|
30
58
|
constructor(config: PasswordPolicyDef);
|
|
31
|
-
protected _evalFn: PasswordPolicyEvalFn;
|
|
32
59
|
evaluate(password: string, context?: PasswordPolicyContext): boolean | Promise<boolean>;
|
|
33
60
|
get transferable(): boolean;
|
|
34
61
|
}
|
|
@@ -50,6 +77,14 @@ declare class UserService<T extends object = object> {
|
|
|
50
77
|
protected readonly hasher: PasswordHasher;
|
|
51
78
|
constructor(store: UserStore<T>, config?: UserServiceConfig);
|
|
52
79
|
/**
|
|
80
|
+
* Creates a user with `account.active: false`. The invite workflow relies
|
|
81
|
+
* on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
|
|
82
|
+
* inactive until they accept). For setup scripts / seeders / tests that
|
|
83
|
+
* don't go through invite, follow up with `activateAccount(username)` or
|
|
84
|
+
* `login()` will throw `UserAuthError("INACTIVE")` — which the login
|
|
85
|
+
* workflow deliberately re-maps to `"Invalid credentials"` to avoid account
|
|
86
|
+
* enumeration, so the failure is silent client-side.
|
|
87
|
+
*
|
|
53
88
|
* @param extras Optional partial user fields merged AFTER the base
|
|
54
89
|
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
55
90
|
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
@@ -67,7 +102,7 @@ declare class UserService<T extends object = object> {
|
|
|
67
102
|
/**
|
|
68
103
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
69
104
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
70
|
-
* invite workflow's `auth
|
|
105
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
71
106
|
*/
|
|
72
107
|
deleteUser(username: string): Promise<void>;
|
|
73
108
|
/**
|
|
@@ -102,16 +137,13 @@ declare class UserService<T extends object = object> {
|
|
|
102
137
|
/**
|
|
103
138
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
104
139
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
105
|
-
* if no match (without modifying storage).
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
110
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
111
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
112
|
-
* stricter guarantee is required.
|
|
140
|
+
* if no match (without modifying storage). Single-use is enforced by
|
|
141
|
+
* optimistic-concurrency CAS on the version column — concurrent consumes
|
|
142
|
+
* of the same code race fairly and only one wins; the loser re-reads,
|
|
143
|
+
* finds the hash already removed, and returns `false`.
|
|
113
144
|
*
|
|
114
145
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
146
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
115
147
|
*/
|
|
116
148
|
consumeBackupCode(username: string, code: string): Promise<boolean>;
|
|
117
149
|
/**
|
|
@@ -121,6 +153,18 @@ declare class UserService<T extends object = object> {
|
|
|
121
153
|
* total tries across BOTH factors, not `2 * threshold`.
|
|
122
154
|
*/
|
|
123
155
|
verifyMfa(username: string, code: string, config?: TotpConfig): Promise<void>;
|
|
156
|
+
/**
|
|
157
|
+
* Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
|
|
158
|
+
* enrollment. Differs from `verifyMfa`:
|
|
159
|
+
* - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
|
|
160
|
+
* - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
|
|
161
|
+
* pre-activation; lockout doesn't apply).
|
|
162
|
+
* - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
|
|
163
|
+
*
|
|
164
|
+
* Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
|
|
165
|
+
* totp method; MFA_INVALID on wrong code.
|
|
166
|
+
*/
|
|
167
|
+
verifyTotpSetupCode(username: string, code: string, config?: TotpConfig): Promise<void>;
|
|
124
168
|
getPasswordHasher(): PasswordHasher;
|
|
125
169
|
getConfig(): Readonly<ResolvedConfig>;
|
|
126
170
|
/**
|
|
@@ -176,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
|
|
|
176
220
|
create(data: UserCredentials & T): Promise<void>;
|
|
177
221
|
update(username: string, update: UserStoreUpdate): Promise<boolean>;
|
|
178
222
|
delete(username: string): Promise<boolean>;
|
|
223
|
+
withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
179
224
|
}
|
|
180
225
|
//#endregion
|
|
181
226
|
//#region src/password/policies.d.ts
|
|
@@ -190,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
|
|
|
190
235
|
declare function generateTotpSecret(bytes?: number): string;
|
|
191
236
|
declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
|
|
192
237
|
declare function generateTotpCode(secret: string, config?: TotpConfig): string;
|
|
193
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Returns the matched HOTP counter when `code` is valid within the verification
|
|
240
|
+
* window, otherwise `null`. Returning the counter (not a bool) lets the caller
|
|
241
|
+
* persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
|
|
242
|
+
*/
|
|
243
|
+
declare function verifyTotpCode(secret: string, code: string, config?: TotpConfig): number | null;
|
|
194
244
|
declare function generateMfaCode(length?: number): string;
|
|
195
245
|
//#endregion
|
|
196
246
|
//#region src/mfa/codes.d.ts
|
|
@@ -230,4 +280,4 @@ declare function maskPhone(phone: string): string;
|
|
|
230
280
|
declare function maskMfaValue(method: MfaMethod): string;
|
|
231
281
|
declare function setAtPath(obj: object, path: string, value: unknown): void;
|
|
232
282
|
//#endregion
|
|
233
|
-
export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
|
283
|
+
export { type AccountData, type DeepPartial, type LockStatus, type LockoutConfig, type LoginResult, type MfaData, type MfaMethod, type MfaMethodInfo, type PasswordConfig, type PasswordData, PasswordHasher, PasswordPolicy, type PasswordPolicyContext, type PasswordPolicyDef, type PasswordPolicyEvalFn, type PasswordPolicyInstance, type PolicyCheckResult, type TotpConfig, type TransferablePolicy, type TrustedDeviceRecord, UserAuthError, type UserAuthErrorType, type UserCredentials, UserService, type UserServiceConfig, UserStore, UserStoreMemory, type UserStoreUpdate, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-
|
|
1
|
+
import { a as maskEmail, c as setAtPath, i as incrementAtPath, l as UserAuthError, n as deepMerge, o as maskMfaValue, r as generateSecureRandom, s as maskPhone, t as UserStore } from "./user-store-BaBmH13V.mjs";
|
|
2
2
|
import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { FtringsPool } from "@prostojs/ftring";
|
|
4
3
|
//#region src/mfa/backup-codes.ts
|
|
5
4
|
/**
|
|
6
5
|
* Custom alphabet for backup codes — uppercase letters and digits with the
|
|
@@ -145,6 +144,11 @@ function generateTotpCode(secret, config) {
|
|
|
145
144
|
const counter = Math.floor(now / 1e3 / period);
|
|
146
145
|
return hotpCode(decode(secret), counter, digits);
|
|
147
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Returns the matched HOTP counter when `code` is valid within the verification
|
|
149
|
+
* window, otherwise `null`. Returning the counter (not a bool) lets the caller
|
|
150
|
+
* persist `lastUsedWindow` and reject same-window replays (RFC 6238 §5.2 SHOULD).
|
|
151
|
+
*/
|
|
148
152
|
function verifyTotpCode(secret, code, config) {
|
|
149
153
|
const period = config?.period ?? 30;
|
|
150
154
|
const digits = config?.digits ?? 6;
|
|
@@ -152,14 +156,15 @@ function verifyTotpCode(secret, code, config) {
|
|
|
152
156
|
const now = (config?.clock ?? Date.now)();
|
|
153
157
|
const counter = Math.floor(now / 1e3 / period);
|
|
154
158
|
const key = decode(secret);
|
|
155
|
-
if (typeof code !== "string" || code.length !== digits) return
|
|
159
|
+
if (typeof code !== "string" || code.length !== digits) return null;
|
|
156
160
|
const submitted = Buffer.from(code, "utf8");
|
|
157
|
-
let
|
|
161
|
+
let matchedCounter = null;
|
|
158
162
|
for (let i = -window; i <= window; i++) {
|
|
159
|
-
const
|
|
160
|
-
|
|
163
|
+
const stepCounter = counter + i;
|
|
164
|
+
const expected = Buffer.from(hotpCode(key, stepCounter, digits), "utf8");
|
|
165
|
+
if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matchedCounter = stepCounter;
|
|
161
166
|
}
|
|
162
|
-
return
|
|
167
|
+
return matchedCounter;
|
|
163
168
|
}
|
|
164
169
|
function generateMfaCode(length = 6) {
|
|
165
170
|
return generateSecureRandom(length, "0123456789");
|
|
@@ -268,33 +273,57 @@ var PasswordHasher = class {
|
|
|
268
273
|
};
|
|
269
274
|
//#endregion
|
|
270
275
|
//#region src/password/policy.ts
|
|
271
|
-
const fnPool = new FtringsPool();
|
|
272
276
|
function normalizePolicies(policies) {
|
|
273
277
|
return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
|
|
274
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* Helper that builds a `PasswordPolicyDef` from a real backend function plus
|
|
281
|
+
* its bound positional arguments. The function runs directly on the server
|
|
282
|
+
* (no sandbox, no eval); `serialized` is auto-derived as
|
|
283
|
+
* `(v) => (${rule.toString()})(v, ${args.map(JSON.stringify).join(', ')})`
|
|
284
|
+
* so the same constraint can ship to the frontend without re-implementing it.
|
|
285
|
+
*
|
|
286
|
+
* Bundler-safe: positional invocation means renamed parameters inside the
|
|
287
|
+
* function body stay consistent — the call site only relies on argument
|
|
288
|
+
* ORDER, not identifier preservation.
|
|
289
|
+
*
|
|
290
|
+
* Constraint on the rule body: it MUST reference only its own parameters.
|
|
291
|
+
* No closures over module imports, no `this`, no helpers from outer scope —
|
|
292
|
+
* `rule.toString()` ships the literal source; any free identifier the
|
|
293
|
+
* frontend cannot resolve breaks transferability. (`String.prototype.match`,
|
|
294
|
+
* regex literals, plain JS globals are fine.)
|
|
295
|
+
*
|
|
296
|
+
* Omit `args` to mark the policy backend-only — `serialized` stays
|
|
297
|
+
* `undefined` and `transferable` is false; frontend pre-validation skips it
|
|
298
|
+
* and only the server check enforces the rule.
|
|
299
|
+
*/
|
|
300
|
+
function definePasswordPolicy(opts) {
|
|
301
|
+
const { rule, args, description, errorMessage } = opts;
|
|
302
|
+
const def = { rule: args ? (v) => rule(v, ...args) : (v) => rule(v, ...[]) };
|
|
303
|
+
if (args) {
|
|
304
|
+
const argList = args.map((a) => JSON.stringify(a)).join(", ");
|
|
305
|
+
def.serialized = argList.length > 0 ? `(v) => (${rule.toString()})(v, ${argList})` : `(v) => (${rule.toString()})(v)`;
|
|
306
|
+
}
|
|
307
|
+
if (description !== void 0) def.description = description;
|
|
308
|
+
if (errorMessage !== void 0) def.errorMessage = errorMessage;
|
|
309
|
+
return def;
|
|
310
|
+
}
|
|
275
311
|
var PasswordPolicy = class {
|
|
276
312
|
rule;
|
|
313
|
+
serialized;
|
|
277
314
|
description;
|
|
278
315
|
errorMessage;
|
|
279
316
|
constructor(config) {
|
|
280
317
|
this.rule = config.rule;
|
|
318
|
+
if (config.serialized !== void 0) this.serialized = config.serialized;
|
|
281
319
|
this.description = config.description || "";
|
|
282
320
|
this.errorMessage = config.errorMessage || "";
|
|
283
321
|
}
|
|
284
|
-
_evalFn;
|
|
285
322
|
evaluate(password, context) {
|
|
286
|
-
|
|
287
|
-
else if (typeof this.rule === "string" && this.rule) {
|
|
288
|
-
const fn = fnPool.getFn(this.rule);
|
|
289
|
-
this._evalFn = (v, ctx) => fn({
|
|
290
|
-
v,
|
|
291
|
-
context: ctx
|
|
292
|
-
});
|
|
293
|
-
} else this._evalFn = () => true;
|
|
294
|
-
return this._evalFn(password, context);
|
|
323
|
+
return this.rule(password, context);
|
|
295
324
|
}
|
|
296
325
|
get transferable() {
|
|
297
|
-
return
|
|
326
|
+
return this.serialized !== void 0;
|
|
298
327
|
}
|
|
299
328
|
};
|
|
300
329
|
//#endregion
|
|
@@ -338,6 +367,14 @@ var UserService = class {
|
|
|
338
367
|
this.hasher = new PasswordHasher(this.config.password);
|
|
339
368
|
}
|
|
340
369
|
/**
|
|
370
|
+
* Creates a user with `account.active: false`. The invite workflow relies
|
|
371
|
+
* on this default (see `InviteWorkflow.acceptInvite` — pending invitees stay
|
|
372
|
+
* inactive until they accept). For setup scripts / seeders / tests that
|
|
373
|
+
* don't go through invite, follow up with `activateAccount(username)` or
|
|
374
|
+
* `login()` will throw `UserAuthError("INACTIVE")` — which the login
|
|
375
|
+
* workflow deliberately re-maps to `"Invalid credentials"` to avoid account
|
|
376
|
+
* enumeration, so the failure is silent client-side.
|
|
377
|
+
*
|
|
341
378
|
* @param extras Optional partial user fields merged AFTER the base
|
|
342
379
|
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
343
380
|
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
@@ -417,7 +454,7 @@ var UserService = class {
|
|
|
417
454
|
/**
|
|
418
455
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
419
456
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
420
|
-
* invite workflow's `auth
|
|
457
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
421
458
|
*/
|
|
422
459
|
async deleteUser(username) {
|
|
423
460
|
if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
|
|
@@ -490,30 +527,32 @@ var UserService = class {
|
|
|
490
527
|
}
|
|
491
528
|
getTransferablePolicies() {
|
|
492
529
|
return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
|
|
493
|
-
rule: p.
|
|
530
|
+
rule: p.serialized,
|
|
494
531
|
description: p.description,
|
|
495
532
|
errorMessage: p.errorMessage
|
|
496
533
|
}));
|
|
497
534
|
}
|
|
498
535
|
async addMfaMethod(username, method) {
|
|
499
|
-
|
|
500
|
-
|
|
536
|
+
await this.store.withCas(username, (user) => {
|
|
537
|
+
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
538
|
+
});
|
|
501
539
|
}
|
|
502
540
|
async confirmMfaMethod(username, name) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
541
|
+
await this.store.withCas(username, (user) => {
|
|
542
|
+
let found = false;
|
|
543
|
+
const methods = user.mfa.methods.map((m) => {
|
|
544
|
+
if (m.name === name) {
|
|
545
|
+
found = true;
|
|
546
|
+
return {
|
|
547
|
+
...m,
|
|
548
|
+
confirmed: true
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
return m;
|
|
552
|
+
});
|
|
553
|
+
if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
554
|
+
return { set: { mfa: { methods } } };
|
|
514
555
|
});
|
|
515
|
-
if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
516
|
-
await this.store.update(username, { set: { mfa: { methods } } });
|
|
517
556
|
}
|
|
518
557
|
async removeMfaMethod(username, name) {
|
|
519
558
|
const user = await this.getUser(username);
|
|
@@ -556,24 +595,27 @@ var UserService = class {
|
|
|
556
595
|
/**
|
|
557
596
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
558
597
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
559
|
-
* if no match (without modifying storage).
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
564
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
565
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
566
|
-
* stricter guarantee is required.
|
|
598
|
+
* if no match (without modifying storage). Single-use is enforced by
|
|
599
|
+
* optimistic-concurrency CAS on the version column — concurrent consumes
|
|
600
|
+
* of the same code race fairly and only one wins; the loser re-reads,
|
|
601
|
+
* finds the hash already removed, and returns `false`.
|
|
567
602
|
*
|
|
568
603
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
604
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
569
605
|
*/
|
|
570
606
|
async consumeBackupCode(username, code) {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
607
|
+
let consumed = false;
|
|
608
|
+
await this.store.withCas(username, (user) => {
|
|
609
|
+
const hashes = user.backupCodes ?? [];
|
|
610
|
+
const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
|
|
611
|
+
if (idx < 0) {
|
|
612
|
+
consumed = false;
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
consumed = true;
|
|
616
|
+
return { set: { backupCodes: hashes.filter((_, i) => i !== idx) } };
|
|
617
|
+
});
|
|
618
|
+
return consumed;
|
|
577
619
|
}
|
|
578
620
|
/**
|
|
579
621
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
@@ -587,12 +629,43 @@ var UserService = class {
|
|
|
587
629
|
await this.ensureNotLockedOrThrow(username, user.account);
|
|
588
630
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
589
631
|
if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
632
|
+
const matchedCounter = verifyTotpCode(totp.value, code, config);
|
|
633
|
+
const isReplay = matchedCounter !== null && totp.lastUsedWindow !== void 0 && matchedCounter <= totp.lastUsedWindow;
|
|
634
|
+
if (matchedCounter !== null && !isReplay) {
|
|
635
|
+
let replayDuringCas = false;
|
|
636
|
+
await this.store.withCas(username, (current) => {
|
|
637
|
+
const currentTotp = current.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
638
|
+
if (currentTotp?.lastUsedWindow !== void 0 && matchedCounter <= currentTotp.lastUsedWindow) {
|
|
639
|
+
replayDuringCas = true;
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
const set = { mfa: { methods: current.mfa.methods.map((m) => m.name === "totp" && m.confirmed ? {
|
|
643
|
+
...m,
|
|
644
|
+
lastUsedWindow: matchedCounter
|
|
645
|
+
} : m) } };
|
|
646
|
+
if (current.account.failedLoginAttempts > 0) set.account = { failedLoginAttempts: 0 };
|
|
647
|
+
return { set };
|
|
648
|
+
});
|
|
649
|
+
if (!replayDuringCas) return;
|
|
593
650
|
}
|
|
594
651
|
await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
|
|
595
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Verify a TOTP code against an UNCONFIRMED `totp` MFA method during initial
|
|
655
|
+
* enrollment. Differs from `verifyMfa`:
|
|
656
|
+
* - Looks up unconfirmed (not confirmed) — confirmed totp uses verifyMfa.
|
|
657
|
+
* - Throws MFA_INVALID on bad code; no failed-login counter bump (this is
|
|
658
|
+
* pre-activation; lockout doesn't apply).
|
|
659
|
+
* - No replay/lastUsedWindow tracking — confirmMfaMethod gates further use.
|
|
660
|
+
*
|
|
661
|
+
* Throws: NOT_FOUND if user missing; MFA_NOT_CONFIGURED if no unconfirmed
|
|
662
|
+
* totp method; MFA_INVALID on wrong code.
|
|
663
|
+
*/
|
|
664
|
+
async verifyTotpSetupCode(username, code, config) {
|
|
665
|
+
const totp = (await this.getUser(username)).mfa.methods.find((m) => m.name === "totp" && !m.confirmed);
|
|
666
|
+
if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
667
|
+
if (verifyTotpCode(totp.value, code, config) === null) throw new UserAuthError("MFA_INVALID");
|
|
668
|
+
}
|
|
596
669
|
getPasswordHasher() {
|
|
597
670
|
return this.hasher;
|
|
598
671
|
}
|
|
@@ -622,8 +695,9 @@ var UserService = class {
|
|
|
622
695
|
* merge strategy replace the whole array.
|
|
623
696
|
*/
|
|
624
697
|
async addTrustedDevice(username, record) {
|
|
625
|
-
|
|
626
|
-
|
|
698
|
+
await this.store.withCas(username, (user) => {
|
|
699
|
+
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
700
|
+
});
|
|
627
701
|
}
|
|
628
702
|
/**
|
|
629
703
|
* Returns true when the supplied token (a) signs against the user+ip with
|
|
@@ -750,50 +824,74 @@ var UserStoreMemory = class extends UserStore {
|
|
|
750
824
|
}
|
|
751
825
|
async create(data) {
|
|
752
826
|
if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
753
|
-
|
|
827
|
+
const cloned = structuredClone(data);
|
|
828
|
+
cloned.version = 0;
|
|
829
|
+
this.store.set(data.username, cloned);
|
|
754
830
|
}
|
|
755
831
|
async update(username, update) {
|
|
756
832
|
const user = this.store.get(username);
|
|
757
833
|
if (!user) return false;
|
|
834
|
+
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
758
835
|
if (update.set) deepMerge(user, update.set);
|
|
759
836
|
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
|
|
837
|
+
user.version = (user.version ?? 0) + 1;
|
|
760
838
|
return true;
|
|
761
839
|
}
|
|
762
840
|
async delete(username) {
|
|
763
841
|
return this.store.delete(username);
|
|
764
842
|
}
|
|
843
|
+
async withCas(username, mutator, opts) {
|
|
844
|
+
const maxAttempts = opts?.maxAttempts ?? 2;
|
|
845
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
846
|
+
const current = await this.findByUsername(username);
|
|
847
|
+
if (!current) throw new UserAuthError("NOT_FOUND");
|
|
848
|
+
const patch = mutator(current);
|
|
849
|
+
if (patch === null) return;
|
|
850
|
+
if (await this.update(username, {
|
|
851
|
+
...patch,
|
|
852
|
+
expectedVersion: current.version ?? 0
|
|
853
|
+
})) return;
|
|
854
|
+
}
|
|
855
|
+
throw new UserAuthError("CAS_EXHAUSTED");
|
|
856
|
+
}
|
|
765
857
|
};
|
|
766
858
|
//#endregion
|
|
767
859
|
//#region src/password/policies.ts
|
|
768
|
-
const ppHasMinLength = (min = 8) => ({
|
|
769
|
-
rule:
|
|
860
|
+
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
861
|
+
rule: (v, min) => v.length >= min,
|
|
862
|
+
args: [min],
|
|
770
863
|
description: `Minimum length ${min}`,
|
|
771
864
|
errorMessage: `Password must be at least ${min} characters long`
|
|
772
865
|
});
|
|
773
|
-
const ppHasUpperCase = (n = 1) => ({
|
|
774
|
-
rule:
|
|
866
|
+
const ppHasUpperCase = (n = 1) => definePasswordPolicy({
|
|
867
|
+
rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
|
|
868
|
+
args: [n],
|
|
775
869
|
description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
|
|
776
870
|
errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
|
|
777
871
|
});
|
|
778
|
-
const ppHasLowerCase = (n = 1) => ({
|
|
779
|
-
rule:
|
|
872
|
+
const ppHasLowerCase = (n = 1) => definePasswordPolicy({
|
|
873
|
+
rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
|
|
874
|
+
args: [n],
|
|
780
875
|
description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
|
|
781
876
|
errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
|
|
782
877
|
});
|
|
783
|
-
const ppHasNumber = (n = 1) => ({
|
|
784
|
-
rule:
|
|
878
|
+
const ppHasNumber = (n = 1) => definePasswordPolicy({
|
|
879
|
+
rule: (v, n) => (v.match(/\d/g) || []).length >= n,
|
|
880
|
+
args: [n],
|
|
785
881
|
description: `At least ${n} number${n === 1 ? "" : "s"}`,
|
|
786
882
|
errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
|
|
787
883
|
});
|
|
788
|
-
const ppHasSpecialChar = (n = 1) => ({
|
|
789
|
-
rule:
|
|
884
|
+
const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
|
|
885
|
+
rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
|
|
886
|
+
args: [n],
|
|
790
887
|
description: `At least ${n} special character${n === 1 ? "" : "s"}`,
|
|
791
888
|
errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
|
|
792
889
|
});
|
|
793
|
-
const ppMaxRepeatedChars = (maxRepeated = 2) => ({
|
|
794
|
-
rule:
|
|
890
|
+
const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
891
|
+
rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
|
|
892
|
+
args: [maxRepeated],
|
|
795
893
|
description: `No more than ${maxRepeated} consecutive repeated characters`,
|
|
796
894
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
797
895
|
});
|
|
798
896
|
//#endregion
|
|
799
|
-
export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
|
897
|
+
export { PasswordHasher, PasswordPolicy, UserAuthError, UserService, UserStore, UserStoreMemory, definePasswordPolicy, generateBackupCodePlaintext, generateMfaCode, generateTotpCode, generateTotpSecret, generateTotpUri, hashMfaCode, maskEmail, maskMfaValue, maskPhone, normalizePolicies, ppHasLowerCase, ppHasMinLength, ppHasNumber, ppHasSpecialChar, ppHasUpperCase, ppMaxRepeatedChars, setAtPath, verifyMfaCode, verifyTotpCode };
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
interface UserCredentials {
|
|
3
3
|
id: string;
|
|
4
4
|
username: string;
|
|
5
|
+
/**
|
|
6
|
+
* Server-managed optimistic-concurrency counter. Bumped by `UserStore.update`
|
|
7
|
+
* on every successful write; checked against `UserStoreUpdate.expectedVersion`
|
|
8
|
+
* for CAS. Callers MUST NOT write it directly — atscript-db rejects direct
|
|
9
|
+
* writes with `DbError("VERSION_COLUMN_WRITE")`. Optional in TS so pre-OCC
|
|
10
|
+
* fixtures keep compiling; the store seeds `0` on insert.
|
|
11
|
+
*/
|
|
12
|
+
version?: number;
|
|
5
13
|
password: PasswordData;
|
|
6
14
|
account: AccountData;
|
|
7
15
|
mfa: MfaData;
|
|
@@ -49,8 +57,8 @@ interface AccountData {
|
|
|
49
57
|
* True while the user record exists from an admin-issued invite but the
|
|
50
58
|
* invitee has not yet accepted (set password + activate). Used by
|
|
51
59
|
* `InviteWorkflow` to gate the accept tail, reject duplicate invites, and
|
|
52
|
-
* power `auth
|
|
53
|
-
* invite has been accepted.
|
|
60
|
+
* power `auth/invite/resend` / `auth/invite/cancel`. Absent / `false` once
|
|
61
|
+
* the invite has been accepted.
|
|
54
62
|
*/
|
|
55
63
|
pendingInvitation?: boolean;
|
|
56
64
|
}
|
|
@@ -69,6 +77,12 @@ interface MfaMethod {
|
|
|
69
77
|
confirmed: boolean;
|
|
70
78
|
/** The method's value: email address, phone number, or TOTP secret */
|
|
71
79
|
value: string;
|
|
80
|
+
/**
|
|
81
|
+
* Last HOTP counter accepted for this method (TOTP only). Server-managed
|
|
82
|
+
* replay guard — `verifyMfa` rejects any code whose matched counter is
|
|
83
|
+
* `<= lastUsedWindow`. Never written from user-facing input.
|
|
84
|
+
*/
|
|
85
|
+
lastUsedWindow?: number;
|
|
72
86
|
}
|
|
73
87
|
interface UserServiceConfig {
|
|
74
88
|
password?: PasswordConfig;
|
|
@@ -107,7 +121,23 @@ interface LockoutConfig {
|
|
|
107
121
|
duration?: number;
|
|
108
122
|
}
|
|
109
123
|
interface PasswordPolicyDef {
|
|
110
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Backend evaluator. Function only — executed directly with no sandbox.
|
|
126
|
+
* String rules were removed: `@prostojs/ftring`'s sandbox does NOT block
|
|
127
|
+
* prototype-chain escapes (`constructor.constructor("return process")()`,
|
|
128
|
+
* `__proto__.x = ...`), so accepting strings was an RCE vector. Authors
|
|
129
|
+
* use `definePasswordPolicy({ rule, args })` to get both this fn AND the
|
|
130
|
+
* serialized form for free.
|
|
131
|
+
*/
|
|
132
|
+
rule: PasswordPolicyEvalFn;
|
|
133
|
+
/**
|
|
134
|
+
* Pre-baked function-literal text shipped to clients for cross-tier
|
|
135
|
+
* validation via `getTransferablePolicies()`. Authored as
|
|
136
|
+
* `(v) => (${ruleSource})(v, ${args.map(JSON.stringify).join(', ')})` by
|
|
137
|
+
* `definePasswordPolicy`. Absent → the policy is backend-only (frontend
|
|
138
|
+
* skips it; server-side check remains authoritative).
|
|
139
|
+
*/
|
|
140
|
+
serialized?: string;
|
|
111
141
|
description?: string;
|
|
112
142
|
errorMessage?: string;
|
|
113
143
|
}
|
|
@@ -127,8 +157,15 @@ interface UserStoreUpdate {
|
|
|
127
157
|
set?: DeepPartial<UserCredentials>;
|
|
128
158
|
/** Dot-paths to atomically increment: e.g. {'account.failedLoginAttempts': 1} */
|
|
129
159
|
inc?: Record<string, number>;
|
|
160
|
+
/**
|
|
161
|
+
* Optimistic concurrency control: when supplied, the store applies the
|
|
162
|
+
* update iff the row's current `version` equals this value. On mismatch
|
|
163
|
+
* the store returns `false` (same shape as "not found") and does NOT
|
|
164
|
+
* mutate. Service callers treat both states as "stale read, retry".
|
|
165
|
+
*/
|
|
166
|
+
expectedVersion?: number;
|
|
130
167
|
}
|
|
131
|
-
type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED";
|
|
168
|
+
type UserAuthErrorType = "NOT_FOUND" | "ALREADY_EXISTS" | "LOCKED" | "INACTIVE" | "INVALID_CREDENTIALS" | "POLICY_VIOLATION" | "PASSWORDS_MISMATCH" | "PASSWORD_IN_HISTORY" | "MFA_REQUIRED" | "MFA_INVALID" | "MFA_NOT_CONFIGURED" | "CAS_EXHAUSTED";
|
|
132
169
|
interface LoginResult<T extends object = object> {
|
|
133
170
|
user: UserCredentials & T;
|
|
134
171
|
/** Whether MFA verification is required before granting full access */
|
|
@@ -171,6 +208,15 @@ interface TotpConfig {
|
|
|
171
208
|
}
|
|
172
209
|
//#endregion
|
|
173
210
|
//#region src/store/user-store.d.ts
|
|
211
|
+
interface WithCasOptions {
|
|
212
|
+
/**
|
|
213
|
+
* Total attempts (1 initial + retries). Default `2` = one retry. Each
|
|
214
|
+
* attempt re-reads the row so the mutator runs against fresh state — that
|
|
215
|
+
* is the whole point of retry under OCC. Bump for high-contention writers
|
|
216
|
+
* (bulk admin scripts); leave at default for normal per-user request flow.
|
|
217
|
+
*/
|
|
218
|
+
maxAttempts?: number;
|
|
219
|
+
}
|
|
174
220
|
declare abstract class UserStore<T extends object = object> {
|
|
175
221
|
abstract exists(username: string): Promise<boolean>;
|
|
176
222
|
abstract findByUsername(username: string): Promise<(UserCredentials & T) | null>;
|
|
@@ -179,9 +225,22 @@ declare abstract class UserStore<T extends object = object> {
|
|
|
179
225
|
/**
|
|
180
226
|
* Hard-delete the row. Returns `true` when a row was removed, `false` when
|
|
181
227
|
* the username was not found. Used by `UserService.deleteUser` (and in turn
|
|
182
|
-
* by the invite workflow's `auth
|
|
228
|
+
* by the invite workflow's `auth/invite/cancel` step).
|
|
183
229
|
*/
|
|
184
230
|
abstract delete(username: string): Promise<boolean>;
|
|
231
|
+
/**
|
|
232
|
+
* Run a read-modify-write cycle under optimistic concurrency. Each attempt
|
|
233
|
+
* fetches the current row, calls `mutator` with it, and applies the returned
|
|
234
|
+
* patch under CAS (`expectedVersion = current.version`). On CAS miss the
|
|
235
|
+
* cycle repeats up to `opts.maxAttempts`. The mutator MAY return `null` to
|
|
236
|
+
* exit early without writing — used for "race-loser detects nothing left to
|
|
237
|
+
* do" paths (e.g. the backup code was already consumed by the winner).
|
|
238
|
+
*
|
|
239
|
+
* Throws `UserAuthError("NOT_FOUND")` when no row matches `username`, or
|
|
240
|
+
* `UserAuthError("CAS_EXHAUSTED")` when retries are saturated. Errors
|
|
241
|
+
* thrown from inside `mutator` propagate immediately without retry.
|
|
242
|
+
*/
|
|
243
|
+
abstract withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
185
244
|
}
|
|
186
245
|
//#endregion
|
|
187
|
-
export {
|
|
246
|
+
export { UserServiceConfig as C, UserCredentials as S, PolicyCheckResult as _, LockStatus as a, TrustedDeviceRecord as b, MfaData as c, PasswordConfig as d, PasswordData as f, PasswordPolicyInstance as g, PasswordPolicyEvalFn as h, DeepPartial as i, MfaMethod as l, PasswordPolicyDef as m, WithCasOptions as n, LockoutConfig as o, PasswordPolicyContext as p, AccountData as r, LoginResult as s, UserStore as t, MfaMethodInfo as u, TotpConfig as v, UserStoreUpdate as w, UserAuthErrorType as x, TransferablePolicy as y };
|
|
@@ -11,7 +11,8 @@ const defaultMessages = {
|
|
|
11
11
|
PASSWORD_IN_HISTORY: "Password was recently used",
|
|
12
12
|
MFA_REQUIRED: "Multi-factor authentication is required",
|
|
13
13
|
MFA_INVALID: "Invalid MFA code",
|
|
14
|
-
MFA_NOT_CONFIGURED: "MFA method is not configured"
|
|
14
|
+
MFA_NOT_CONFIGURED: "MFA method is not configured",
|
|
15
|
+
CAS_EXHAUSTED: "Update conflict — please retry"
|
|
15
16
|
};
|
|
16
17
|
var UserAuthError = class extends Error {
|
|
17
18
|
name = "UserAuthError";
|
|
@@ -11,7 +11,8 @@ const defaultMessages = {
|
|
|
11
11
|
PASSWORD_IN_HISTORY: "Password was recently used",
|
|
12
12
|
MFA_REQUIRED: "Multi-factor authentication is required",
|
|
13
13
|
MFA_INVALID: "Invalid MFA code",
|
|
14
|
-
MFA_NOT_CONFIGURED: "MFA method is not configured"
|
|
14
|
+
MFA_NOT_CONFIGURED: "MFA method is not configured",
|
|
15
|
+
CAS_EXHAUSTED: "Update conflict — please retry"
|
|
15
16
|
};
|
|
16
17
|
var UserAuthError = class extends Error {
|
|
17
18
|
name = "UserAuthError";
|