@aooth/user 0.1.3 → 0.1.5
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 +159 -68
- package/dist/index.d.cts +56 -14
- package/dist/index.d.mts +56 -14
- package/dist/index.mjs +159 -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
|
}
|
|
@@ -75,7 +102,7 @@ declare class UserService<T extends object = object> {
|
|
|
75
102
|
/**
|
|
76
103
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
77
104
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
78
|
-
* invite workflow's `auth
|
|
105
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
79
106
|
*/
|
|
80
107
|
deleteUser(username: string): Promise<void>;
|
|
81
108
|
/**
|
|
@@ -110,16 +137,13 @@ declare class UserService<T extends object = object> {
|
|
|
110
137
|
/**
|
|
111
138
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
112
139
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
113
|
-
* if no match (without modifying storage).
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
118
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
119
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
120
|
-
* 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`.
|
|
121
144
|
*
|
|
122
145
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
146
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
123
147
|
*/
|
|
124
148
|
consumeBackupCode(username: string, code: string): Promise<boolean>;
|
|
125
149
|
/**
|
|
@@ -129,6 +153,18 @@ declare class UserService<T extends object = object> {
|
|
|
129
153
|
* total tries across BOTH factors, not `2 * threshold`.
|
|
130
154
|
*/
|
|
131
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>;
|
|
132
168
|
getPasswordHasher(): PasswordHasher;
|
|
133
169
|
getConfig(): Readonly<ResolvedConfig>;
|
|
134
170
|
/**
|
|
@@ -184,6 +220,7 @@ declare class UserStoreMemory<T extends object = object> extends UserStore<T> {
|
|
|
184
220
|
create(data: UserCredentials & T): Promise<void>;
|
|
185
221
|
update(username: string, update: UserStoreUpdate): Promise<boolean>;
|
|
186
222
|
delete(username: string): Promise<boolean>;
|
|
223
|
+
withCas(username: string, mutator: (current: UserCredentials & T) => UserStoreUpdate | null, opts?: WithCasOptions): Promise<void>;
|
|
187
224
|
}
|
|
188
225
|
//#endregion
|
|
189
226
|
//#region src/password/policies.d.ts
|
|
@@ -198,7 +235,12 @@ declare const ppMaxRepeatedChars: (maxRepeated?: number) => PasswordPolicyDef;
|
|
|
198
235
|
declare function generateTotpSecret(bytes?: number): string;
|
|
199
236
|
declare function generateTotpUri(secret: string, issuer: string, account: string, config?: Pick<TotpConfig, "period" | "digits">): string;
|
|
200
237
|
declare function generateTotpCode(secret: string, config?: TotpConfig): string;
|
|
201
|
-
|
|
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;
|
|
202
244
|
declare function generateMfaCode(length?: number): string;
|
|
203
245
|
//#endregion
|
|
204
246
|
//#region src/mfa/codes.d.ts
|
|
@@ -238,4 +280,4 @@ declare function maskPhone(phone: string): string;
|
|
|
238
280
|
declare function maskMfaValue(method: MfaMethod): string;
|
|
239
281
|
declare function setAtPath(obj: object, path: string, value: unknown): void;
|
|
240
282
|
//#endregion
|
|
241
|
-
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
|
|
@@ -425,7 +454,7 @@ var UserService = class {
|
|
|
425
454
|
/**
|
|
426
455
|
* Hard-delete the user row. Returns nothing on success. Throws
|
|
427
456
|
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
428
|
-
* invite workflow's `auth
|
|
457
|
+
* invite workflow's `auth/invite/cancel` to revoke a pending invitation.
|
|
429
458
|
*/
|
|
430
459
|
async deleteUser(username) {
|
|
431
460
|
if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
|
|
@@ -498,30 +527,32 @@ var UserService = class {
|
|
|
498
527
|
}
|
|
499
528
|
getTransferablePolicies() {
|
|
500
529
|
return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
|
|
501
|
-
rule: p.
|
|
530
|
+
rule: p.serialized,
|
|
502
531
|
description: p.description,
|
|
503
532
|
errorMessage: p.errorMessage
|
|
504
533
|
}));
|
|
505
534
|
}
|
|
506
535
|
async addMfaMethod(username, method) {
|
|
507
|
-
|
|
508
|
-
|
|
536
|
+
await this.store.withCas(username, (user) => {
|
|
537
|
+
return { set: { mfa: { methods: [...user.mfa.methods.filter((m) => m.name !== method.name), method] } } };
|
|
538
|
+
});
|
|
509
539
|
}
|
|
510
540
|
async confirmMfaMethod(username, name) {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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 } } };
|
|
522
555
|
});
|
|
523
|
-
if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
524
|
-
await this.store.update(username, { set: { mfa: { methods } } });
|
|
525
556
|
}
|
|
526
557
|
async removeMfaMethod(username, name) {
|
|
527
558
|
const user = await this.getUser(username);
|
|
@@ -564,24 +595,27 @@ var UserService = class {
|
|
|
564
595
|
/**
|
|
565
596
|
* Consume a backup code: returns `true` and removes the matching hash
|
|
566
597
|
* from storage if `code` matches a stored backup code; returns `false`
|
|
567
|
-
* if no match (without modifying storage).
|
|
568
|
-
*
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
* underlying store API does not expose an atomic match-and-remove, so
|
|
572
|
-
* this is acceptable at the intended scale (backup codes are a fallback
|
|
573
|
-
* path, not a hot one). Wrap in your store's transaction primitive if a
|
|
574
|
-
* 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`.
|
|
575
602
|
*
|
|
576
603
|
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
604
|
+
* Throws `UserAuthError("CAS_EXHAUSTED")` if retries are saturated.
|
|
577
605
|
*/
|
|
578
606
|
async consumeBackupCode(username, code) {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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;
|
|
585
619
|
}
|
|
586
620
|
/**
|
|
587
621
|
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
@@ -595,12 +629,43 @@ var UserService = class {
|
|
|
595
629
|
await this.ensureNotLockedOrThrow(username, user.account);
|
|
596
630
|
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
597
631
|
if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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;
|
|
601
650
|
}
|
|
602
651
|
await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
|
|
603
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
|
+
}
|
|
604
669
|
getPasswordHasher() {
|
|
605
670
|
return this.hasher;
|
|
606
671
|
}
|
|
@@ -630,8 +695,9 @@ var UserService = class {
|
|
|
630
695
|
* merge strategy replace the whole array.
|
|
631
696
|
*/
|
|
632
697
|
async addTrustedDevice(username, record) {
|
|
633
|
-
|
|
634
|
-
|
|
698
|
+
await this.store.withCas(username, (user) => {
|
|
699
|
+
return { set: { trustedDevices: [...user.trustedDevices ?? [], record] } };
|
|
700
|
+
});
|
|
635
701
|
}
|
|
636
702
|
/**
|
|
637
703
|
* Returns true when the supplied token (a) signs against the user+ip with
|
|
@@ -758,50 +824,74 @@ var UserStoreMemory = class extends UserStore {
|
|
|
758
824
|
}
|
|
759
825
|
async create(data) {
|
|
760
826
|
if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
761
|
-
|
|
827
|
+
const cloned = structuredClone(data);
|
|
828
|
+
cloned.version = 0;
|
|
829
|
+
this.store.set(data.username, cloned);
|
|
762
830
|
}
|
|
763
831
|
async update(username, update) {
|
|
764
832
|
const user = this.store.get(username);
|
|
765
833
|
if (!user) return false;
|
|
834
|
+
if (update.expectedVersion !== void 0 && (user.version ?? 0) !== update.expectedVersion) return false;
|
|
766
835
|
if (update.set) deepMerge(user, update.set);
|
|
767
836
|
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
|
|
837
|
+
user.version = (user.version ?? 0) + 1;
|
|
768
838
|
return true;
|
|
769
839
|
}
|
|
770
840
|
async delete(username) {
|
|
771
841
|
return this.store.delete(username);
|
|
772
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
|
+
}
|
|
773
857
|
};
|
|
774
858
|
//#endregion
|
|
775
859
|
//#region src/password/policies.ts
|
|
776
|
-
const ppHasMinLength = (min = 8) => ({
|
|
777
|
-
rule:
|
|
860
|
+
const ppHasMinLength = (min = 8) => definePasswordPolicy({
|
|
861
|
+
rule: (v, min) => v.length >= min,
|
|
862
|
+
args: [min],
|
|
778
863
|
description: `Minimum length ${min}`,
|
|
779
864
|
errorMessage: `Password must be at least ${min} characters long`
|
|
780
865
|
});
|
|
781
|
-
const ppHasUpperCase = (n = 1) => ({
|
|
782
|
-
rule:
|
|
866
|
+
const ppHasUpperCase = (n = 1) => definePasswordPolicy({
|
|
867
|
+
rule: (v, n) => (v.match(/[A-Z]/g) || []).length >= n,
|
|
868
|
+
args: [n],
|
|
783
869
|
description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
|
|
784
870
|
errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
|
|
785
871
|
});
|
|
786
|
-
const ppHasLowerCase = (n = 1) => ({
|
|
787
|
-
rule:
|
|
872
|
+
const ppHasLowerCase = (n = 1) => definePasswordPolicy({
|
|
873
|
+
rule: (v, n) => (v.match(/[a-z]/g) || []).length >= n,
|
|
874
|
+
args: [n],
|
|
788
875
|
description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
|
|
789
876
|
errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
|
|
790
877
|
});
|
|
791
|
-
const ppHasNumber = (n = 1) => ({
|
|
792
|
-
rule:
|
|
878
|
+
const ppHasNumber = (n = 1) => definePasswordPolicy({
|
|
879
|
+
rule: (v, n) => (v.match(/\d/g) || []).length >= n,
|
|
880
|
+
args: [n],
|
|
793
881
|
description: `At least ${n} number${n === 1 ? "" : "s"}`,
|
|
794
882
|
errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
|
|
795
883
|
});
|
|
796
|
-
const ppHasSpecialChar = (n = 1) => ({
|
|
797
|
-
rule:
|
|
884
|
+
const ppHasSpecialChar = (n = 1) => definePasswordPolicy({
|
|
885
|
+
rule: (v, n) => (v.match(/[^A-Za-z0-9]/g) || []).length >= n,
|
|
886
|
+
args: [n],
|
|
798
887
|
description: `At least ${n} special character${n === 1 ? "" : "s"}`,
|
|
799
888
|
errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
|
|
800
889
|
});
|
|
801
|
-
const ppMaxRepeatedChars = (maxRepeated = 2) => ({
|
|
802
|
-
rule:
|
|
890
|
+
const ppMaxRepeatedChars = (maxRepeated = 2) => definePasswordPolicy({
|
|
891
|
+
rule: (v, maxRepeated) => !new RegExp(`(.)\\1{${maxRepeated},}`).test(v),
|
|
892
|
+
args: [maxRepeated],
|
|
803
893
|
description: `No more than ${maxRepeated} consecutive repeated characters`,
|
|
804
894
|
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
805
895
|
});
|
|
806
896
|
//#endregion
|
|
807
|
-
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";
|