@meith/accounts 0.16.0

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/src/service.ts ADDED
@@ -0,0 +1,684 @@
1
+ import { ConflictError, ForbiddenError, ValidationError } from '@meith/core'
2
+ import { msg } from '@meith/i18n'
3
+
4
+ import { type BanFilterSubject, matchBanFilter } from './ban-filter'
5
+ import { foldIdentifier } from './case-fold'
6
+ import { hashPassword, needsRehash, verifyPassword } from './crypto/password'
7
+ import { generateToken, hashToken } from './crypto/tokens'
8
+ import type {
9
+ AccountRecord,
10
+ AccountStore,
11
+ AuthConfig,
12
+ BanFilterRepository,
13
+ BanRecord,
14
+ Clock,
15
+ LoginBucket,
16
+ } from './ports'
17
+ import { REGISTER_FIELD } from './register-fields'
18
+
19
+ export interface IdentityDeps {
20
+ readonly store: AccountStore
21
+ readonly config: AuthConfig
22
+ readonly clock?: Clock
23
+ readonly banFilters?: BanFilterRepository
24
+ readonly bans?: BanLookup
25
+ readonly secondFactor?: SecondFactorLookup
26
+ }
27
+
28
+ /**
29
+ * Whether an account asks for something beyond its password. Kept as a port
30
+ * rather than the whole two-factor service so that the login path cannot reach
31
+ * the secrets it holds — it only ever needs the yes or no.
32
+ */
33
+ export interface SecondFactorLookup {
34
+ isEnrolled(userId: number): Promise<boolean>
35
+ }
36
+
37
+ export interface BanLookup {
38
+ findActive(userId: number): Promise<BanRecord | null>
39
+ }
40
+
41
+ export interface RequestContext {
42
+ readonly ip?: string | undefined
43
+ readonly ipPrefix?: string | null | undefined
44
+ readonly userAgent?: string | null | undefined
45
+ }
46
+
47
+ export interface RegisterInput {
48
+ readonly username: string
49
+ readonly email: string
50
+ readonly password: string
51
+ }
52
+
53
+ export interface RegisterResult {
54
+ readonly account: AccountRecord
55
+ readonly verificationToken?: string
56
+ }
57
+
58
+ export interface LoginResult {
59
+ readonly account: AccountRecord
60
+ readonly sessionToken: string
61
+ readonly expiresAt: Date
62
+ }
63
+
64
+ /**
65
+ * How long a half-finished sign-in waits for its second factor. Long enough to
66
+ * fetch a phone from another room, short enough that a password proven on a
67
+ * shared machine does not stay proven.
68
+ */
69
+ export const SECOND_FACTOR_TTL_MINUTES = 10
70
+
71
+ export type LoginOutcome =
72
+ | { readonly status: 'signed-in'; readonly login: LoginResult }
73
+ | {
74
+ readonly status: 'second-factor'
75
+ readonly account: AccountRecord
76
+ readonly token: string
77
+ readonly expiresAt: Date
78
+ }
79
+
80
+ export interface PendingSecondFactor {
81
+ readonly userId: number
82
+ readonly remember: boolean
83
+ }
84
+
85
+ export interface ResetRequest {
86
+ readonly token: string | null
87
+ readonly userId: number | null
88
+ }
89
+
90
+ export const VERIFICATION_TTL_HOURS = 24
91
+
92
+ export type ActivationOutcome =
93
+ | 'activated'
94
+ | 'awaiting-approval'
95
+ | 'invalid'
96
+ | 'already-active'
97
+ | 'banned'
98
+
99
+ export interface ActivationResult {
100
+ readonly outcome: ActivationOutcome
101
+ readonly userId: number | null
102
+ }
103
+
104
+ export interface ResendVerification {
105
+ readonly token: string | null
106
+ readonly account: AccountRecord | null
107
+ }
108
+
109
+ const USERNAME_RE = /^[\p{L}\p{N}][\p{L}\p{N} ._-]*$/u
110
+
111
+ const USERNAME_CHARACTER_RE = /[\p{L}\p{N} ._-]/u
112
+
113
+ const USERNAME_ATTEMPTS = 50
114
+
115
+ const FALLBACK_USERNAME = 'member'
116
+
117
+ export interface FederatedProvision {
118
+ readonly username: string
119
+ readonly email: string
120
+ readonly emailVerified: boolean
121
+ }
122
+
123
+ export const REGISTRATION_CLOSED = 'This board is not taking new members at the moment.'
124
+
125
+ export class IdentityService {
126
+ private readonly store: AccountStore
127
+ private readonly config: AuthConfig
128
+ private readonly now: Clock
129
+
130
+ private readonly banFilters: BanFilterRepository | undefined
131
+
132
+ private readonly bans: BanLookup | undefined
133
+
134
+ private readonly secondFactor: SecondFactorLookup | undefined
135
+
136
+ constructor(deps: IdentityDeps) {
137
+ this.store = deps.store
138
+ this.config = deps.config
139
+ this.now = deps.clock ?? (() => new Date())
140
+ this.banFilters = deps.banFilters
141
+ this.bans = deps.bans
142
+ this.secondFactor = deps.secondFactor
143
+ }
144
+
145
+ async register(input: RegisterInput, context: RequestContext = {}): Promise<RegisterResult> {
146
+ if (!this.config.registrationEnabled) {
147
+ throw new ForbiddenError(REGISTRATION_CLOSED)
148
+ }
149
+
150
+ const username = input.username.trim()
151
+ const email = input.email.trim()
152
+ const usernameLower = foldIdentifier(username)
153
+ const emailLower = foldIdentifier(email)
154
+
155
+ await this.assertNotFiltered({ username, email, ip: context.ip })
156
+
157
+ this.assertUsername(username, usernameLower)
158
+ this.assertEmail(email)
159
+ if (input.password.length < this.config.minPasswordLength) {
160
+ throw new ValidationError(
161
+ `Password must be at least ${this.config.minPasswordLength} characters.`,
162
+ {},
163
+ { meta: { field: REGISTER_FIELD.password } },
164
+ )
165
+ }
166
+
167
+ if (await this.store.accounts.findByUsernameLower(usernameLower)) {
168
+ throw new ConflictError(msg('error.accounts.username-taken'), {
169
+ meta: { field: REGISTER_FIELD.username },
170
+ })
171
+ }
172
+ if (await this.store.accounts.findByEmailLower(emailLower)) {
173
+ throw new ConflictError(msg('error.accounts.email-already-registered'), {
174
+ meta: { field: REGISTER_FIELD.email },
175
+ })
176
+ }
177
+
178
+ const encoded = await hashPassword(input.password)
179
+
180
+ const state = this.config.activationMethod === 'none' ? 'active' : 'awaiting_activation'
181
+
182
+ const account = await this.store.accounts.create({
183
+ username,
184
+ usernameLower,
185
+ email,
186
+ emailLower,
187
+ passwordHash: encoded,
188
+ passwordAlgo: PASSWORD_ALGO,
189
+ state,
190
+ primaryGroupId: this.config.defaultMemberGroupId,
191
+ registrationIpPrefix: prefixOf(context),
192
+ })
193
+
194
+ if (this.verifiesEmail()) {
195
+ return { account, verificationToken: await this.issueVerification(account.id) }
196
+ }
197
+
198
+ return { account }
199
+ }
200
+
201
+ async provisionFederated(
202
+ input: FederatedProvision,
203
+ context: RequestContext = {},
204
+ ): Promise<RegisterResult> {
205
+ if (!this.config.registrationEnabled) {
206
+ throw new ForbiddenError(REGISTRATION_CLOSED)
207
+ }
208
+
209
+ const email = input.email.trim()
210
+ const emailLower = foldIdentifier(email)
211
+
212
+ this.assertEmail(email)
213
+ await this.assertNotFiltered({ email, ip: context.ip })
214
+
215
+ if (await this.store.accounts.findByEmailLower(emailLower)) {
216
+ throw new ConflictError(msg('error.accounts.account-here-already-uses-address'), {
217
+ meta: { field: REGISTER_FIELD.email },
218
+ })
219
+ }
220
+
221
+ const username = await this.availableUsername(input.username)
222
+ await this.assertNotFiltered({ username })
223
+
224
+ const created = await this.store.accounts.create({
225
+ username,
226
+ usernameLower: foldIdentifier(username),
227
+ email,
228
+ emailLower,
229
+ passwordHash: null,
230
+ passwordAlgo: null,
231
+ state: this.config.activationMethod === 'none' ? 'active' : 'awaiting_activation',
232
+ primaryGroupId: this.config.defaultMemberGroupId,
233
+ registrationIpPrefix: prefixOf(context),
234
+ })
235
+
236
+ if (!this.verifiesEmail()) return { account: created }
237
+
238
+ if (!input.emailVerified) {
239
+ return {
240
+ account: created,
241
+ verificationToken: await this.issueVerification(created.id),
242
+ }
243
+ }
244
+
245
+ await this.store.accounts.markEmailVerified(
246
+ created.id,
247
+ this.now(),
248
+ this.config.activationMethod !== 'both',
249
+ )
250
+
251
+ return { account: (await this.store.accounts.findById(created.id)) ?? created }
252
+ }
253
+
254
+ private async availableUsername(suggestion: string): Promise<string> {
255
+ const base = this.usernameStem(suggestion)
256
+
257
+ for (let attempt = 0; attempt < USERNAME_ATTEMPTS; attempt += 1) {
258
+ const suffix = attempt === 0 ? '' : String(attempt + 1)
259
+ const stem = trimToLength(base, this.config.usernameMax - suffix.length)
260
+ const candidate = `${stem}${suffix}`
261
+
262
+ const lower = foldIdentifier(candidate)
263
+ if (this.config.reservedUsernames.includes(lower)) continue
264
+ if (await this.store.accounts.findByUsernameLower(lower)) continue
265
+
266
+ return candidate
267
+ }
268
+
269
+ throw new ConflictError(msg('error.accounts.could-settle-free-username-for'), {
270
+ meta: { field: REGISTER_FIELD.username },
271
+ })
272
+ }
273
+
274
+ private usernameStem(suggestion: string): string {
275
+ const cleaned = [...suggestion.normalize('NFC')]
276
+ .filter((character) => USERNAME_CHARACTER_RE.test(character))
277
+ .join('')
278
+ .replace(/\s+/gu, ' ')
279
+ .trim()
280
+ .replace(/^[^\p{L}\p{N}]+/u, '')
281
+
282
+ const stem = trimToLength(cleaned, this.config.usernameMax)
283
+ if (codePointLength(stem) < this.config.usernameMin) {
284
+ return trimToLength(
285
+ FALLBACK_USERNAME.padEnd(this.config.usernameMin, '0'),
286
+ this.config.usernameMax,
287
+ )
288
+ }
289
+ return stem
290
+ }
291
+
292
+ private verifiesEmail(): boolean {
293
+ return this.config.activationMethod === 'email' || this.config.activationMethod === 'both'
294
+ }
295
+
296
+ private async issueVerification(userId: number): Promise<string> {
297
+ const token = generateToken()
298
+ await this.store.tokens.issue({
299
+ tokenHash: await hashToken(token),
300
+ userId,
301
+ purpose: 'email_verification',
302
+ expiresAt: new Date(this.now().getTime() + VERIFICATION_TTL_HOURS * 60 * 60 * 1000),
303
+ })
304
+ return token
305
+ }
306
+
307
+ async activateAccount(token: string): Promise<ActivationResult> {
308
+ const redeemed = await this.store.tokens.consume(
309
+ await hashToken(token),
310
+ 'email_verification',
311
+ this.now(),
312
+ )
313
+ if (!redeemed) return { outcome: 'invalid', userId: null }
314
+
315
+ const userId = redeemed.userId
316
+ const needsApproval = this.config.activationMethod === 'both'
317
+ const previous = await this.store.accounts.markEmailVerified(userId, this.now(), !needsApproval)
318
+
319
+ if (previous === null) return { outcome: 'invalid', userId: null }
320
+ if (previous === 'banned') return { outcome: 'banned', userId }
321
+ if (previous === 'active') return { outcome: 'already-active', userId }
322
+ return { outcome: needsApproval ? 'awaiting-approval' : 'activated', userId }
323
+ }
324
+
325
+ async resendVerification(email: string): Promise<ResendVerification> {
326
+ if (!this.verifiesEmail()) return { token: null, account: null }
327
+
328
+ const account = await this.store.accounts.findByEmailLower(foldIdentifier(email))
329
+ if (!account) return { token: null, account: null }
330
+ if (account.state !== 'awaiting_activation') return { token: null, account: null }
331
+ if (account.emailVerifiedAt !== null) return { token: null, account: null }
332
+
333
+ await this.store.tokens.revokeAllForUser(account.id, 'email_verification')
334
+ return { token: await this.issueVerification(account.id), account }
335
+ }
336
+
337
+ async login(
338
+ identifier: string,
339
+ password: string,
340
+ buckets: string | readonly LoginBucket[],
341
+ context: RequestContext = {},
342
+ options: { readonly remember?: boolean } = {},
343
+ ): Promise<LoginOutcome> {
344
+ const at = this.now()
345
+ const counters: readonly LoginBucket[] =
346
+ typeof buckets === 'string' ? [{ key: buckets }] : buckets
347
+
348
+ await this.assertNotFiltered({ ip: context.ip })
349
+
350
+ const since = new Date(at.getTime() - this.config.lockoutMinutes * 60_000)
351
+ for (const counter of counters) {
352
+ const max = counter.max ?? this.config.maxLoginAttempts
353
+ if (max <= 0) continue
354
+ const failures = await this.store.loginAttempts.countFailuresSince(counter.key, since)
355
+ if (failures >= max) {
356
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
357
+ }
358
+ }
359
+
360
+ const idLower = foldIdentifier(identifier)
361
+ const account =
362
+ (await this.store.accounts.findByUsernameLower(idLower)) ??
363
+ (await this.store.accounts.findByEmailLower(idLower))
364
+
365
+ const encoded = account?.passwordHash ?? (await dummyHash())
366
+ const ok = await verifyPassword(password, encoded)
367
+
368
+ const recordFailure = async (): Promise<void> => {
369
+ for (const counter of counters) {
370
+ await this.store.loginAttempts.record(counter.key, false, at)
371
+ }
372
+ }
373
+
374
+ if (!account || !ok || account.passwordHash === null) {
375
+ await recordFailure()
376
+ throw new ValidationError(msg('error.accounts.incorrect-username-password'))
377
+ }
378
+
379
+ const refusal = await this.signInRefusal(account)
380
+ if (refusal !== null) {
381
+ await recordFailure()
382
+ throw new ForbiddenError(refusal)
383
+ }
384
+
385
+ await this.assertNotFiltered({ username: account.username, email: account.email })
386
+
387
+ for (const counter of counters) {
388
+ if (counter.clearOnSuccess === false) continue
389
+ await this.store.loginAttempts.record(counter.key, true, at)
390
+ await this.store.loginAttempts.clear(counter.key)
391
+ }
392
+
393
+ if (needsRehash(encoded)) {
394
+ const upgraded = await hashPassword(password)
395
+ await this.store.accounts.updatePassword(account.id, upgraded, PASSWORD_ALGO)
396
+ }
397
+
398
+ const prefix = prefixOf(context)
399
+ if (prefix !== null) {
400
+ await this.store.accounts.recordLastIpPrefix(account.id, prefix)
401
+ }
402
+
403
+ if (await this.secondFactor?.isEnrolled(account.id)) {
404
+ return {
405
+ status: 'second-factor',
406
+ account,
407
+ ...(await this.holdForSecondFactor(account.id, options.remember === true, at)),
408
+ }
409
+ }
410
+
411
+ return { status: 'signed-in', login: await this.startSession(account, at, context) }
412
+ }
413
+
414
+ private async holdForSecondFactor(
415
+ userId: number,
416
+ remember: boolean,
417
+ at: Date,
418
+ ): Promise<{ token: string; expiresAt: Date }> {
419
+ await this.store.tokens.revokeAllForUser(userId, 'second_factor')
420
+
421
+ const token = generateToken()
422
+ const expiresAt = new Date(at.getTime() + SECOND_FACTOR_TTL_MINUTES * 60_000)
423
+
424
+ await this.store.tokens.issue({
425
+ tokenHash: await hashToken(token),
426
+ userId,
427
+ purpose: 'second_factor',
428
+ payload: remember ? 'remember' : null,
429
+ expiresAt,
430
+ })
431
+
432
+ return { token, expiresAt }
433
+ }
434
+
435
+ /**
436
+ * Who a half-finished sign-in belongs to, without spending it — the code can
437
+ * be mistyped, and a member who fumbles it should not have to start from
438
+ * their password again.
439
+ */
440
+ async pendingSecondFactor(token: string): Promise<PendingSecondFactor | null> {
441
+ const held = await this.store.tokens.peek(await hashToken(token), 'second_factor', this.now())
442
+ if (held === null) return null
443
+
444
+ return { userId: held.userId, remember: held.payload === 'remember' }
445
+ }
446
+
447
+ /** Spends the hold and starts the session it was standing in for. */
448
+ async redeemSecondFactor(token: string, context: RequestContext = {}): Promise<LoginResult> {
449
+ const at = this.now()
450
+ const redeemed = await this.store.tokens.consume(await hashToken(token), 'second_factor', at)
451
+
452
+ if (redeemed === null) {
453
+ throw new ForbiddenError(msg('error.accounts.sign-in-took-too-long-finish'))
454
+ }
455
+
456
+ const account = await this.store.accounts.findById(redeemed.userId)
457
+ if (account === null) throw new ForbiddenError(msg('error.accounts.account-longer-exists'))
458
+
459
+ await this.assertSignInAllowed(account)
460
+ return this.startSession(account, at, context)
461
+ }
462
+
463
+ async abandonSecondFactor(userId: number): Promise<void> {
464
+ await this.store.tokens.revokeAllForUser(userId, 'second_factor')
465
+ }
466
+
467
+ /**
468
+ * The second step gets its own counter. The password counters were cleared
469
+ * when the password proved out, and a six-digit code is worth a million
470
+ * guesses — far fewer than a password, and so worth far less patience.
471
+ */
472
+ async assertSecondFactorAttemptsLeft(userId: number): Promise<void> {
473
+ const max = this.config.maxLoginAttempts
474
+ if (max <= 0) return
475
+
476
+ const since = new Date(this.now().getTime() - this.config.lockoutMinutes * 60_000)
477
+ const failures = await this.store.loginAttempts.countFailuresSince(
478
+ secondFactorBucket(userId),
479
+ since,
480
+ )
481
+
482
+ if (failures >= max) {
483
+ throw new ForbiddenError(msg('error.accounts.too-many-wrong-codes-please'))
484
+ }
485
+ }
486
+
487
+ async recordSecondFactorFailure(userId: number): Promise<void> {
488
+ await this.store.loginAttempts.record(secondFactorBucket(userId), false, this.now())
489
+ }
490
+
491
+ async clearSecondFactorFailures(userId: number): Promise<void> {
492
+ await this.store.loginAttempts.clear(secondFactorBucket(userId))
493
+ }
494
+
495
+ private async signInRefusal(account: AccountRecord): Promise<string | null> {
496
+ const ban = await this.bans?.findActive(account.id)
497
+ if (ban || account.state === 'banned') {
498
+ return ban?.publicReason
499
+ ? `This account is banned: ${ban.publicReason}`
500
+ : 'This account is banned.'
501
+ }
502
+ if (account.state === 'awaiting_activation') {
503
+ return 'This account is not yet activated.'
504
+ }
505
+ return null
506
+ }
507
+
508
+ async assertSignInAllowed(account: AccountRecord): Promise<void> {
509
+ const refusal = await this.signInRefusal(account)
510
+ if (refusal !== null) throw new ForbiddenError(refusal)
511
+
512
+ await this.assertNotFiltered({ username: account.username, email: account.email })
513
+ }
514
+
515
+ async startSessionFor(
516
+ account: AccountRecord,
517
+ context: RequestContext = {},
518
+ ): Promise<LoginResult> {
519
+ await this.assertSignInAllowed(account)
520
+
521
+ const prefix = prefixOf(context)
522
+ if (prefix !== null) {
523
+ await this.store.accounts.recordLastIpPrefix(account.id, prefix)
524
+ }
525
+
526
+ return this.startSession(account, this.now(), context)
527
+ }
528
+
529
+ private async assertNotFiltered(subject: BanFilterSubject): Promise<void> {
530
+ if (!this.banFilters) return
531
+
532
+ const match = matchBanFilter(await this.banFilters.listAll(), subject)
533
+ if (match) {
534
+ throw new ForbiddenError(msg('error.accounts.account-used-board-contact-administrator'))
535
+ }
536
+ }
537
+
538
+ async logout(sessionToken: string): Promise<void> {
539
+ const session = await this.store.sessions.findByTokenHash(await hashToken(sessionToken))
540
+ if (session) await this.store.sessions.revoke(session.id)
541
+ }
542
+
543
+ async resolveSession(sessionToken: string): Promise<{ userId: number } | null> {
544
+ const located = await this.locateSession(sessionToken)
545
+ if (located === null) return null
546
+ return { userId: located.userId }
547
+ }
548
+
549
+ async locateSession(sessionToken: string): Promise<{ sessionId: number; userId: number } | null> {
550
+ const session = await this.store.sessions.findByTokenHash(await hashToken(sessionToken))
551
+ if (!session) return null
552
+ if (session.revokedAt !== null) return null
553
+ if (session.expiresAt.getTime() <= this.now().getTime()) return null
554
+ return { sessionId: session.id, userId: session.userId }
555
+ }
556
+
557
+ async requestPasswordReset(email: string): Promise<ResetRequest> {
558
+ const account = await this.store.accounts.findByEmailLower(foldIdentifier(email))
559
+ if (!account) return { token: null, userId: null }
560
+
561
+ await this.store.tokens.revokeAllForUser(account.id, 'password_reset')
562
+
563
+ const token = generateToken()
564
+ await this.store.tokens.issue({
565
+ tokenHash: await hashToken(token),
566
+ userId: account.id,
567
+ purpose: 'password_reset',
568
+ expiresAt: new Date(this.now().getTime() + this.config.resetTokenTtlMinutes * 60_000),
569
+ })
570
+ return { token, userId: account.id }
571
+ }
572
+
573
+ async redeemPasswordReset(
574
+ token: string,
575
+ newPassword: string,
576
+ ): Promise<{ readonly userId: number }> {
577
+ if (newPassword.length < this.config.minPasswordLength) {
578
+ throw new ValidationError(
579
+ `Password must be at least ${this.config.minPasswordLength} characters.`,
580
+ )
581
+ }
582
+
583
+ const redeemed = await this.store.tokens.consume(
584
+ await hashToken(token),
585
+ 'password_reset',
586
+ this.now(),
587
+ )
588
+ if (!redeemed) {
589
+ throw new ValidationError(msg('error.accounts.reset-link-invalid-expired'))
590
+ }
591
+
592
+ const encoded = await hashPassword(newPassword)
593
+ const at = this.now()
594
+ await this.store.accounts.updatePassword(redeemed.userId, encoded, PASSWORD_ALGO)
595
+ await this.store.sessions.revokeAllForUser(redeemed.userId)
596
+ await this.store.remember.revokeAllForUser(redeemed.userId, 'password_reset', at)
597
+ await this.store.tokens.revokeAllForUser(redeemed.userId, 'email_change')
598
+
599
+ return { userId: redeemed.userId }
600
+ }
601
+
602
+ private async startSession(
603
+ account: AccountRecord,
604
+ at: Date,
605
+ context: RequestContext = {},
606
+ ): Promise<LoginResult> {
607
+ const token = generateToken()
608
+ const expiresAt = new Date(at.getTime() + this.config.sessionLifetimeDays * 86_400_000)
609
+ await this.store.sessions.create({
610
+ tokenHash: await hashToken(token),
611
+ userId: account.id,
612
+ expiresAt,
613
+ ipPrefix: prefixOf(context),
614
+ userAgent: context.userAgent ?? null,
615
+ })
616
+ return { account, sessionToken: token, expiresAt }
617
+ }
618
+
619
+ private assertUsername(username: string, usernameLower: string): void {
620
+ const length = codePointLength(username)
621
+ if (length < this.config.usernameMin || length > this.config.usernameMax) {
622
+ throw new ValidationError(
623
+ `Username must be between ${this.config.usernameMin} and ${this.config.usernameMax} characters.`,
624
+ {},
625
+ { meta: { field: REGISTER_FIELD.username } },
626
+ )
627
+ }
628
+ if (!USERNAME_RE.test(username)) {
629
+ throw new ValidationError(
630
+ msg('error.accounts.username-contains-invalid-characters'),
631
+ {},
632
+ {
633
+ meta: { field: REGISTER_FIELD.username },
634
+ },
635
+ )
636
+ }
637
+ if (this.config.reservedUsernames.includes(usernameLower)) {
638
+ throw new ConflictError(msg('error.accounts.username-reserved-pick-another-board'), {
639
+ meta: { field: REGISTER_FIELD.username },
640
+ })
641
+ }
642
+ }
643
+
644
+ private assertEmail(email: string): void {
645
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
646
+ throw new ValidationError(
647
+ msg('error.accounts.please-enter-valid-email-address'),
648
+ {},
649
+ {
650
+ meta: { field: REGISTER_FIELD.email },
651
+ },
652
+ )
653
+ }
654
+ }
655
+ }
656
+
657
+ function secondFactorBucket(userId: number): string {
658
+ return `second-factor:${userId}`
659
+ }
660
+
661
+ function codePointLength(value: string): number {
662
+ return [...value].length
663
+ }
664
+
665
+ function trimToLength(value: string, max: number): string {
666
+ return [...value].slice(0, Math.max(max, 1)).join('').trim()
667
+ }
668
+
669
+ function prefixOf(context: RequestContext): string | null {
670
+ const prefix = context.ipPrefix
671
+ if (prefix === undefined || prefix === null) return null
672
+ const value = prefix.trim()
673
+ return value === '' ? null : value
674
+ }
675
+
676
+ let dummyHashPromise: Promise<string> | null = null
677
+ function dummyHash(): Promise<string> {
678
+ if (dummyHashPromise === null) {
679
+ dummyHashPromise = hashPassword(generateToken())
680
+ }
681
+ return dummyHashPromise
682
+ }
683
+
684
+ const PASSWORD_ALGO = 'argon2id'