@aooth/user 0.1.1
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 +21 -0
- package/README.md +38 -0
- package/dist/atscript-db.cjs +44 -0
- package/dist/atscript-db.d.cts +55 -0
- package/dist/atscript-db.d.mts +55 -0
- package/dist/atscript-db.mjs +43 -0
- package/dist/index.cjs +824 -0
- package/dist/index.d.cts +233 -0
- package/dist/index.d.mts +233 -0
- package/dist/index.mjs +799 -0
- package/dist/user-store-B_l9vqlQ.mjs +96 -0
- package/dist/user-store-CdWrTeqR.cjs +149 -0
- package/dist/user-store-Dbc9unW3.d.mts +187 -0
- package/dist/user-store-VJPWNgdp.d.cts +187 -0
- package/package.json +75 -0
- package/src/atscript-db/user-credentials.as +42 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
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-B_l9vqlQ.mjs";
|
|
2
|
+
import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { FtringsPool } from "@prostojs/ftring";
|
|
4
|
+
//#region src/mfa/backup-codes.ts
|
|
5
|
+
/**
|
|
6
|
+
* Custom alphabet for backup codes — uppercase letters and digits with the
|
|
7
|
+
* easily-confused characters (I, O, L, 0, 1) removed (31 chars).
|
|
8
|
+
*/
|
|
9
|
+
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
10
|
+
const RAW_LENGTH = 10;
|
|
11
|
+
const GROUP_SIZE = 4;
|
|
12
|
+
/**
|
|
13
|
+
* Generate `count` cryptographically-random backup codes (default 10).
|
|
14
|
+
*
|
|
15
|
+
* Format: 10 characters from the 31-char safe alphabet (uppercase letters +
|
|
16
|
+
* digits, omitting I/O/L/0/1), grouped as `XXXX-XXXX-XX`.
|
|
17
|
+
*
|
|
18
|
+
* Returns plaintext codes for the caller to deliver to the user — these
|
|
19
|
+
* should be hashed via {@link hashMfaCode} before persistence and never
|
|
20
|
+
* shown to the user again.
|
|
21
|
+
*/
|
|
22
|
+
function generateBackupCodePlaintext(count = 10) {
|
|
23
|
+
const codes = [];
|
|
24
|
+
for (let i = 0; i < count; i++) codes.push(formatCode(generateSecureRandom(RAW_LENGTH, BACKUP_CODE_ALPHABET)));
|
|
25
|
+
return codes;
|
|
26
|
+
}
|
|
27
|
+
function formatCode(raw) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (let i = 0; i < raw.length; i += GROUP_SIZE) parts.push(raw.slice(i, i + GROUP_SIZE));
|
|
30
|
+
return parts.join("-");
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/mfa/codes.ts
|
|
34
|
+
/**
|
|
35
|
+
* SHA-256 hash of an MFA code (e.g. one-time email/SMS code or backup code).
|
|
36
|
+
*
|
|
37
|
+
* Hex-encoded for stable, comparable output regardless of input case/format.
|
|
38
|
+
* Use {@link verifyMfaCode} to compare a submitted plaintext code against the
|
|
39
|
+
* stored hash in constant time.
|
|
40
|
+
*/
|
|
41
|
+
function hashMfaCode(code) {
|
|
42
|
+
return createHash("sha256").update(code).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Constant-time comparison of a submitted plaintext code against an
|
|
46
|
+
* expected SHA-256 hex hash (as produced by {@link hashMfaCode}).
|
|
47
|
+
*
|
|
48
|
+
* Returns false for malformed/empty expected hashes (timingSafeEqual
|
|
49
|
+
* requires equal-length, non-empty buffers).
|
|
50
|
+
*/
|
|
51
|
+
function verifyMfaCode(submitted, expectedHash) {
|
|
52
|
+
if (!expectedHash) return false;
|
|
53
|
+
const a = Buffer.from(hashMfaCode(submitted), "hex");
|
|
54
|
+
const b = Buffer.from(expectedHash, "hex");
|
|
55
|
+
if (a.length !== b.length) return false;
|
|
56
|
+
return timingSafeEqual(a, b);
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/base-x/base32.ts
|
|
60
|
+
/**
|
|
61
|
+
* Partially copied from "thirty-two" library, all credits to Chris Umbel.
|
|
62
|
+
*
|
|
63
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
64
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
65
|
+
* in the Software without restriction, including without limitation the rights
|
|
66
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
67
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
68
|
+
* furnished to do so, subject to the following conditions:
|
|
69
|
+
*
|
|
70
|
+
* The above copyright notice and this permission notice shall be included in
|
|
71
|
+
* all copies or substantial portions of the Software.
|
|
72
|
+
*
|
|
73
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
74
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
75
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
76
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
77
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
78
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
79
|
+
* THE SOFTWARE.
|
|
80
|
+
*/
|
|
81
|
+
const charTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
82
|
+
function quintetCount(buff) {
|
|
83
|
+
const quintets = Math.floor(buff.length / 5);
|
|
84
|
+
return buff.length % 5 === 0 ? quintets : quintets + 1;
|
|
85
|
+
}
|
|
86
|
+
const encode = function(plain) {
|
|
87
|
+
if (!Buffer.isBuffer(plain) && typeof plain !== "string") throw new TypeError("base32.encode only takes Buffer or string as parameter");
|
|
88
|
+
if (!Buffer.isBuffer(plain)) plain = Buffer.from(plain);
|
|
89
|
+
let i = 0;
|
|
90
|
+
let j = 0;
|
|
91
|
+
let shiftIndex = 0;
|
|
92
|
+
let digit = 0;
|
|
93
|
+
const encoded = Buffer.alloc(quintetCount(plain) * 8);
|
|
94
|
+
while (i < plain.length) {
|
|
95
|
+
const current = plain[i];
|
|
96
|
+
if (shiftIndex > 3) {
|
|
97
|
+
digit = current & 255 >> shiftIndex;
|
|
98
|
+
shiftIndex = (shiftIndex + 5) % 8;
|
|
99
|
+
digit = digit << shiftIndex | (i + 1 < plain.length ? plain[i + 1] : 0) >> 8 - shiftIndex;
|
|
100
|
+
i++;
|
|
101
|
+
} else {
|
|
102
|
+
digit = current >> 8 - (shiftIndex + 5) & 31;
|
|
103
|
+
shiftIndex = (shiftIndex + 5) % 8;
|
|
104
|
+
if (shiftIndex === 0) i++;
|
|
105
|
+
}
|
|
106
|
+
encoded[j] = charTable.charCodeAt(digit);
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
for (i = j; i < encoded.length; i++) encoded[i] = 61;
|
|
110
|
+
return encoded;
|
|
111
|
+
};
|
|
112
|
+
function decode(input) {
|
|
113
|
+
const cleaned = input.toUpperCase().replace(/=+$/, "");
|
|
114
|
+
let bits = 0;
|
|
115
|
+
let value = 0;
|
|
116
|
+
let index = 0;
|
|
117
|
+
const output = Buffer.alloc(Math.floor(cleaned.length * 5 / 8));
|
|
118
|
+
for (const ch of cleaned) {
|
|
119
|
+
const val = charTable.indexOf(ch);
|
|
120
|
+
if (val === -1) continue;
|
|
121
|
+
value = value << 5 | val;
|
|
122
|
+
bits += 5;
|
|
123
|
+
if (bits >= 8) {
|
|
124
|
+
output[index++] = value >>> bits - 8 & 255;
|
|
125
|
+
bits -= 8;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return output.subarray(0, index);
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/mfa/totp.ts
|
|
132
|
+
function generateTotpSecret(bytes = 20) {
|
|
133
|
+
return encode(randomBytes(bytes)).toString().replace(/=+$/, "").toUpperCase();
|
|
134
|
+
}
|
|
135
|
+
function generateTotpUri(secret, issuer, account, config) {
|
|
136
|
+
const period = config?.period ?? 30;
|
|
137
|
+
const digits = config?.digits ?? 6;
|
|
138
|
+
const encodedIssuer = encodeURIComponent(issuer);
|
|
139
|
+
return `otpauth://totp/${encodedIssuer}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=${digits}&period=${period}`;
|
|
140
|
+
}
|
|
141
|
+
function generateTotpCode(secret, config) {
|
|
142
|
+
const period = config?.period ?? 30;
|
|
143
|
+
const digits = config?.digits ?? 6;
|
|
144
|
+
const now = (config?.clock ?? Date.now)();
|
|
145
|
+
const counter = Math.floor(now / 1e3 / period);
|
|
146
|
+
return hotpCode(decode(secret), counter, digits);
|
|
147
|
+
}
|
|
148
|
+
function verifyTotpCode(secret, code, config) {
|
|
149
|
+
const period = config?.period ?? 30;
|
|
150
|
+
const digits = config?.digits ?? 6;
|
|
151
|
+
const window = config?.window ?? 1;
|
|
152
|
+
const now = (config?.clock ?? Date.now)();
|
|
153
|
+
const counter = Math.floor(now / 1e3 / period);
|
|
154
|
+
const key = decode(secret);
|
|
155
|
+
if (typeof code !== "string" || code.length !== digits) return false;
|
|
156
|
+
const submitted = Buffer.from(code, "utf8");
|
|
157
|
+
let matched = false;
|
|
158
|
+
for (let i = -window; i <= window; i++) {
|
|
159
|
+
const expected = Buffer.from(hotpCode(key, counter + i, digits), "utf8");
|
|
160
|
+
if (expected.length === submitted.length && timingSafeEqual(expected, submitted)) matched = true;
|
|
161
|
+
}
|
|
162
|
+
return matched;
|
|
163
|
+
}
|
|
164
|
+
function generateMfaCode(length = 6) {
|
|
165
|
+
return generateSecureRandom(length, "0123456789");
|
|
166
|
+
}
|
|
167
|
+
function hotpCode(key, counter, digits) {
|
|
168
|
+
const counterBuf = Buffer.alloc(8);
|
|
169
|
+
counterBuf.writeUInt32BE(Math.floor(counter / 4294967296), 0);
|
|
170
|
+
counterBuf.writeUInt32BE(counter >>> 0, 4);
|
|
171
|
+
const hmac = createHmac("sha1", key);
|
|
172
|
+
hmac.update(counterBuf);
|
|
173
|
+
const hmacResult = hmac.digest();
|
|
174
|
+
const offset = hmacResult[hmacResult.length - 1] & 15;
|
|
175
|
+
return (((hmacResult[offset] & 127) << 24 | (hmacResult[offset + 1] & 255) << 16 | (hmacResult[offset + 2] & 255) << 8 | hmacResult[offset + 3] & 255) % 10 ** digits).toString().padStart(digits, "0");
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
//#region src/password/hasher.ts
|
|
179
|
+
const DEFAULTS = {
|
|
180
|
+
N: 16384,
|
|
181
|
+
r: 8,
|
|
182
|
+
p: 1,
|
|
183
|
+
keyLength: 64,
|
|
184
|
+
saltLength: 32
|
|
185
|
+
};
|
|
186
|
+
const PREFIX = "$scrypt$";
|
|
187
|
+
function scryptAsync(password, salt, keyLength, options) {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
scrypt(password, salt, keyLength, options, (err, derived) => {
|
|
190
|
+
if (err) reject(err);
|
|
191
|
+
else resolve(derived);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
function parseHash(encoded) {
|
|
196
|
+
if (!encoded.startsWith(PREFIX)) return null;
|
|
197
|
+
const parts = encoded.slice(8).split("$");
|
|
198
|
+
if (parts.length !== 3) return null;
|
|
199
|
+
const params = {};
|
|
200
|
+
for (const kv of parts[0].split(",")) {
|
|
201
|
+
const [k, v] = kv.split("=");
|
|
202
|
+
params[k] = Number(v);
|
|
203
|
+
}
|
|
204
|
+
if (!params.N || !params.r || !params.p || !params.l) return null;
|
|
205
|
+
return {
|
|
206
|
+
N: params.N,
|
|
207
|
+
r: params.r,
|
|
208
|
+
p: params.p,
|
|
209
|
+
keyLength: params.l,
|
|
210
|
+
salt: Buffer.from(parts[1], "base64url"),
|
|
211
|
+
hash: Buffer.from(parts[2], "base64url")
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
var PasswordHasher = class {
|
|
215
|
+
pepper;
|
|
216
|
+
N;
|
|
217
|
+
r;
|
|
218
|
+
p;
|
|
219
|
+
keyLength;
|
|
220
|
+
constructor(config) {
|
|
221
|
+
this.pepper = config?.pepper ?? "";
|
|
222
|
+
this.N = config?.scryptN ?? DEFAULTS.N;
|
|
223
|
+
this.r = config?.scryptR ?? DEFAULTS.r;
|
|
224
|
+
this.p = config?.scryptP ?? DEFAULTS.p;
|
|
225
|
+
this.keyLength = config?.keyLength ?? DEFAULTS.keyLength;
|
|
226
|
+
}
|
|
227
|
+
async hash(password) {
|
|
228
|
+
const salt = randomBytes(DEFAULTS.saltLength);
|
|
229
|
+
const derived = await scryptAsync(this.pepper + password, salt, this.keyLength, {
|
|
230
|
+
N: this.N,
|
|
231
|
+
r: this.r,
|
|
232
|
+
p: this.p
|
|
233
|
+
});
|
|
234
|
+
return `${PREFIX}${`N=${this.N},r=${this.r},p=${this.p},l=${this.keyLength}`}$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
|
235
|
+
}
|
|
236
|
+
async verify(password, encoded) {
|
|
237
|
+
const parsed = parseHash(encoded);
|
|
238
|
+
if (!parsed) return false;
|
|
239
|
+
const derived = await scryptAsync(this.pepper + password, parsed.salt, parsed.keyLength, {
|
|
240
|
+
N: parsed.N,
|
|
241
|
+
r: parsed.r,
|
|
242
|
+
p: parsed.p
|
|
243
|
+
});
|
|
244
|
+
if (derived.length !== parsed.hash.length) return false;
|
|
245
|
+
return timingSafeEqual(derived, parsed.hash);
|
|
246
|
+
}
|
|
247
|
+
generatePassword(length = 16) {
|
|
248
|
+
const minLen = Math.max(length, 8);
|
|
249
|
+
const lower = "abcdefghijklmnopqrstuvwxyz";
|
|
250
|
+
const upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
251
|
+
const digits = "0123456789";
|
|
252
|
+
const special = "!@#$%^&*()-_=+";
|
|
253
|
+
const all = lower + upper + digits + special;
|
|
254
|
+
const bytes = randomBytes(minLen);
|
|
255
|
+
const result = Array.from({ length: minLen });
|
|
256
|
+
result[0] = lower[bytes[0] % 26];
|
|
257
|
+
result[1] = upper[bytes[1] % 26];
|
|
258
|
+
result[2] = digits[bytes[2] % 10];
|
|
259
|
+
result[3] = special[bytes[3] % 14];
|
|
260
|
+
for (let i = 4; i < minLen; i++) result[i] = all[bytes[i] % all.length];
|
|
261
|
+
const shuffleBytes = randomBytes(minLen);
|
|
262
|
+
for (let i = minLen - 1; i > 0; i--) {
|
|
263
|
+
const j = shuffleBytes[i] % (i + 1);
|
|
264
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
265
|
+
}
|
|
266
|
+
return result.join("");
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/password/policy.ts
|
|
271
|
+
const fnPool = new FtringsPool();
|
|
272
|
+
function normalizePolicies(policies) {
|
|
273
|
+
return (policies || []).map((p) => p instanceof PasswordPolicy ? p : new PasswordPolicy(p));
|
|
274
|
+
}
|
|
275
|
+
var PasswordPolicy = class {
|
|
276
|
+
rule;
|
|
277
|
+
description;
|
|
278
|
+
errorMessage;
|
|
279
|
+
constructor(config) {
|
|
280
|
+
this.rule = config.rule;
|
|
281
|
+
this.description = config.description || "";
|
|
282
|
+
this.errorMessage = config.errorMessage || "";
|
|
283
|
+
}
|
|
284
|
+
_evalFn;
|
|
285
|
+
evaluate(password, context) {
|
|
286
|
+
if (!this._evalFn) if (typeof this.rule === "function") this._evalFn = this.rule;
|
|
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);
|
|
295
|
+
}
|
|
296
|
+
get transferable() {
|
|
297
|
+
return typeof this.rule === "string";
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
//#endregion
|
|
301
|
+
//#region src/user-service.ts
|
|
302
|
+
function resolveConfig(config) {
|
|
303
|
+
return {
|
|
304
|
+
password: {
|
|
305
|
+
pepper: config?.password?.pepper ?? "",
|
|
306
|
+
historyLength: config?.password?.historyLength ?? 0,
|
|
307
|
+
scryptN: config?.password?.scryptN ?? 16384,
|
|
308
|
+
scryptR: config?.password?.scryptR ?? 8,
|
|
309
|
+
scryptP: config?.password?.scryptP ?? 1,
|
|
310
|
+
keyLength: config?.password?.keyLength ?? 64,
|
|
311
|
+
policies: normalizePolicies(config?.password?.policies)
|
|
312
|
+
},
|
|
313
|
+
lockout: {
|
|
314
|
+
threshold: config?.lockout?.threshold ?? 0,
|
|
315
|
+
duration: config?.lockout?.duration ?? 0
|
|
316
|
+
},
|
|
317
|
+
clock: config?.clock ?? Date.now,
|
|
318
|
+
...config?.deviceTrust && { deviceTrust: config.deviceTrust }
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const DEVICE_TRUST_TOKEN_BYTES = 32;
|
|
322
|
+
const DEVICE_TRUST_SEPARATOR = ".";
|
|
323
|
+
function signDeviceTrust(secret, payload) {
|
|
324
|
+
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
325
|
+
}
|
|
326
|
+
function deviceTrustSafeEqual(a, b) {
|
|
327
|
+
const ab = Buffer.from(a);
|
|
328
|
+
const bb = Buffer.from(b);
|
|
329
|
+
if (ab.length !== bb.length) return false;
|
|
330
|
+
return timingSafeEqual(ab, bb);
|
|
331
|
+
}
|
|
332
|
+
var UserService = class {
|
|
333
|
+
config;
|
|
334
|
+
hasher;
|
|
335
|
+
constructor(store, config) {
|
|
336
|
+
this.store = store;
|
|
337
|
+
this.config = resolveConfig(config);
|
|
338
|
+
this.hasher = new PasswordHasher(this.config.password);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* @param extras Optional partial user fields merged AFTER the base
|
|
342
|
+
* `UserCredentials` shape, so callers can populate consumer-specific
|
|
343
|
+
* required fields (e.g. `tenantId`) without subclassing the store.
|
|
344
|
+
* Because the merge is shallow and extras win, overlapping top-level
|
|
345
|
+
* keys (`id`, `account`, `mfa`, ...) replace the defaults entirely —
|
|
346
|
+
* pass nested objects with all required sub-fields if you intend to
|
|
347
|
+
* override them.
|
|
348
|
+
*/
|
|
349
|
+
async createUser(username, password, extras) {
|
|
350
|
+
const pw = password ?? this.hasher.generatePassword();
|
|
351
|
+
const userData = {
|
|
352
|
+
username,
|
|
353
|
+
password: {
|
|
354
|
+
hash: await this.hasher.hash(pw),
|
|
355
|
+
history: [],
|
|
356
|
+
lastChanged: this.config.clock(),
|
|
357
|
+
isInitial: !password
|
|
358
|
+
},
|
|
359
|
+
account: {
|
|
360
|
+
active: false,
|
|
361
|
+
locked: false,
|
|
362
|
+
lockReason: "",
|
|
363
|
+
lockEnds: 0,
|
|
364
|
+
failedLoginAttempts: 0,
|
|
365
|
+
lastLogin: 0
|
|
366
|
+
},
|
|
367
|
+
mfa: {
|
|
368
|
+
methods: [],
|
|
369
|
+
defaultMethod: "",
|
|
370
|
+
autoSend: false
|
|
371
|
+
},
|
|
372
|
+
...extras
|
|
373
|
+
};
|
|
374
|
+
await this.store.create(userData);
|
|
375
|
+
return userData;
|
|
376
|
+
}
|
|
377
|
+
async getUser(username) {
|
|
378
|
+
const user = await this.store.findByUsername(username);
|
|
379
|
+
if (!user) throw new UserAuthError("NOT_FOUND");
|
|
380
|
+
return user;
|
|
381
|
+
}
|
|
382
|
+
async login(username, password) {
|
|
383
|
+
const user = await this.store.findByUsername(username);
|
|
384
|
+
if (!user) throw new UserAuthError("NOT_FOUND");
|
|
385
|
+
if (!user.account.active) throw new UserAuthError("INACTIVE");
|
|
386
|
+
await this.ensureNotLockedOrThrow(username, user.account);
|
|
387
|
+
if (await this.hasher.verify(password, user.password.hash)) {
|
|
388
|
+
const now = this.config.clock();
|
|
389
|
+
await this.store.update(username, { set: { account: {
|
|
390
|
+
lastLogin: now,
|
|
391
|
+
failedLoginAttempts: 0
|
|
392
|
+
} } });
|
|
393
|
+
user.account.lastLogin = now;
|
|
394
|
+
user.account.failedLoginAttempts = 0;
|
|
395
|
+
return {
|
|
396
|
+
user,
|
|
397
|
+
mfaRequired: this.hasConfirmedMfaMethods(user.mfa)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return this.incrementAndMaybeLock(username, user.account, "INVALID_CREDENTIALS");
|
|
401
|
+
}
|
|
402
|
+
async verifyPassword(username, password) {
|
|
403
|
+
const user = await this.store.findByUsername(username);
|
|
404
|
+
if (!user) throw new UserAuthError("NOT_FOUND");
|
|
405
|
+
return this.hasher.verify(password, user.password.hash);
|
|
406
|
+
}
|
|
407
|
+
async changePassword(username, currentPassword, newPassword, repeatPassword) {
|
|
408
|
+
if (repeatPassword !== void 0 && newPassword !== repeatPassword) throw new UserAuthError("PASSWORDS_MISMATCH");
|
|
409
|
+
const user = await this.getUser(username);
|
|
410
|
+
if (!await this.hasher.verify(currentPassword, user.password.hash)) throw new UserAuthError("INVALID_CREDENTIALS");
|
|
411
|
+
await this.applyPasswordChange(username, user, newPassword);
|
|
412
|
+
}
|
|
413
|
+
async setPassword(username, newPassword) {
|
|
414
|
+
const user = await this.getUser(username);
|
|
415
|
+
await this.applyPasswordChange(username, user, newPassword);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Hard-delete the user row. Returns nothing on success. Throws
|
|
419
|
+
* `UserAuthError("NOT_FOUND")` when no row matches `username`. Used by the
|
|
420
|
+
* invite workflow's `auth.cancelInvite` to revoke a pending invitation.
|
|
421
|
+
*/
|
|
422
|
+
async deleteUser(username) {
|
|
423
|
+
if (!await this.store.delete(username)) throw new UserAuthError("NOT_FOUND");
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Deep-merge `patch` into the user record (top-level fields are shallow-
|
|
427
|
+
* merged; `account` / `mfa` / `password` are merged per their
|
|
428
|
+
* `@db.patch.strategy 'merge'` declaration). Returns the patched record.
|
|
429
|
+
* Used by the invite workflow's `applyProfile` default fallback.
|
|
430
|
+
*/
|
|
431
|
+
async update(username, patch) {
|
|
432
|
+
if (!await this.store.update(username, { set: patch })) throw new UserAuthError("NOT_FOUND");
|
|
433
|
+
return this.getUser(username);
|
|
434
|
+
}
|
|
435
|
+
async activateAccount(username) {
|
|
436
|
+
if (!await this.store.update(username, { set: { account: { active: true } } })) throw new UserAuthError("NOT_FOUND");
|
|
437
|
+
}
|
|
438
|
+
async deactivateAccount(username) {
|
|
439
|
+
if (!await this.store.update(username, { set: { account: { active: false } } })) throw new UserAuthError("NOT_FOUND");
|
|
440
|
+
}
|
|
441
|
+
async lockAccount(username, reason, duration) {
|
|
442
|
+
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
443
|
+
if (!await this.store.update(username, { set: { account: {
|
|
444
|
+
locked: true,
|
|
445
|
+
lockReason: reason,
|
|
446
|
+
lockEnds
|
|
447
|
+
} } })) throw new UserAuthError("NOT_FOUND");
|
|
448
|
+
}
|
|
449
|
+
async unlockAccount(username) {
|
|
450
|
+
if (!await this.store.update(username, { set: { account: {
|
|
451
|
+
locked: false,
|
|
452
|
+
lockReason: "",
|
|
453
|
+
lockEnds: 0,
|
|
454
|
+
failedLoginAttempts: 0
|
|
455
|
+
} } })) throw new UserAuthError("NOT_FOUND");
|
|
456
|
+
}
|
|
457
|
+
getLockStatus(account) {
|
|
458
|
+
if (!account.locked) return {
|
|
459
|
+
locked: false,
|
|
460
|
+
expired: false,
|
|
461
|
+
reason: "",
|
|
462
|
+
lockEnds: 0
|
|
463
|
+
};
|
|
464
|
+
return {
|
|
465
|
+
locked: true,
|
|
466
|
+
expired: account.lockEnds > 0 && account.lockEnds < this.config.clock(),
|
|
467
|
+
reason: account.lockReason,
|
|
468
|
+
lockEnds: account.lockEnds
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
async checkPolicies(password, passwordData) {
|
|
472
|
+
const result = {
|
|
473
|
+
passed: true,
|
|
474
|
+
policies: [],
|
|
475
|
+
errors: []
|
|
476
|
+
};
|
|
477
|
+
for (const policy of this.config.password.policies) {
|
|
478
|
+
const passed = await policy.evaluate(password, {
|
|
479
|
+
passwordData,
|
|
480
|
+
passwordConfig: this.config.password
|
|
481
|
+
});
|
|
482
|
+
result.passed = result.passed && passed;
|
|
483
|
+
result.policies.push({
|
|
484
|
+
description: policy.description,
|
|
485
|
+
passed
|
|
486
|
+
});
|
|
487
|
+
if (!passed) result.errors.push(policy.errorMessage);
|
|
488
|
+
}
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
getTransferablePolicies() {
|
|
492
|
+
return this.config.password.policies.filter((p) => p.transferable).map((p) => ({
|
|
493
|
+
rule: p.rule,
|
|
494
|
+
description: p.description,
|
|
495
|
+
errorMessage: p.errorMessage
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
async addMfaMethod(username, method) {
|
|
499
|
+
const methods = [...(await this.getUser(username)).mfa.methods.filter((m) => m.name !== method.name), method];
|
|
500
|
+
await this.store.update(username, { set: { mfa: { methods } } });
|
|
501
|
+
}
|
|
502
|
+
async confirmMfaMethod(username, name) {
|
|
503
|
+
const user = await this.getUser(username);
|
|
504
|
+
let found = false;
|
|
505
|
+
const methods = user.mfa.methods.map((m) => {
|
|
506
|
+
if (m.name === name) {
|
|
507
|
+
found = true;
|
|
508
|
+
return {
|
|
509
|
+
...m,
|
|
510
|
+
confirmed: true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return m;
|
|
514
|
+
});
|
|
515
|
+
if (!found) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
516
|
+
await this.store.update(username, { set: { mfa: { methods } } });
|
|
517
|
+
}
|
|
518
|
+
async removeMfaMethod(username, name) {
|
|
519
|
+
const user = await this.getUser(username);
|
|
520
|
+
const update = { mfa: { methods: user.mfa.methods.filter((m) => m.name !== name) } };
|
|
521
|
+
if (user.mfa.defaultMethod === name) update.mfa.defaultMethod = "";
|
|
522
|
+
await this.store.update(username, { set: update });
|
|
523
|
+
}
|
|
524
|
+
async setDefaultMfaMethod(username, name) {
|
|
525
|
+
const user = await this.getUser(username);
|
|
526
|
+
if (name && !user.mfa.methods.some((m) => m.name === name)) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
527
|
+
await this.store.update(username, { set: { mfa: {
|
|
528
|
+
defaultMethod: name,
|
|
529
|
+
autoSend: false
|
|
530
|
+
} } });
|
|
531
|
+
}
|
|
532
|
+
async setMfaAutoSend(username, value) {
|
|
533
|
+
if (!await this.store.update(username, { set: { mfa: { autoSend: value } } })) throw new UserAuthError("NOT_FOUND");
|
|
534
|
+
}
|
|
535
|
+
getAvailableMfaMethods(mfa) {
|
|
536
|
+
return mfa.methods.filter((m) => m.confirmed).map((m) => ({
|
|
537
|
+
name: m.name,
|
|
538
|
+
isDefault: mfa.defaultMethod === m.name,
|
|
539
|
+
masked: maskMfaValue(m)
|
|
540
|
+
}));
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Generate `count` plaintext backup codes (default 10), persist their
|
|
544
|
+
* hashes (replacing any existing batch), and return the plaintext codes
|
|
545
|
+
* once for the caller to deliver to the user. Plaintext is never
|
|
546
|
+
* recoverable after this call returns.
|
|
547
|
+
*
|
|
548
|
+
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
549
|
+
*/
|
|
550
|
+
async generateBackupCodes(username, count = 10) {
|
|
551
|
+
const codes = generateBackupCodePlaintext(count);
|
|
552
|
+
const hashes = codes.map(hashMfaCode);
|
|
553
|
+
if (!await this.store.update(username, { set: { backupCodes: hashes } })) throw new UserAuthError("NOT_FOUND");
|
|
554
|
+
return codes;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Consume a backup code: returns `true` and removes the matching hash
|
|
558
|
+
* from storage if `code` matches a stored backup code; returns `false`
|
|
559
|
+
* if no match (without modifying storage).
|
|
560
|
+
*
|
|
561
|
+
* Read-then-write is not atomic at this layer: two concurrent consumes
|
|
562
|
+
* of the same code may both succeed, with the last write winning. The
|
|
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.
|
|
567
|
+
*
|
|
568
|
+
* Throws `UserAuthError("NOT_FOUND")` if the user does not exist.
|
|
569
|
+
*/
|
|
570
|
+
async consumeBackupCode(username, code) {
|
|
571
|
+
const hashes = (await this.getUser(username)).backupCodes ?? [];
|
|
572
|
+
const idx = hashes.findIndex((h) => verifyMfaCode(code, h));
|
|
573
|
+
if (idx < 0) return false;
|
|
574
|
+
const remaining = hashes.filter((_, i) => i !== idx);
|
|
575
|
+
await this.store.update(username, { set: { backupCodes: remaining } });
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Verify a TOTP code against the user's confirmed `totp` MFA method.
|
|
580
|
+
* Failures bump the same `failedLoginAttempts` counter as `login` so an
|
|
581
|
+
* attacker who knows the password but not the TOTP gets `lockout.threshold`
|
|
582
|
+
* total tries across BOTH factors, not `2 * threshold`.
|
|
583
|
+
*/
|
|
584
|
+
async verifyMfa(username, code, config) {
|
|
585
|
+
const user = await this.getUser(username);
|
|
586
|
+
if (!user.account.active) throw new UserAuthError("INACTIVE");
|
|
587
|
+
await this.ensureNotLockedOrThrow(username, user.account);
|
|
588
|
+
const totp = user.mfa.methods.find((m) => m.name === "totp" && m.confirmed);
|
|
589
|
+
if (!totp) throw new UserAuthError("MFA_NOT_CONFIGURED");
|
|
590
|
+
if (verifyTotpCode(totp.value, code, config)) {
|
|
591
|
+
if (user.account.failedLoginAttempts > 0) await this.store.update(username, { set: { account: { failedLoginAttempts: 0 } } });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
await this.incrementAndMaybeLock(username, user.account, "MFA_INVALID");
|
|
595
|
+
}
|
|
596
|
+
getPasswordHasher() {
|
|
597
|
+
return this.hasher;
|
|
598
|
+
}
|
|
599
|
+
getConfig() {
|
|
600
|
+
return this.config;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Mint a freshly-signed trust record (does NOT persist — pair with
|
|
604
|
+
* `addTrustedDevice`). Throws when `deviceTrust.secret` is unset.
|
|
605
|
+
*/
|
|
606
|
+
issueTrustedDevice(userId, opts) {
|
|
607
|
+
const secret = this.requireDeviceTrustSecret();
|
|
608
|
+
const raw = randomBytes(DEVICE_TRUST_TOKEN_BYTES).toString("hex");
|
|
609
|
+
const sig = signDeviceTrust(secret, `${userId}|${raw}|${opts.ip ?? ""}`);
|
|
610
|
+
const now = this.config.clock();
|
|
611
|
+
return {
|
|
612
|
+
token: `${raw}${DEVICE_TRUST_SEPARATOR}${sig}`,
|
|
613
|
+
...opts.ip !== void 0 && { ip: opts.ip },
|
|
614
|
+
issuedAt: now,
|
|
615
|
+
expiresAt: now + opts.ttlMs,
|
|
616
|
+
...opts.name !== void 0 && { name: opts.name }
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Append a trust record to the user's `trustedDevices` list. Read-modify-
|
|
621
|
+
* write — the array shape is preserved end-to-end so DB adapters with a
|
|
622
|
+
* merge strategy replace the whole array.
|
|
623
|
+
*/
|
|
624
|
+
async addTrustedDevice(username, record) {
|
|
625
|
+
const next = [...(await this.getUser(username)).trustedDevices ?? [], record];
|
|
626
|
+
await this.store.update(username, { set: { trustedDevices: next } });
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Returns true when the supplied token (a) signs against the user+ip with
|
|
630
|
+
* the configured secret, AND (b) matches a persisted record that is still
|
|
631
|
+
* within its expiry window and whose bound IP (if any) matches.
|
|
632
|
+
*/
|
|
633
|
+
async verifyTrustedDevice(username, token, ip) {
|
|
634
|
+
const secret = this.requireDeviceTrustSecret();
|
|
635
|
+
const sepIdx = token.lastIndexOf(DEVICE_TRUST_SEPARATOR);
|
|
636
|
+
if (sepIdx <= 0) return false;
|
|
637
|
+
const raw = token.slice(0, sepIdx);
|
|
638
|
+
if (!deviceTrustSafeEqual(token.slice(sepIdx + 1), signDeviceTrust(secret, `${username}|${raw}|${ip ?? ""}`))) return false;
|
|
639
|
+
const user = await this.store.findByUsername(username);
|
|
640
|
+
if (!user) return false;
|
|
641
|
+
const list = user.trustedDevices ?? [];
|
|
642
|
+
const now = this.config.clock();
|
|
643
|
+
const found = list.find((r) => r.token === token && r.expiresAt > now);
|
|
644
|
+
if (!found) return false;
|
|
645
|
+
if (found.ip !== void 0 && found.ip !== ip) return false;
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Remove a specific trust record from the user. No-op when the record is
|
|
650
|
+
* absent — mirrors the legacy `DeviceTrustStore.revoke` semantics.
|
|
651
|
+
*/
|
|
652
|
+
async revokeTrustedDevice(username, token) {
|
|
653
|
+
const user = await this.store.findByUsername(username);
|
|
654
|
+
if (!user) return;
|
|
655
|
+
const list = user.trustedDevices ?? [];
|
|
656
|
+
const next = list.filter((r) => r.token !== token);
|
|
657
|
+
if (next.length === list.length) return;
|
|
658
|
+
await this.store.update(username, { set: { trustedDevices: next } });
|
|
659
|
+
}
|
|
660
|
+
async listTrustedDevices(username) {
|
|
661
|
+
return (await this.getUser(username)).trustedDevices ?? [];
|
|
662
|
+
}
|
|
663
|
+
requireDeviceTrustSecret() {
|
|
664
|
+
const secret = this.config.deviceTrust?.secret;
|
|
665
|
+
if (!secret) throw new Error("UserService: deviceTrust.secret is required to use trusted-device APIs");
|
|
666
|
+
return secret;
|
|
667
|
+
}
|
|
668
|
+
async applyPasswordChange(username, user, newPassword) {
|
|
669
|
+
const policyResult = await this.checkPolicies(newPassword, user.password);
|
|
670
|
+
if (!policyResult.passed) throw new UserAuthError("POLICY_VIOLATION", policyResult.errors.join("; "), { policies: policyResult.policies });
|
|
671
|
+
const hashesToCheck = [user.password.hash, ...user.password.history].filter(Boolean);
|
|
672
|
+
if (hashesToCheck.length > 0) {
|
|
673
|
+
if ((await Promise.all(hashesToCheck.map((h) => this.hasher.verify(newPassword, h)))).some(Boolean)) throw new UserAuthError("PASSWORD_IN_HISTORY");
|
|
674
|
+
}
|
|
675
|
+
const newHash = await this.hasher.hash(newPassword);
|
|
676
|
+
const limit = this.config.password.historyLength;
|
|
677
|
+
const newHistory = limit > 0 ? [user.password.hash, ...user.password.history].filter(Boolean).slice(0, limit) : [];
|
|
678
|
+
await this.store.update(username, { set: { password: {
|
|
679
|
+
hash: newHash,
|
|
680
|
+
history: newHistory,
|
|
681
|
+
lastChanged: this.config.clock(),
|
|
682
|
+
isInitial: false
|
|
683
|
+
} } });
|
|
684
|
+
}
|
|
685
|
+
hasConfirmedMfaMethods(mfa) {
|
|
686
|
+
return mfa.methods.some((m) => m.confirmed);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* If `account.locked`: auto-unlock when the lock has expired (mutating
|
|
690
|
+
* `account` in place), or throw `LOCKED` otherwise.
|
|
691
|
+
*/
|
|
692
|
+
async ensureNotLockedOrThrow(username, account) {
|
|
693
|
+
const lockStatus = this.getLockStatus(account);
|
|
694
|
+
if (!lockStatus.locked) return;
|
|
695
|
+
if (lockStatus.expired) {
|
|
696
|
+
await this.store.update(username, { set: { account: {
|
|
697
|
+
locked: false,
|
|
698
|
+
lockReason: "",
|
|
699
|
+
lockEnds: 0
|
|
700
|
+
} } });
|
|
701
|
+
account.locked = false;
|
|
702
|
+
account.lockEnds = 0;
|
|
703
|
+
account.lockReason = "";
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
throw new UserAuthError("LOCKED", void 0, {
|
|
707
|
+
reason: account.lockReason,
|
|
708
|
+
lockEnds: account.lockEnds
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Bump `failedLoginAttempts`, locking the account when threshold is hit,
|
|
713
|
+
* and always throw `errorCode` (with `details.lockEnds` when the lockout
|
|
714
|
+
* just tripped). Used by both `login` and `verifyMfa` so the two factors
|
|
715
|
+
* share one counter.
|
|
716
|
+
*/
|
|
717
|
+
async incrementAndMaybeLock(username, account, errorCode) {
|
|
718
|
+
const newAttempts = account.failedLoginAttempts + 1;
|
|
719
|
+
const { threshold, duration } = this.config.lockout;
|
|
720
|
+
if (threshold > 0 && newAttempts >= threshold) {
|
|
721
|
+
const lockEnds = duration ? this.config.clock() + duration : 0;
|
|
722
|
+
await this.store.update(username, {
|
|
723
|
+
inc: { "account.failedLoginAttempts": 1 },
|
|
724
|
+
set: { account: {
|
|
725
|
+
locked: true,
|
|
726
|
+
lockReason: "Too many login attempts",
|
|
727
|
+
lockEnds
|
|
728
|
+
} }
|
|
729
|
+
});
|
|
730
|
+
throw new UserAuthError(errorCode, void 0, { lockEnds });
|
|
731
|
+
}
|
|
732
|
+
await this.store.update(username, { inc: { "account.failedLoginAttempts": 1 } });
|
|
733
|
+
throw new UserAuthError(errorCode);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/store/memory.ts
|
|
738
|
+
var UserStoreMemory = class extends UserStore {
|
|
739
|
+
store = /* @__PURE__ */ new Map();
|
|
740
|
+
constructor(seed) {
|
|
741
|
+
super();
|
|
742
|
+
if (seed) for (const [key, value] of Object.entries(seed)) this.store.set(key, structuredClone(value));
|
|
743
|
+
}
|
|
744
|
+
async exists(username) {
|
|
745
|
+
return this.store.has(username);
|
|
746
|
+
}
|
|
747
|
+
async findByUsername(username) {
|
|
748
|
+
const user = this.store.get(username);
|
|
749
|
+
return user ? structuredClone(user) : null;
|
|
750
|
+
}
|
|
751
|
+
async create(data) {
|
|
752
|
+
if (this.store.has(data.username)) throw new UserAuthError("ALREADY_EXISTS", `User "${data.username}" already exists`);
|
|
753
|
+
this.store.set(data.username, structuredClone(data));
|
|
754
|
+
}
|
|
755
|
+
async update(username, update) {
|
|
756
|
+
const user = this.store.get(username);
|
|
757
|
+
if (!user) return false;
|
|
758
|
+
if (update.set) deepMerge(user, update.set);
|
|
759
|
+
if (update.inc) for (const [path, amount] of Object.entries(update.inc)) incrementAtPath(user, path, amount);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
async delete(username) {
|
|
763
|
+
return this.store.delete(username);
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/password/policies.ts
|
|
768
|
+
const ppHasMinLength = (min = 8) => ({
|
|
769
|
+
rule: `v.length >= ${min}`,
|
|
770
|
+
description: `Minimum length ${min}`,
|
|
771
|
+
errorMessage: `Password must be at least ${min} characters long`
|
|
772
|
+
});
|
|
773
|
+
const ppHasUpperCase = (n = 1) => ({
|
|
774
|
+
rule: `(v.match(/[A-Z]/g) || []).length >= ${n}`,
|
|
775
|
+
description: `At least ${n} uppercase character${n === 1 ? "" : "s"}`,
|
|
776
|
+
errorMessage: `Password must include at least ${n} uppercase character${n === 1 ? "" : "s"}`
|
|
777
|
+
});
|
|
778
|
+
const ppHasLowerCase = (n = 1) => ({
|
|
779
|
+
rule: `(v.match(/[a-z]/g) || []).length >= ${n}`,
|
|
780
|
+
description: `At least ${n} lowercase character${n === 1 ? "" : "s"}`,
|
|
781
|
+
errorMessage: `Password must include at least ${n} lowercase character${n === 1 ? "" : "s"}`
|
|
782
|
+
});
|
|
783
|
+
const ppHasNumber = (n = 1) => ({
|
|
784
|
+
rule: `(v.match(/\\d/g) || []).length >= ${n}`,
|
|
785
|
+
description: `At least ${n} number${n === 1 ? "" : "s"}`,
|
|
786
|
+
errorMessage: `Password must include at least ${n} number${n === 1 ? "" : "s"}`
|
|
787
|
+
});
|
|
788
|
+
const ppHasSpecialChar = (n = 1) => ({
|
|
789
|
+
rule: `(v.match(/[^A-Za-z0-9]/g) || []).length >= ${n}`,
|
|
790
|
+
description: `At least ${n} special character${n === 1 ? "" : "s"}`,
|
|
791
|
+
errorMessage: `Password must include at least ${n} special character${n === 1 ? "" : "s"}`
|
|
792
|
+
});
|
|
793
|
+
const ppMaxRepeatedChars = (maxRepeated = 2) => ({
|
|
794
|
+
rule: `/(.)\\1{${maxRepeated},}/.test(v) === false`,
|
|
795
|
+
description: `No more than ${maxRepeated} consecutive repeated characters`,
|
|
796
|
+
errorMessage: `Password must not have more than ${maxRepeated} consecutive repeated characters`
|
|
797
|
+
});
|
|
798
|
+
//#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 };
|