@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/policy.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { AuthConfig } from './ports'
|
|
2
|
+
|
|
3
|
+
export type AuthPolicy = Omit<AuthConfig, 'activationMethod' | 'defaultMemberGroupId'>
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_AUTH_POLICY: AuthPolicy = {
|
|
6
|
+
registrationEnabled: true,
|
|
7
|
+
minPasswordLength: 8,
|
|
8
|
+
usernameMin: 3,
|
|
9
|
+
usernameMax: 30,
|
|
10
|
+
maxLoginAttempts: 5,
|
|
11
|
+
maxAccountLoginAttempts: 50,
|
|
12
|
+
lockoutMinutes: 15,
|
|
13
|
+
sessionLifetimeDays: 14,
|
|
14
|
+
resetTokenTtlMinutes: 60,
|
|
15
|
+
reservedUsernames: [
|
|
16
|
+
'admin',
|
|
17
|
+
'administrator',
|
|
18
|
+
'root',
|
|
19
|
+
'moderator',
|
|
20
|
+
'mod',
|
|
21
|
+
'staff',
|
|
22
|
+
'system',
|
|
23
|
+
'guest',
|
|
24
|
+
'anonymous',
|
|
25
|
+
'me',
|
|
26
|
+
'you',
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AUTH_SETTING_KEYS = {
|
|
31
|
+
registrationEnabled: 'registration.enabled',
|
|
32
|
+
activationMethod: 'registration.method',
|
|
33
|
+
minPasswordLength: 'registration.min_password_length',
|
|
34
|
+
usernameMin: 'registration.username_min',
|
|
35
|
+
usernameMax: 'registration.username_max',
|
|
36
|
+
maxLoginAttempts: 'security.max_login_attempts',
|
|
37
|
+
maxAccountLoginAttempts: 'security.max_account_login_attempts',
|
|
38
|
+
lockoutMinutes: 'security.lockout_minutes',
|
|
39
|
+
sessionLifetimeDays: 'security.session_idle_days',
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
export type SettingReader = (key: string) => unknown
|
|
43
|
+
|
|
44
|
+
export interface ResolvedAuthSettings {
|
|
45
|
+
readonly registrationEnabled: boolean
|
|
46
|
+
readonly activationMethod: AuthConfig['activationMethod']
|
|
47
|
+
readonly minPasswordLength: number
|
|
48
|
+
readonly usernameMin: number
|
|
49
|
+
readonly usernameMax: number
|
|
50
|
+
readonly maxLoginAttempts: number
|
|
51
|
+
readonly maxAccountLoginAttempts: number
|
|
52
|
+
readonly lockoutMinutes: number
|
|
53
|
+
readonly sessionLifetimeDays: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ACTIVATION_METHODS: readonly AuthConfig['activationMethod'][] = [
|
|
57
|
+
'none',
|
|
58
|
+
'email',
|
|
59
|
+
'admin',
|
|
60
|
+
'both',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export function resolveAuthPolicy(
|
|
64
|
+
read: SettingReader,
|
|
65
|
+
base: AuthPolicy & { readonly activationMethod: AuthConfig['activationMethod'] },
|
|
66
|
+
): ResolvedAuthSettings {
|
|
67
|
+
const method = read(AUTH_SETTING_KEYS.activationMethod)
|
|
68
|
+
const min = read(AUTH_SETTING_KEYS.usernameMin)
|
|
69
|
+
const max = read(AUTH_SETTING_KEYS.usernameMax)
|
|
70
|
+
|
|
71
|
+
const usernameMin = positiveInteger(min) ?? base.usernameMin
|
|
72
|
+
const usernameMax = positiveInteger(max) ?? base.usernameMax
|
|
73
|
+
const impossible = usernameMin > usernameMax
|
|
74
|
+
|
|
75
|
+
const enabled = read(AUTH_SETTING_KEYS.registrationEnabled)
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
registrationEnabled: typeof enabled === 'boolean' ? enabled : base.registrationEnabled,
|
|
79
|
+
activationMethod:
|
|
80
|
+
typeof method === 'string' && (ACTIVATION_METHODS as readonly string[]).includes(method)
|
|
81
|
+
? (method as AuthConfig['activationMethod'])
|
|
82
|
+
: base.activationMethod,
|
|
83
|
+
minPasswordLength:
|
|
84
|
+
positiveInteger(read(AUTH_SETTING_KEYS.minPasswordLength)) ?? base.minPasswordLength,
|
|
85
|
+
usernameMin: impossible ? base.usernameMin : usernameMin,
|
|
86
|
+
usernameMax: impossible ? base.usernameMax : usernameMax,
|
|
87
|
+
maxLoginAttempts:
|
|
88
|
+
countingInteger(read(AUTH_SETTING_KEYS.maxLoginAttempts)) ?? base.maxLoginAttempts,
|
|
89
|
+
maxAccountLoginAttempts:
|
|
90
|
+
countingInteger(read(AUTH_SETTING_KEYS.maxAccountLoginAttempts)) ??
|
|
91
|
+
base.maxAccountLoginAttempts,
|
|
92
|
+
lockoutMinutes: positiveInteger(read(AUTH_SETTING_KEYS.lockoutMinutes)) ?? base.lockoutMinutes,
|
|
93
|
+
sessionLifetimeDays:
|
|
94
|
+
positiveInteger(read(AUTH_SETTING_KEYS.sessionLifetimeDays)) ?? base.sessionLifetimeDays,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function positiveInteger(value: unknown): number | null {
|
|
99
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function countingInteger(value: unknown): number | null {
|
|
103
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null
|
|
104
|
+
}
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import type { BanFilter } from './ban-filter'
|
|
2
|
+
|
|
3
|
+
export type AccountState = 'active' | 'awaiting_activation' | 'banned'
|
|
4
|
+
|
|
5
|
+
export interface AccountRecord {
|
|
6
|
+
readonly id: number
|
|
7
|
+
readonly username: string
|
|
8
|
+
readonly usernameLower: string
|
|
9
|
+
readonly email: string
|
|
10
|
+
readonly emailLower: string
|
|
11
|
+
readonly passwordHash: string | null
|
|
12
|
+
readonly passwordAlgo: string | null
|
|
13
|
+
readonly state: AccountState
|
|
14
|
+
readonly emailVerifiedAt: Date | null
|
|
15
|
+
readonly primaryGroupId: number | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NewAccount {
|
|
19
|
+
readonly username: string
|
|
20
|
+
readonly usernameLower: string
|
|
21
|
+
readonly email: string
|
|
22
|
+
readonly emailLower: string
|
|
23
|
+
readonly passwordHash: string | null
|
|
24
|
+
readonly passwordAlgo: string | null
|
|
25
|
+
readonly state: AccountState
|
|
26
|
+
readonly primaryGroupId: number
|
|
27
|
+
readonly registrationIpPrefix?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AccountRepository {
|
|
31
|
+
findById(id: number): Promise<AccountRecord | null>
|
|
32
|
+
findByUsernameLower(usernameLower: string): Promise<AccountRecord | null>
|
|
33
|
+
findByEmailLower(emailLower: string): Promise<AccountRecord | null>
|
|
34
|
+
create(input: NewAccount): Promise<AccountRecord>
|
|
35
|
+
updatePassword(userId: number, passwordHash: string, passwordAlgo: string): Promise<void>
|
|
36
|
+
setState(userId: number, state: AccountState): Promise<void>
|
|
37
|
+
markEmailVerified(userId: number, at: Date, activate: boolean): Promise<AccountState | null>
|
|
38
|
+
touchLastActive(userId: number, now: Date, windowSeconds: number): Promise<boolean>
|
|
39
|
+
recordLastIpPrefix(userId: number, prefix: string): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MemberProfileRecord {
|
|
43
|
+
readonly id: number
|
|
44
|
+
readonly username: string
|
|
45
|
+
readonly title: string | null
|
|
46
|
+
readonly postCount: number
|
|
47
|
+
readonly createdAt: Date
|
|
48
|
+
readonly lastActiveAt: Date | null
|
|
49
|
+
readonly location: string | null
|
|
50
|
+
readonly website: string | null
|
|
51
|
+
readonly bio: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MemberSuggestion {
|
|
55
|
+
readonly id: number
|
|
56
|
+
readonly username: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MemberProfileRepository {
|
|
60
|
+
findPublicById(id: number): Promise<MemberProfileRecord | null>
|
|
61
|
+
searchByUsernamePrefix(prefix: string, limit: number): Promise<readonly MemberSuggestion[]>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SessionRecord {
|
|
65
|
+
readonly id: number
|
|
66
|
+
readonly userId: number
|
|
67
|
+
readonly expiresAt: Date
|
|
68
|
+
readonly revokedAt: Date | null
|
|
69
|
+
readonly supersededBySessionId: number | null
|
|
70
|
+
readonly credentialProvedAt: Date | null
|
|
71
|
+
readonly lastSeenAt: Date
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SessionLocation {
|
|
75
|
+
readonly path: string | null
|
|
76
|
+
readonly forumId: number | null
|
|
77
|
+
readonly threadId: number | null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ActiveSessionRecord {
|
|
81
|
+
readonly id: number
|
|
82
|
+
readonly createdAt: Date
|
|
83
|
+
readonly lastSeenAt: Date
|
|
84
|
+
readonly expiresAt: Date
|
|
85
|
+
readonly ipPrefix: string | null
|
|
86
|
+
readonly userAgent: string | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SessionRepository {
|
|
90
|
+
create(input: {
|
|
91
|
+
tokenHash: string
|
|
92
|
+
userId: number
|
|
93
|
+
expiresAt: Date
|
|
94
|
+
ipPrefix?: string | null
|
|
95
|
+
userAgent?: string | null
|
|
96
|
+
}): Promise<SessionRecord>
|
|
97
|
+
findByTokenHash(tokenHash: string): Promise<SessionRecord | null>
|
|
98
|
+
markCredentialProved(sessionId: number, userId: number, at: Date): Promise<boolean>
|
|
99
|
+
listActiveForUser(userId: number, now: Date): Promise<readonly ActiveSessionRecord[]>
|
|
100
|
+
revoke(sessionId: number): Promise<void>
|
|
101
|
+
revokeOwned(userId: number, sessionId: number, now: Date): Promise<boolean>
|
|
102
|
+
revokeAllForUserExcept(userId: number, sessionId: number | null): Promise<number>
|
|
103
|
+
revokeAllForUser(userId: number): Promise<void>
|
|
104
|
+
supersede(oldSessionId: number, newSessionId: number, now: Date): Promise<void>
|
|
105
|
+
touchLocation(
|
|
106
|
+
sessionId: number,
|
|
107
|
+
location: SessionLocation,
|
|
108
|
+
now: Date,
|
|
109
|
+
windowSeconds: number,
|
|
110
|
+
): Promise<boolean>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type RememberRotation =
|
|
114
|
+
| { readonly status: 'rotated'; readonly userId: number; readonly familyId: string }
|
|
115
|
+
| { readonly status: 'reuse'; readonly userId: number; readonly familyId: string }
|
|
116
|
+
| { readonly status: 'invalid' }
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How long after a remember token is rotated its old value is still honoured.
|
|
120
|
+
*
|
|
121
|
+
* Rotation is single-use, and two requests carrying the same cookie arrive
|
|
122
|
+
* whenever a browser restores several tabs at once, a link is double-clicked, or
|
|
123
|
+
* a prefetch races the navigation that prompted it. Exactly one wins the claim;
|
|
124
|
+
* without this window the others are indistinguishable from a stolen token and
|
|
125
|
+
* cost the member every session they hold. Real theft reuses a token long after
|
|
126
|
+
* the fact, not inside the same breath.
|
|
127
|
+
*/
|
|
128
|
+
export const REMEMBER_ROTATION_GRACE_SECONDS = 30
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether a spent remember token is a concurrent request rather than a theft.
|
|
132
|
+
*
|
|
133
|
+
* A revoked family is always theft: the board has already decided about it.
|
|
134
|
+
* Elapsed time is not required to be positive — requests racing each other
|
|
135
|
+
* stamp `now` on the way in, so the one that loses the claim can carry a
|
|
136
|
+
* timestamp from just before the winner wrote `usedAt`, which is the very case
|
|
137
|
+
* this window exists to forgive.
|
|
138
|
+
*/
|
|
139
|
+
export function withinRotationGrace(
|
|
140
|
+
row: { readonly usedAt: Date | null; readonly revokedAt: Date | null },
|
|
141
|
+
now: Date,
|
|
142
|
+
): boolean {
|
|
143
|
+
if (row.revokedAt !== null || row.usedAt === null) return false
|
|
144
|
+
|
|
145
|
+
return now.getTime() - row.usedAt.getTime() <= REMEMBER_ROTATION_GRACE_SECONDS * 1_000
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface RememberTokenRepository {
|
|
149
|
+
issue(input: {
|
|
150
|
+
tokenHash: string
|
|
151
|
+
familyId: string
|
|
152
|
+
userId: number
|
|
153
|
+
expiresAt: Date
|
|
154
|
+
}): Promise<void>
|
|
155
|
+
rotate(input: {
|
|
156
|
+
presentedHash: string
|
|
157
|
+
nextHash: string
|
|
158
|
+
now: Date
|
|
159
|
+
nextExpiresAt: Date
|
|
160
|
+
}): Promise<RememberRotation>
|
|
161
|
+
revokeFamily(familyId: string, reason: string, now: Date): Promise<void>
|
|
162
|
+
revokeAllForUser(userId: number, reason: string, now: Date): Promise<void>
|
|
163
|
+
findByTokenHash(tokenHash: string): Promise<{
|
|
164
|
+
familyId: string
|
|
165
|
+
userId: number
|
|
166
|
+
usedAt: Date | null
|
|
167
|
+
revokedAt: Date | null
|
|
168
|
+
} | null>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type CredentialPurpose =
|
|
172
|
+
| 'password_reset'
|
|
173
|
+
| 'email_verification'
|
|
174
|
+
| 'email_change'
|
|
175
|
+
| 'second_factor'
|
|
176
|
+
|
|
177
|
+
export interface CredentialTokenRepository {
|
|
178
|
+
issue(input: {
|
|
179
|
+
tokenHash: string
|
|
180
|
+
userId: number
|
|
181
|
+
purpose: CredentialPurpose
|
|
182
|
+
payload?: string | null
|
|
183
|
+
expiresAt: Date
|
|
184
|
+
}): Promise<void>
|
|
185
|
+
consume(
|
|
186
|
+
tokenHash: string,
|
|
187
|
+
purpose: CredentialPurpose,
|
|
188
|
+
now: Date,
|
|
189
|
+
): Promise<{ userId: number; payload: string | null } | null>
|
|
190
|
+
/**
|
|
191
|
+
* The same lookup without spending the token. A half-finished sign-in has to
|
|
192
|
+
* survive a mistyped code, so the token that carries it is only consumed once
|
|
193
|
+
* the second factor is actually satisfied.
|
|
194
|
+
*/
|
|
195
|
+
peek(
|
|
196
|
+
tokenHash: string,
|
|
197
|
+
purpose: CredentialPurpose,
|
|
198
|
+
now: Date,
|
|
199
|
+
): Promise<{ userId: number; payload: string | null } | null>
|
|
200
|
+
revokeAllForUser(userId: number, purpose: CredentialPurpose): Promise<void>
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface UserIdentityRecord {
|
|
204
|
+
readonly id: number
|
|
205
|
+
readonly userId: number
|
|
206
|
+
readonly provider: string
|
|
207
|
+
readonly subject: string
|
|
208
|
+
readonly label: string | null
|
|
209
|
+
readonly linkedAt: Date
|
|
210
|
+
readonly lastUsedAt: Date | null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface LinkIdentityInput {
|
|
214
|
+
readonly userId: number
|
|
215
|
+
readonly provider: string
|
|
216
|
+
readonly subject: string
|
|
217
|
+
readonly label: string | null
|
|
218
|
+
readonly now: Date
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface UserIdentityRepository {
|
|
222
|
+
findBySubject(provider: string, subject: string): Promise<UserIdentityRecord | null>
|
|
223
|
+
listForUser(userId: number): Promise<readonly UserIdentityRecord[]>
|
|
224
|
+
link(input: LinkIdentityInput): Promise<UserIdentityRecord>
|
|
225
|
+
unlink(userId: number, identityId: number): Promise<boolean>
|
|
226
|
+
markUsed(identityId: number, now: Date): Promise<void>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface PasskeyRecord {
|
|
230
|
+
readonly id: number
|
|
231
|
+
readonly userId: number
|
|
232
|
+
readonly credentialId: string
|
|
233
|
+
readonly publicKey: string
|
|
234
|
+
readonly signCount: number
|
|
235
|
+
readonly label: string
|
|
236
|
+
readonly transports: string | null
|
|
237
|
+
readonly createdAt: Date
|
|
238
|
+
readonly lastUsedAt: Date | null
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface NewPasskey {
|
|
242
|
+
readonly userId: number
|
|
243
|
+
readonly credentialId: string
|
|
244
|
+
readonly publicKey: string
|
|
245
|
+
readonly signCount: number
|
|
246
|
+
readonly label: string
|
|
247
|
+
readonly transports: string | null
|
|
248
|
+
readonly now: Date
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface PasskeyRepository {
|
|
252
|
+
findByCredentialId(credentialId: string): Promise<PasskeyRecord | null>
|
|
253
|
+
listForUser(userId: number): Promise<readonly PasskeyRecord[]>
|
|
254
|
+
create(input: NewPasskey): Promise<PasskeyRecord>
|
|
255
|
+
remove(userId: number, passkeyId: number): Promise<boolean>
|
|
256
|
+
markUsed(passkeyId: number, signCount: number, now: Date): Promise<void>
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface TwoFactorRecord {
|
|
260
|
+
readonly userId: number
|
|
261
|
+
readonly sealedSecret: string
|
|
262
|
+
readonly confirmedAt: Date | null
|
|
263
|
+
readonly lastStep: number | null
|
|
264
|
+
readonly createdAt: Date
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface TwoFactorRepository {
|
|
268
|
+
find(userId: number): Promise<TwoFactorRecord | null>
|
|
269
|
+
startEnrolment(input: {
|
|
270
|
+
userId: number
|
|
271
|
+
sealedSecret: string
|
|
272
|
+
now: Date
|
|
273
|
+
}): Promise<TwoFactorRecord>
|
|
274
|
+
confirm(userId: number, step: number, now: Date): Promise<boolean>
|
|
275
|
+
/** False when the step has already been spent, which is a replayed code. */
|
|
276
|
+
spendStep(userId: number, step: number): Promise<boolean>
|
|
277
|
+
remove(userId: number): Promise<boolean>
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface RecoveryCodeRepository {
|
|
281
|
+
replaceAll(userId: number, hashes: readonly string[], now: Date): Promise<void>
|
|
282
|
+
spend(userId: number, hash: string, now: Date): Promise<boolean>
|
|
283
|
+
countUnused(userId: number): Promise<number>
|
|
284
|
+
removeAll(userId: number): Promise<void>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const AUTH_EVENT_KINDS = [
|
|
288
|
+
'login',
|
|
289
|
+
'login_failed',
|
|
290
|
+
'logout',
|
|
291
|
+
'second_factor_failed',
|
|
292
|
+
'second_factor_enabled',
|
|
293
|
+
'second_factor_disabled',
|
|
294
|
+
'second_factor_cleared',
|
|
295
|
+
'recovery_code_used',
|
|
296
|
+
'recovery_codes_replaced',
|
|
297
|
+
'password_changed',
|
|
298
|
+
'password_reset',
|
|
299
|
+
'email_change_requested',
|
|
300
|
+
'email_changed',
|
|
301
|
+
'session_revoked',
|
|
302
|
+
'sessions_revoked',
|
|
303
|
+
'identity_linked',
|
|
304
|
+
'identity_unlinked',
|
|
305
|
+
'passkey_added',
|
|
306
|
+
'passkey_removed',
|
|
307
|
+
] as const
|
|
308
|
+
|
|
309
|
+
export type AuthEventKind = (typeof AUTH_EVENT_KINDS)[number]
|
|
310
|
+
|
|
311
|
+
export interface AuthEventRecord {
|
|
312
|
+
readonly id: number
|
|
313
|
+
readonly userId: number | null
|
|
314
|
+
readonly kind: AuthEventKind
|
|
315
|
+
readonly ipPrefix: string | null
|
|
316
|
+
readonly userAgent: string | null
|
|
317
|
+
readonly detail: Readonly<Record<string, unknown>>
|
|
318
|
+
readonly at: Date
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface NewAuthEvent {
|
|
322
|
+
readonly userId: number | null
|
|
323
|
+
readonly kind: AuthEventKind
|
|
324
|
+
readonly ipPrefix?: string | null
|
|
325
|
+
readonly userAgent?: string | null
|
|
326
|
+
readonly detail?: Readonly<Record<string, unknown>>
|
|
327
|
+
readonly at: Date
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface AuthEventRepository {
|
|
331
|
+
record(event: NewAuthEvent): Promise<void>
|
|
332
|
+
listForUser(userId: number, limit: number): Promise<readonly AuthEventRecord[]>
|
|
333
|
+
listRecent(input: {
|
|
334
|
+
limit: number
|
|
335
|
+
before?: number | undefined
|
|
336
|
+
kind?: AuthEventKind | undefined
|
|
337
|
+
}): Promise<readonly AuthEventRecord[]>
|
|
338
|
+
pruneBefore(cutoff: Date, limit?: number): Promise<number>
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface LoginAttemptRepository {
|
|
342
|
+
record(bucket: string, succeeded: boolean, at: Date): Promise<void>
|
|
343
|
+
countFailuresSince(bucket: string, since: Date): Promise<number>
|
|
344
|
+
clear(bucket: string): Promise<void>
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface LoginBucket {
|
|
348
|
+
readonly key: string
|
|
349
|
+
readonly max?: number | undefined
|
|
350
|
+
readonly clearOnSuccess?: boolean | undefined
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface AuthConfig {
|
|
354
|
+
readonly registrationEnabled: boolean
|
|
355
|
+
readonly minPasswordLength: number
|
|
356
|
+
readonly usernameMin: number
|
|
357
|
+
readonly usernameMax: number
|
|
358
|
+
readonly activationMethod: 'none' | 'email' | 'admin' | 'both'
|
|
359
|
+
readonly maxLoginAttempts: number
|
|
360
|
+
readonly maxAccountLoginAttempts: number
|
|
361
|
+
readonly lockoutMinutes: number
|
|
362
|
+
readonly sessionLifetimeDays: number
|
|
363
|
+
readonly resetTokenTtlMinutes: number
|
|
364
|
+
readonly reservedUsernames: readonly string[]
|
|
365
|
+
readonly defaultMemberGroupId: number
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export type Clock = () => Date
|
|
369
|
+
|
|
370
|
+
export interface AccountStore {
|
|
371
|
+
readonly accounts: AccountRepository
|
|
372
|
+
readonly sessions: SessionRepository
|
|
373
|
+
readonly tokens: CredentialTokenRepository
|
|
374
|
+
readonly loginAttempts: LoginAttemptRepository
|
|
375
|
+
readonly remember: RememberTokenRepository
|
|
376
|
+
readonly identities: UserIdentityRepository
|
|
377
|
+
readonly passkeys: PasskeyRepository
|
|
378
|
+
readonly twoFactor: TwoFactorRepository
|
|
379
|
+
readonly recoveryCodes: RecoveryCodeRepository
|
|
380
|
+
readonly authEvents: AuthEventRepository
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface BanRecord {
|
|
384
|
+
readonly id: number
|
|
385
|
+
readonly userId: number
|
|
386
|
+
readonly reason: string | null
|
|
387
|
+
readonly publicReason: string | null
|
|
388
|
+
readonly previousPrimaryGroupId: number | null
|
|
389
|
+
readonly expiresAt: Date | null
|
|
390
|
+
readonly liftedAt: Date | null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export interface CreateBanInput {
|
|
394
|
+
readonly userId: number
|
|
395
|
+
readonly bannedByUserId: number | null
|
|
396
|
+
readonly reason: string | null
|
|
397
|
+
readonly publicReason: string | null
|
|
398
|
+
readonly expiresAt: Date | null
|
|
399
|
+
readonly bannedGroupId: number
|
|
400
|
+
readonly now: Date
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export interface BanRepository {
|
|
404
|
+
findActive(userId: number): Promise<BanRecord | null>
|
|
405
|
+
|
|
406
|
+
create(input: CreateBanInput): Promise<BanRecord>
|
|
407
|
+
|
|
408
|
+
lift(banId: number, now: Date): Promise<void>
|
|
409
|
+
|
|
410
|
+
expireDue(now: Date, limit: number): Promise<number>
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export interface BanFilterRepository {
|
|
414
|
+
listAll(): Promise<readonly BanFilter[]>
|
|
415
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isAppError } from '@meith/core'
|
|
2
|
+
|
|
3
|
+
export const REGISTER_FIELD = {
|
|
4
|
+
username: 'username',
|
|
5
|
+
email: 'email',
|
|
6
|
+
password: 'password',
|
|
7
|
+
} as const
|
|
8
|
+
|
|
9
|
+
export type RegisterField = (typeof REGISTER_FIELD)[keyof typeof REGISTER_FIELD]
|
|
10
|
+
|
|
11
|
+
const FIELDS: readonly string[] = Object.values(REGISTER_FIELD)
|
|
12
|
+
|
|
13
|
+
export function rejectedField(error: unknown): RegisterField | null {
|
|
14
|
+
if (!isAppError(error)) return null
|
|
15
|
+
const field = error.meta.field
|
|
16
|
+
return typeof field === 'string' && FIELDS.includes(field) ? (field as RegisterField) : null
|
|
17
|
+
}
|