@byline/admin 5.0.0 → 5.1.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/dist/fields/field-services-context.d.ts +1 -1
- package/dist/forms/available-locales-widget.d.ts +1 -1
- package/dist/forms/document-actions.d.ts +1 -1
- package/dist/forms/form-renderer.d.ts +3 -3
- package/dist/forms/form-status-display.d.ts +1 -1
- package/dist/forms/upload-executor.d.ts +2 -2
- package/dist/modules/admin-account/components/change-password.js +18 -0
- package/dist/modules/admin-account/service.d.ts +2 -6
- package/dist/modules/admin-account/service.js +2 -1
- package/dist/modules/admin-users/repository.d.ts +10 -1
- package/dist/modules/auth/index.d.ts +3 -1
- package/dist/modules/auth/index.js +1 -0
- package/dist/modules/auth/jwt-session-provider.d.ts +8 -2
- package/dist/modules/auth/jwt-session-provider.js +222 -70
- package/dist/modules/auth/login-sessions-repository.d.ts +23 -0
- package/dist/modules/auth/login-sessions-repository.js +1 -0
- package/dist/modules/auth/refresh-tokens-repository.d.ts +12 -9
- package/dist/modules/auth/resolve-actor.d.ts +3 -0
- package/dist/modules/auth/resolve-actor.js +5 -2
- package/dist/modules/auth/sign-in-rate-limiter.d.ts +48 -0
- package/dist/modules/auth/sign-in-rate-limiter.js +229 -0
- package/dist/store.d.ts +15 -2
- package/package.json +17 -17
- package/src/modules/admin-account/components/change-password.test.tsx +72 -0
- package/src/modules/admin-account/components/change-password.tsx +15 -4
- package/src/modules/admin-account/service.ts +8 -7
- package/src/modules/admin-users/repository.ts +10 -1
- package/src/modules/auth/index.ts +13 -1
- package/src/modules/auth/jwt-session-provider.ts +276 -91
- package/src/modules/auth/login-sessions-repository.ts +25 -0
- package/src/modules/auth/refresh-tokens-repository.ts +12 -9
- package/src/modules/auth/resolve-actor.ts +10 -1
- package/src/modules/auth/sign-in-rate-limiter.ts +240 -0
- package/src/store.ts +22 -2
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHmac } from 'node:crypto'
|
|
10
|
+
import { isIP } from 'node:net'
|
|
11
|
+
|
|
12
|
+
import type { PasswordSignInLimiter } from '@byline/auth'
|
|
13
|
+
import type { RecurringTaskDefinition } from '@byline/core'
|
|
14
|
+
|
|
15
|
+
export interface SignInRateLimitStore {
|
|
16
|
+
/** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
|
|
17
|
+
consume(key: string, limit: number, expiresAt: Date): Promise<boolean>
|
|
18
|
+
/** Remove at most 100 expired counters and return the number removed. */
|
|
19
|
+
purgeExpired(before: Date): Promise<number>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SignInRateLimitPolicy {
|
|
23
|
+
account: { limit: number; windowSeconds: number }
|
|
24
|
+
ip: { limit: number; windowSeconds: number }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SignInSecurityEvent {
|
|
28
|
+
type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure'
|
|
29
|
+
scope?: 'account' | 'ip'
|
|
30
|
+
/** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
|
|
31
|
+
account?: string
|
|
32
|
+
network?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SignInLimiterOptions {
|
|
36
|
+
onEvent?: (event: SignInSecurityEvent) => void
|
|
37
|
+
slots?: number
|
|
38
|
+
maxQueue?: number
|
|
39
|
+
queueTimeoutMs?: number
|
|
40
|
+
/** Also bounds counter creation when the provider or a denial completes very quickly. */
|
|
41
|
+
minimumSlotMs?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_POLICY: SignInRateLimitPolicy = {
|
|
45
|
+
account: { limit: 10, windowSeconds: 15 * 60 },
|
|
46
|
+
ip: { limit: 60, windowSeconds: 60 },
|
|
47
|
+
}
|
|
48
|
+
const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup'
|
|
49
|
+
|
|
50
|
+
/** IPv4 uses one address; IPv6 uses its /64. Mapped IPv4 shares its native IPv4 bucket. */
|
|
51
|
+
function signInNetwork(ip: string): string {
|
|
52
|
+
const version = isIP(ip)
|
|
53
|
+
if (version === 4) return ip
|
|
54
|
+
if (version !== 6 || ip.includes('%')) throw new Error('Invalid client address')
|
|
55
|
+
const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1)
|
|
56
|
+
const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(canonical)
|
|
57
|
+
if (mapped) {
|
|
58
|
+
const high = Number.parseInt(mapped[1]!, 16)
|
|
59
|
+
const low = Number.parseInt(mapped[2]!, 16)
|
|
60
|
+
return [high >>> 8, high & 255, low >>> 8, low & 255].join('.')
|
|
61
|
+
}
|
|
62
|
+
const [left, right] = canonical.split('::')
|
|
63
|
+
const head = left ? left.split(':') : []
|
|
64
|
+
const tail = right ? right.split(':') : []
|
|
65
|
+
const words =
|
|
66
|
+
right === undefined
|
|
67
|
+
? head
|
|
68
|
+
: [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail]
|
|
69
|
+
return `${words.slice(0, 4).join(':')}::/64`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Instantiate once per process. Acquire before consume and release in finally after verification.
|
|
74
|
+
* Network admission precedes account-plus-network admission. There is no account-wide lockout.
|
|
75
|
+
*/
|
|
76
|
+
export function createPasswordSignInLimiter(
|
|
77
|
+
store: SignInRateLimitStore,
|
|
78
|
+
secret: string | Uint8Array,
|
|
79
|
+
policy: SignInRateLimitPolicy = DEFAULT_POLICY,
|
|
80
|
+
now: () => number = Date.now,
|
|
81
|
+
options: SignInLimiterOptions = {}
|
|
82
|
+
): PasswordSignInLimiter & { cleanupTask: RecurringTaskDefinition; dispose(): void } {
|
|
83
|
+
const secretBytes = typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
|
|
84
|
+
if (secretBytes.byteLength < 32)
|
|
85
|
+
throw new Error('Sign-in HMAC secret must contain at least 32 bytes')
|
|
86
|
+
// Domain separation permits reuse of the installation JWT secret without reusing its signing key.
|
|
87
|
+
const key = createHmac('sha256', secretBytes).update('byline:password-sign-in:v1').digest()
|
|
88
|
+
const digest = (value: unknown) =>
|
|
89
|
+
createHmac('sha256', key).update(JSON.stringify(value)).digest('hex')
|
|
90
|
+
const rules = structuredClone(policy)
|
|
91
|
+
for (const rule of Object.values(rules)) {
|
|
92
|
+
if (
|
|
93
|
+
!Number.isSafeInteger(rule.limit) ||
|
|
94
|
+
rule.limit < 1 ||
|
|
95
|
+
rule.limit > 1_000_000 ||
|
|
96
|
+
!Number.isSafeInteger(rule.windowSeconds) ||
|
|
97
|
+
rule.windowSeconds < 1 ||
|
|
98
|
+
rule.windowSeconds > 86400
|
|
99
|
+
)
|
|
100
|
+
throw new Error('Invalid password sign-in rate limit policy')
|
|
101
|
+
}
|
|
102
|
+
const slots = options.slots ?? 1
|
|
103
|
+
const maxQueue = options.maxQueue ?? 4
|
|
104
|
+
const queueTimeoutMs = options.queueTimeoutMs ?? 250
|
|
105
|
+
const minimumSlotMs = options.minimumSlotMs ?? 100
|
|
106
|
+
for (const value of [slots, queueTimeoutMs, minimumSlotMs])
|
|
107
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 60_000)
|
|
108
|
+
throw new Error('Invalid sign-in capacity policy')
|
|
109
|
+
if (!Number.isSafeInteger(maxQueue) || maxQueue < 0 || maxQueue > 1000)
|
|
110
|
+
throw new Error('Invalid sign-in queue size')
|
|
111
|
+
const diagnostics = new Map<string, { last: number; count: number }>()
|
|
112
|
+
const emit = (event: SignInSecurityEvent) => {
|
|
113
|
+
// Observability must not change admission or strand a capacity lease.
|
|
114
|
+
try {
|
|
115
|
+
if (options.onEvent) options.onEvent(event)
|
|
116
|
+
else if (event.type !== 'admitted' && event.type !== 'success') {
|
|
117
|
+
const entry = diagnostics.get(event.type) ?? { last: -Infinity, count: 0 }
|
|
118
|
+
entry.count++
|
|
119
|
+
if (performance.now() - entry.last >= 10_000) {
|
|
120
|
+
console.warn('[byline:password-sign-in]', { ...event, count: entry.count })
|
|
121
|
+
entry.last = performance.now()
|
|
122
|
+
entry.count = 0
|
|
123
|
+
}
|
|
124
|
+
diagnostics.set(event.type, entry)
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
/* Host telemetry is best effort. */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const identifiers = ({ email, ip }: { email: string; ip: string }) => ({
|
|
131
|
+
account: digest(['account', email.trim().toLowerCase()]),
|
|
132
|
+
network: digest(['network', signInNetwork(ip)]),
|
|
133
|
+
})
|
|
134
|
+
let active = 0
|
|
135
|
+
let disposed = false
|
|
136
|
+
const queue: Array<{ grant: () => void; cancel: () => void }> = []
|
|
137
|
+
const lease = (): (() => void) => {
|
|
138
|
+
active++
|
|
139
|
+
const started = performance.now()
|
|
140
|
+
let released = false
|
|
141
|
+
return () => {
|
|
142
|
+
if (released) return
|
|
143
|
+
released = true
|
|
144
|
+
// Keep the slot occupied for at least this interval, including fast denials.
|
|
145
|
+
setTimeout(
|
|
146
|
+
() => {
|
|
147
|
+
active--
|
|
148
|
+
queue.shift()?.grant()
|
|
149
|
+
},
|
|
150
|
+
Math.max(0, minimumSlotMs - (performance.now() - started))
|
|
151
|
+
).unref()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const cleanupTask: RecurringTaskDefinition = {
|
|
155
|
+
name: CLEANUP_TASK_NAME,
|
|
156
|
+
intervalMs: 60_000,
|
|
157
|
+
leaseMs: 60_000,
|
|
158
|
+
async run(context) {
|
|
159
|
+
const cutoff = new Date(now() - 300_000)
|
|
160
|
+
try {
|
|
161
|
+
for (let batch = 0; batch < 32; batch++) {
|
|
162
|
+
context.signal.throwIfAborted()
|
|
163
|
+
await context.heartbeat()
|
|
164
|
+
if ((await store.purgeExpired(cutoff)) < 100) return { workRemaining: false }
|
|
165
|
+
}
|
|
166
|
+
return { workRemaining: true }
|
|
167
|
+
} catch (error) {
|
|
168
|
+
emit({ type: 'cleanup-error' })
|
|
169
|
+
// Let the scheduler record the failure and apply its retry/backoff policy.
|
|
170
|
+
throw error
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
requiredCleanupTask: CLEANUP_TASK_NAME,
|
|
176
|
+
cleanupTask,
|
|
177
|
+
dispose() {
|
|
178
|
+
disposed = true
|
|
179
|
+
for (const waiter of queue.splice(0)) waiter.cancel()
|
|
180
|
+
},
|
|
181
|
+
async acquire() {
|
|
182
|
+
if (disposed) throw new Error('Sign-in limiter disposed')
|
|
183
|
+
if (active < slots) return lease()
|
|
184
|
+
if (queue.length >= maxQueue) {
|
|
185
|
+
emit({ type: 'capacity' })
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
return new Promise<(() => void) | null>((resolve) => {
|
|
189
|
+
const waiter = {
|
|
190
|
+
grant: () => {
|
|
191
|
+
clearTimeout(timeout)
|
|
192
|
+
resolve(lease())
|
|
193
|
+
},
|
|
194
|
+
cancel: () => {
|
|
195
|
+
clearTimeout(timeout)
|
|
196
|
+
emit({ type: 'capacity' })
|
|
197
|
+
resolve(null)
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
const timeout = setTimeout(() => {
|
|
201
|
+
queue.splice(queue.indexOf(waiter), 1)
|
|
202
|
+
waiter.cancel()
|
|
203
|
+
}, queueTimeoutMs)
|
|
204
|
+
queue.push(waiter)
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
recordResult(input, outcome) {
|
|
208
|
+
emit({ type: outcome, ...identifiers(input) })
|
|
209
|
+
},
|
|
210
|
+
async consume(input) {
|
|
211
|
+
const time = now()
|
|
212
|
+
const network = signInNetwork(input.ip)
|
|
213
|
+
const ids = identifiers(input)
|
|
214
|
+
try {
|
|
215
|
+
// Load-proportional cleanup stays on the request path; the scheduler drains idle residue.
|
|
216
|
+
await store.purgeExpired(new Date(time - 300_000))
|
|
217
|
+
for (const [scope, identity] of [
|
|
218
|
+
['ip', network],
|
|
219
|
+
['account', [input.email.trim().toLowerCase(), network]],
|
|
220
|
+
] as const) {
|
|
221
|
+
const rule = rules[scope]
|
|
222
|
+
const windowMs = rule.windowSeconds * 1000
|
|
223
|
+
const end = (Math.floor(time / windowMs) + 1) * windowMs
|
|
224
|
+
if (!(await store.consume(digest([scope, identity, end]), rule.limit, new Date(end)))) {
|
|
225
|
+
emit({ type: 'denied', scope, ...ids })
|
|
226
|
+
return {
|
|
227
|
+
allowed: false,
|
|
228
|
+
retryAfterSeconds: Math.max(1, Math.ceil((end - time) / 1000)),
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
emit({ type: 'admitted', ...ids })
|
|
233
|
+
return { allowed: true, retryAfterSeconds: 0 }
|
|
234
|
+
} catch (error) {
|
|
235
|
+
emit({ type: 'store-error', ...ids })
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js'
|
|
10
10
|
import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js'
|
|
11
11
|
import type { AdminRolesRepository } from './modules/admin-roles/repository.js'
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
AdminUsersRepository,
|
|
14
|
+
AdminUserWithPasswordRow,
|
|
15
|
+
} from './modules/admin-users/repository.js'
|
|
16
|
+
import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js'
|
|
13
17
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js'
|
|
18
|
+
import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js'
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* The bundle of repositories that `@byline/admin` needs from the DB
|
|
@@ -21,11 +26,26 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
|
|
|
21
26
|
* `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
|
|
22
27
|
* and admin-role commands.
|
|
23
28
|
*
|
|
24
|
-
* Keeping the
|
|
29
|
+
* Keeping the repositories together as a single argument avoids
|
|
25
30
|
* exploding constructor signatures and makes "needs admin DB access" a
|
|
26
31
|
* single, recognisable type.
|
|
27
32
|
*/
|
|
33
|
+
|
|
28
34
|
export interface AdminStore {
|
|
35
|
+
/**
|
|
36
|
+
* Serialize native issuance/revocation against account mutations. Lock the
|
|
37
|
+
* account row first, then operate on refresh rows through the scoped store.
|
|
38
|
+
* The callback and all its writes commit together or roll back together.
|
|
39
|
+
* Never retain the scoped repositories beyond the callback. No automatic retries.
|
|
40
|
+
*/
|
|
41
|
+
withSessionLock<T>(
|
|
42
|
+
adminUserId: string,
|
|
43
|
+
work: (store: AdminStore, user: AdminUserWithPasswordRow | null) => Promise<T>
|
|
44
|
+
): Promise<T>
|
|
45
|
+
/** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
|
|
46
|
+
withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>
|
|
47
|
+
loginSessions: LoginSessionsRepository
|
|
48
|
+
signInRateLimits: SignInRateLimitStore
|
|
29
49
|
adminUsers: AdminUsersRepository
|
|
30
50
|
adminRoles: AdminRolesRepository
|
|
31
51
|
adminPermissions: AdminPermissionsRepository
|