@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/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/ban-filter.ts +127 -0
- package/src/ban-service.ts +71 -0
- package/src/case-fold.ts +3 -0
- package/src/credential-proof.ts +17 -0
- package/src/crypto/base64url.ts +53 -0
- package/src/crypto/legacy.ts +127 -0
- package/src/crypto/password.ts +92 -0
- package/src/crypto/tokens.ts +28 -0
- package/src/federation/catalog.ts +115 -0
- package/src/federation/github.ts +117 -0
- package/src/federation/http.ts +48 -0
- package/src/federation/jwt.ts +198 -0
- package/src/federation/oidc.ts +175 -0
- package/src/federation/pkce.ts +20 -0
- package/src/federation/service.ts +198 -0
- package/src/federation/types.ts +50 -0
- package/src/index.ts +223 -0
- package/src/member-settings.ts +390 -0
- package/src/memory-bans.ts +88 -0
- package/src/memory-repos.ts +665 -0
- package/src/policy.ts +104 -0
- package/src/ports.ts +415 -0
- package/src/register-fields.ts +17 -0
- package/src/service.ts +684 -0
- package/src/session-service.ts +134 -0
- package/src/test-support.fixture.ts +8 -0
- package/src/totp/base32.ts +52 -0
- package/src/totp/secret-box.ts +83 -0
- package/src/totp/service.ts +252 -0
- package/src/totp/totp.ts +120 -0
- package/src/webauthn/authenticator.fixture.ts +268 -0
- package/src/webauthn/cbor.ts +110 -0
- package/src/webauthn/cose.ts +130 -0
- package/src/webauthn/service.ts +266 -0
- package/src/webauthn/verify.ts +231 -0
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'
|