@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
|
@@ -19,6 +19,9 @@ export interface RefreshTokenRow {
|
|
|
19
19
|
id: string;
|
|
20
20
|
admin_user_id: string;
|
|
21
21
|
token_hash: string;
|
|
22
|
+
/** Null only for legacy rows, which cannot authorize native sessions. */
|
|
23
|
+
sid: string | null;
|
|
24
|
+
session_version: number;
|
|
22
25
|
issued_at: Date;
|
|
23
26
|
expires_at: Date;
|
|
24
27
|
revoked_at: Date | null;
|
|
@@ -28,9 +31,13 @@ export interface RefreshTokenRow {
|
|
|
28
31
|
ip: string | null;
|
|
29
32
|
}
|
|
30
33
|
export interface IssueRefreshTokenInput {
|
|
34
|
+
/** Omitted only by legacy fixtures/imports; never accepted for native renewal. */
|
|
35
|
+
sid?: string | null;
|
|
31
36
|
id: string;
|
|
32
37
|
admin_user_id: string;
|
|
33
38
|
token_hash: string;
|
|
39
|
+
/** Account generation observed under the native issuance lock. */
|
|
40
|
+
session_version: number;
|
|
34
41
|
expires_at: Date;
|
|
35
42
|
user_agent?: string | null;
|
|
36
43
|
ip?: string | null;
|
|
@@ -43,19 +50,15 @@ export interface RefreshTokensRepository {
|
|
|
43
50
|
/** Stamp `last_used_at` for observability. */
|
|
44
51
|
touch(id: string, at?: Date): Promise<void>;
|
|
45
52
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
53
|
+
* Revoke `oldId` and set its `rotated_to_id` to `newId`. This is not
|
|
54
|
+
* independently a compare-and-swap. Native callers must hold the account
|
|
55
|
+
* lock, reread the predecessor, and insert the successor before this write,
|
|
56
|
+
* all through the same `withSessionLock` transaction.
|
|
49
57
|
*/
|
|
50
58
|
markRotated(oldId: string, newId: string, at?: Date): Promise<void>;
|
|
51
59
|
/** Revoke a single token. Idempotent. */
|
|
52
60
|
revoke(id: string, at?: Date): Promise<void>;
|
|
53
|
-
/**
|
|
54
|
-
* Walk the rotation chain starting at `startId` and revoke every token
|
|
55
|
-
* in it. Called when a rotated token is replayed — indicates the chain
|
|
56
|
-
* has been compromised and every descendant is suspect. Returns the
|
|
57
|
-
* number of rows touched.
|
|
58
|
-
*/
|
|
61
|
+
/** Revoke all refresh rows sharing the start member's sid, without traversal. */
|
|
59
62
|
revokeChain(startId: string, at?: Date): Promise<number>;
|
|
60
63
|
/** Revoke every non-revoked token for a user. Used on password change / sign-out everywhere. */
|
|
61
64
|
revokeAllForUser(adminUserId: string, at?: Date): Promise<number>;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AdminAuth } from '@byline/auth';
|
|
9
9
|
import type { AdminStore } from '../../store.js';
|
|
10
|
+
import type { AdminUserRow } from '../admin-users/repository.js';
|
|
10
11
|
/**
|
|
11
12
|
* Build an `AdminAuth` from a user id by reading the admin-users row and
|
|
12
13
|
* collecting the distinct abilities granted through every role the user
|
|
@@ -22,3 +23,5 @@ import type { AdminStore } from '../../store.js';
|
|
|
22
23
|
* `AdminStore` bundle; adapter-agnostic.
|
|
23
24
|
*/
|
|
24
25
|
export declare function resolveActor(store: AdminStore, adminUserId: string): Promise<AdminAuth | null>;
|
|
26
|
+
/** Internal helper: reuse the account snapshot already checked by native authentication. */
|
|
27
|
+
export declare function resolveActorFromUser(store: AdminStore, user: AdminUserRow | null): Promise<AdminAuth | null>;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { AdminAuth } from "@byline/auth";
|
|
2
2
|
async function resolveActor(store, adminUserId) {
|
|
3
3
|
const user = await store.adminUsers.getById(adminUserId);
|
|
4
|
+
return resolveActorFromUser(store, user);
|
|
5
|
+
}
|
|
6
|
+
async function resolveActorFromUser(store, user) {
|
|
4
7
|
if (!user) return null;
|
|
5
8
|
if (!user.is_enabled) return null;
|
|
6
|
-
const abilities = await store.adminPermissions.listAbilitiesForUser(
|
|
9
|
+
const abilities = await store.adminPermissions.listAbilitiesForUser(user.id);
|
|
7
10
|
return new AdminAuth({
|
|
8
11
|
id: user.id,
|
|
9
12
|
abilities,
|
|
10
13
|
isSuperAdmin: user.is_super_admin
|
|
11
14
|
});
|
|
12
15
|
}
|
|
13
|
-
export { resolveActor };
|
|
16
|
+
export { resolveActor, resolveActorFromUser };
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
import type { PasswordSignInLimiter } from '@byline/auth';
|
|
9
|
+
import type { RecurringTaskDefinition } from '@byline/core';
|
|
10
|
+
export interface SignInRateLimitStore {
|
|
11
|
+
/** Atomically increment, saturating at limit + 1; true only for the first limit calls. */
|
|
12
|
+
consume(key: string, limit: number, expiresAt: Date): Promise<boolean>;
|
|
13
|
+
/** Remove at most 100 expired counters and return the number removed. */
|
|
14
|
+
purgeExpired(before: Date): Promise<number>;
|
|
15
|
+
}
|
|
16
|
+
export interface SignInRateLimitPolicy {
|
|
17
|
+
account: {
|
|
18
|
+
limit: number;
|
|
19
|
+
windowSeconds: number;
|
|
20
|
+
};
|
|
21
|
+
ip: {
|
|
22
|
+
limit: number;
|
|
23
|
+
windowSeconds: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface SignInSecurityEvent {
|
|
27
|
+
type: 'admitted' | 'denied' | 'capacity' | 'store-error' | 'cleanup-error' | 'success' | 'failure';
|
|
28
|
+
scope?: 'account' | 'ip';
|
|
29
|
+
/** Stable, pseudonymous digests for cross-network correlation. Never passwords or raw IPs. */
|
|
30
|
+
account?: string;
|
|
31
|
+
network?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SignInLimiterOptions {
|
|
34
|
+
onEvent?: (event: SignInSecurityEvent) => void;
|
|
35
|
+
slots?: number;
|
|
36
|
+
maxQueue?: number;
|
|
37
|
+
queueTimeoutMs?: number;
|
|
38
|
+
/** Also bounds counter creation when the provider or a denial completes very quickly. */
|
|
39
|
+
minimumSlotMs?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Instantiate once per process. Acquire before consume and release in finally after verification.
|
|
43
|
+
* Network admission precedes account-plus-network admission. There is no account-wide lockout.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createPasswordSignInLimiter(store: SignInRateLimitStore, secret: string | Uint8Array, policy?: SignInRateLimitPolicy, now?: () => number, options?: SignInLimiterOptions): PasswordSignInLimiter & {
|
|
46
|
+
cleanupTask: RecurringTaskDefinition;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
const DEFAULT_POLICY = {
|
|
4
|
+
account: {
|
|
5
|
+
limit: 10,
|
|
6
|
+
windowSeconds: 900
|
|
7
|
+
},
|
|
8
|
+
ip: {
|
|
9
|
+
limit: 60,
|
|
10
|
+
windowSeconds: 60
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const CLEANUP_TASK_NAME = 'auth.sign-in-counters.cleanup';
|
|
14
|
+
function signInNetwork(ip) {
|
|
15
|
+
const version = isIP(ip);
|
|
16
|
+
if (4 === version) return ip;
|
|
17
|
+
if (6 !== version || ip.includes('%')) throw new Error('Invalid client address');
|
|
18
|
+
const canonical = new URL(`http://[${ip}]/`).hostname.slice(1, -1);
|
|
19
|
+
const mapped = /^::ffff:([0-9a-f]+):([0-9a-f]+)$/.exec(canonical);
|
|
20
|
+
if (mapped) {
|
|
21
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
22
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
23
|
+
return [
|
|
24
|
+
high >>> 8,
|
|
25
|
+
255 & high,
|
|
26
|
+
low >>> 8,
|
|
27
|
+
255 & low
|
|
28
|
+
].join('.');
|
|
29
|
+
}
|
|
30
|
+
const [left, right] = canonical.split('::');
|
|
31
|
+
const head = left ? left.split(':') : [];
|
|
32
|
+
const tail = right ? right.split(':') : [];
|
|
33
|
+
const words = void 0 === right ? head : [
|
|
34
|
+
...head,
|
|
35
|
+
...Array(8 - head.length - tail.length).fill('0'),
|
|
36
|
+
...tail
|
|
37
|
+
];
|
|
38
|
+
return `${words.slice(0, 4).join(':')}::/64`;
|
|
39
|
+
}
|
|
40
|
+
function createPasswordSignInLimiter(store, secret, policy = DEFAULT_POLICY, now = Date.now, options = {}) {
|
|
41
|
+
const secretBytes = 'string' == typeof secret ? new TextEncoder().encode(secret) : secret;
|
|
42
|
+
if (secretBytes.byteLength < 32) throw new Error('Sign-in HMAC secret must contain at least 32 bytes');
|
|
43
|
+
const key = createHmac('sha256', secretBytes).update('byline:password-sign-in:v1').digest();
|
|
44
|
+
const digest = (value)=>createHmac('sha256', key).update(JSON.stringify(value)).digest('hex');
|
|
45
|
+
const rules = structuredClone(policy);
|
|
46
|
+
for (const rule of Object.values(rules))if (!Number.isSafeInteger(rule.limit) || rule.limit < 1 || rule.limit > 1000000 || !Number.isSafeInteger(rule.windowSeconds) || rule.windowSeconds < 1 || rule.windowSeconds > 86400) throw new Error('Invalid password sign-in rate limit policy');
|
|
47
|
+
const slots = options.slots ?? 1;
|
|
48
|
+
const maxQueue = options.maxQueue ?? 4;
|
|
49
|
+
const queueTimeoutMs = options.queueTimeoutMs ?? 250;
|
|
50
|
+
const minimumSlotMs = options.minimumSlotMs ?? 100;
|
|
51
|
+
for (const value of [
|
|
52
|
+
slots,
|
|
53
|
+
queueTimeoutMs,
|
|
54
|
+
minimumSlotMs
|
|
55
|
+
])if (!Number.isSafeInteger(value) || value < 1 || value > 60000) throw new Error('Invalid sign-in capacity policy');
|
|
56
|
+
if (!Number.isSafeInteger(maxQueue) || maxQueue < 0 || maxQueue > 1000) throw new Error('Invalid sign-in queue size');
|
|
57
|
+
const diagnostics = new Map();
|
|
58
|
+
const emit = (event)=>{
|
|
59
|
+
try {
|
|
60
|
+
if (options.onEvent) options.onEvent(event);
|
|
61
|
+
else if ('admitted' !== event.type && 'success' !== event.type) {
|
|
62
|
+
const entry = diagnostics.get(event.type) ?? {
|
|
63
|
+
last: -1 / 0,
|
|
64
|
+
count: 0
|
|
65
|
+
};
|
|
66
|
+
entry.count++;
|
|
67
|
+
if (performance.now() - entry.last >= 10000) {
|
|
68
|
+
console.warn('[byline:password-sign-in]', {
|
|
69
|
+
...event,
|
|
70
|
+
count: entry.count
|
|
71
|
+
});
|
|
72
|
+
entry.last = performance.now();
|
|
73
|
+
entry.count = 0;
|
|
74
|
+
}
|
|
75
|
+
diagnostics.set(event.type, entry);
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
};
|
|
79
|
+
const identifiers = ({ email, ip })=>({
|
|
80
|
+
account: digest([
|
|
81
|
+
'account',
|
|
82
|
+
email.trim().toLowerCase()
|
|
83
|
+
]),
|
|
84
|
+
network: digest([
|
|
85
|
+
'network',
|
|
86
|
+
signInNetwork(ip)
|
|
87
|
+
])
|
|
88
|
+
});
|
|
89
|
+
let active = 0;
|
|
90
|
+
let disposed = false;
|
|
91
|
+
const queue = [];
|
|
92
|
+
const lease = ()=>{
|
|
93
|
+
active++;
|
|
94
|
+
const started = performance.now();
|
|
95
|
+
let released = false;
|
|
96
|
+
return ()=>{
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
setTimeout(()=>{
|
|
100
|
+
active--;
|
|
101
|
+
queue.shift()?.grant();
|
|
102
|
+
}, Math.max(0, minimumSlotMs - (performance.now() - started))).unref();
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
const cleanupTask = {
|
|
106
|
+
name: CLEANUP_TASK_NAME,
|
|
107
|
+
intervalMs: 60000,
|
|
108
|
+
leaseMs: 60000,
|
|
109
|
+
async run (context) {
|
|
110
|
+
const cutoff = new Date(now() - 300000);
|
|
111
|
+
try {
|
|
112
|
+
for(let batch = 0; batch < 32; batch++){
|
|
113
|
+
context.signal.throwIfAborted();
|
|
114
|
+
await context.heartbeat();
|
|
115
|
+
if (await store.purgeExpired(cutoff) < 100) return {
|
|
116
|
+
workRemaining: false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
workRemaining: true
|
|
121
|
+
};
|
|
122
|
+
} catch (error) {
|
|
123
|
+
emit({
|
|
124
|
+
type: 'cleanup-error'
|
|
125
|
+
});
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
requiredCleanupTask: CLEANUP_TASK_NAME,
|
|
132
|
+
cleanupTask,
|
|
133
|
+
dispose () {
|
|
134
|
+
disposed = true;
|
|
135
|
+
for (const waiter of queue.splice(0))waiter.cancel();
|
|
136
|
+
},
|
|
137
|
+
async acquire () {
|
|
138
|
+
if (disposed) throw new Error('Sign-in limiter disposed');
|
|
139
|
+
if (active < slots) return lease();
|
|
140
|
+
if (queue.length >= maxQueue) {
|
|
141
|
+
emit({
|
|
142
|
+
type: 'capacity'
|
|
143
|
+
});
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return new Promise((resolve)=>{
|
|
147
|
+
const waiter = {
|
|
148
|
+
grant: ()=>{
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
resolve(lease());
|
|
151
|
+
},
|
|
152
|
+
cancel: ()=>{
|
|
153
|
+
clearTimeout(timeout);
|
|
154
|
+
emit({
|
|
155
|
+
type: 'capacity'
|
|
156
|
+
});
|
|
157
|
+
resolve(null);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const timeout = setTimeout(()=>{
|
|
161
|
+
queue.splice(queue.indexOf(waiter), 1);
|
|
162
|
+
waiter.cancel();
|
|
163
|
+
}, queueTimeoutMs);
|
|
164
|
+
queue.push(waiter);
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
recordResult (input, outcome) {
|
|
168
|
+
emit({
|
|
169
|
+
type: outcome,
|
|
170
|
+
...identifiers(input)
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async consume (input) {
|
|
174
|
+
const time = now();
|
|
175
|
+
const network = signInNetwork(input.ip);
|
|
176
|
+
const ids = identifiers(input);
|
|
177
|
+
try {
|
|
178
|
+
await store.purgeExpired(new Date(time - 300000));
|
|
179
|
+
for (const [scope, identity] of [
|
|
180
|
+
[
|
|
181
|
+
'ip',
|
|
182
|
+
network
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
'account',
|
|
186
|
+
[
|
|
187
|
+
input.email.trim().toLowerCase(),
|
|
188
|
+
network
|
|
189
|
+
]
|
|
190
|
+
]
|
|
191
|
+
]){
|
|
192
|
+
const rule = rules[scope];
|
|
193
|
+
const windowMs = 1000 * rule.windowSeconds;
|
|
194
|
+
const end = (Math.floor(time / windowMs) + 1) * windowMs;
|
|
195
|
+
if (!await store.consume(digest([
|
|
196
|
+
scope,
|
|
197
|
+
identity,
|
|
198
|
+
end
|
|
199
|
+
]), rule.limit, new Date(end))) {
|
|
200
|
+
emit({
|
|
201
|
+
type: 'denied',
|
|
202
|
+
scope,
|
|
203
|
+
...ids
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
allowed: false,
|
|
207
|
+
retryAfterSeconds: Math.max(1, Math.ceil((end - time) / 1000))
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
emit({
|
|
212
|
+
type: 'admitted',
|
|
213
|
+
...ids
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
allowed: true,
|
|
217
|
+
retryAfterSeconds: 0
|
|
218
|
+
};
|
|
219
|
+
} catch (error) {
|
|
220
|
+
emit({
|
|
221
|
+
type: 'store-error',
|
|
222
|
+
...ids
|
|
223
|
+
});
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export { createPasswordSignInLimiter };
|
package/dist/store.d.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
import type { AdminPermissionsRepository } from './modules/admin-permissions/repository.js';
|
|
9
9
|
import type { AdminPreferencesRepository } from './modules/admin-preferences/repository.js';
|
|
10
10
|
import type { AdminRolesRepository } from './modules/admin-roles/repository.js';
|
|
11
|
-
import type { AdminUsersRepository } from './modules/admin-users/repository.js';
|
|
11
|
+
import type { AdminUsersRepository, AdminUserWithPasswordRow } from './modules/admin-users/repository.js';
|
|
12
|
+
import type { LoginSessionsRepository } from './modules/auth/login-sessions-repository.js';
|
|
12
13
|
import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repository.js';
|
|
14
|
+
import type { SignInRateLimitStore } from './modules/auth/sign-in-rate-limiter.js';
|
|
13
15
|
/**
|
|
14
16
|
* The bundle of repositories that `@byline/admin` needs from the DB
|
|
15
17
|
* adapter. A DB adapter package (`@byline/db-postgres`, a future
|
|
@@ -19,11 +21,22 @@ import type { RefreshTokensRepository } from './modules/auth/refresh-tokens-repo
|
|
|
19
21
|
* `JwtSessionProvider`, to `seedSuperAdmin`, and (later) to admin-user
|
|
20
22
|
* and admin-role commands.
|
|
21
23
|
*
|
|
22
|
-
* Keeping the
|
|
24
|
+
* Keeping the repositories together as a single argument avoids
|
|
23
25
|
* exploding constructor signatures and makes "needs admin DB access" a
|
|
24
26
|
* single, recognisable type.
|
|
25
27
|
*/
|
|
26
28
|
export interface AdminStore {
|
|
29
|
+
/**
|
|
30
|
+
* Serialize native issuance/revocation against account mutations. Lock the
|
|
31
|
+
* account row first, then operate on refresh rows through the scoped store.
|
|
32
|
+
* The callback and all its writes commit together or roll back together.
|
|
33
|
+
* Never retain the scoped repositories beyond the callback. No automatic retries.
|
|
34
|
+
*/
|
|
35
|
+
withSessionLock<T>(adminUserId: string, work: (store: AdminStore, user: AdminUserWithPasswordRow | null) => Promise<T>): Promise<T>;
|
|
36
|
+
/** Lock distinct account IDs in sorted order in one transaction for account-switch sign-in. */
|
|
37
|
+
withSessionLocks<T>(adminUserIds: string[], work: (store: AdminStore) => Promise<T>): Promise<T>;
|
|
38
|
+
loginSessions: LoginSessionsRepository;
|
|
39
|
+
signInRateLimits: SignInRateLimitStore;
|
|
27
40
|
adminUsers: AdminUsersRepository;
|
|
28
41
|
adminRoles: AdminRolesRepository;
|
|
29
42
|
adminPermissions: AdminPermissionsRepository;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "5.
|
|
5
|
+
"version": "5.1.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -174,40 +174,40 @@
|
|
|
174
174
|
"@codemirror/lang-sql": "^6.10.0",
|
|
175
175
|
"@codemirror/lang-yaml": "^6.1.3",
|
|
176
176
|
"@codemirror/language": "^6.12.4",
|
|
177
|
-
"@codemirror/state": "^6.7.
|
|
178
|
-
"@codemirror/view": "^6.43.
|
|
177
|
+
"@codemirror/state": "^6.7.4",
|
|
178
|
+
"@codemirror/view": "^6.43.11",
|
|
179
179
|
"@lezer/highlight": "^1.2.3",
|
|
180
180
|
"@tanstack/react-form-start": "^1.33.5",
|
|
181
181
|
"clsx": "^2.1.1",
|
|
182
|
-
"jose": "^6.2.
|
|
182
|
+
"jose": "^6.2.12",
|
|
183
183
|
"react-diff-viewer-continued": "^4.4.0",
|
|
184
184
|
"uuid": "^14.0.2",
|
|
185
|
-
"zod": "^4.4
|
|
186
|
-
"@byline/
|
|
187
|
-
"@byline/analytics": "5.
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
185
|
+
"zod": "^4.5.4",
|
|
186
|
+
"@byline/analytics": "5.1.0",
|
|
187
|
+
"@byline/analytics-agent": "5.1.0",
|
|
188
|
+
"@byline/core": "5.1.0",
|
|
189
|
+
"@byline/auth": "5.1.0",
|
|
190
|
+
"@byline/ui": "5.1.0",
|
|
191
|
+
"@byline/i18n": "5.1.0"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
195
195
|
"react-dom": "^19.0.0"
|
|
196
196
|
},
|
|
197
197
|
"devDependencies": {
|
|
198
|
-
"@biomejs/biome": "2.5.
|
|
198
|
+
"@biomejs/biome": "2.5.12",
|
|
199
199
|
"@rsbuild/plugin-react": "^2.1.0",
|
|
200
|
-
"@rslib/core": "^0.
|
|
201
|
-
"@types/node": "^26.
|
|
200
|
+
"@rslib/core": "^1.0.0",
|
|
201
|
+
"@types/node": "^26.5.0",
|
|
202
202
|
"@types/react": "19.2.18",
|
|
203
|
-
"@types/react-dom": "19.2.
|
|
204
|
-
"@vitejs/plugin-react": "^6.1.
|
|
203
|
+
"@types/react-dom": "19.2.7",
|
|
204
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
205
205
|
"jsdom": "^30.0.1",
|
|
206
206
|
"react": "^19.2.8",
|
|
207
207
|
"react-dom": "^19.2.8",
|
|
208
208
|
"typescript": "^7.0.2",
|
|
209
209
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
210
|
-
"vitest": "^
|
|
210
|
+
"vitest": "^5.0.0"
|
|
211
211
|
},
|
|
212
212
|
"publishConfig": {
|
|
213
213
|
"access": "public",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { act } from 'react'
|
|
2
|
+
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
change: vi.fn(),
|
|
8
|
+
submit: null as
|
|
9
|
+
| null
|
|
10
|
+
| ((args: {
|
|
11
|
+
value: { currentPassword: string; newPassword: string; confirm: string }
|
|
12
|
+
}) => Promise<void>),
|
|
13
|
+
}))
|
|
14
|
+
vi.mock('@tanstack/react-form-start', () => ({
|
|
15
|
+
revalidateLogic: () => ({}),
|
|
16
|
+
useForm: (options: { onSubmit: typeof mocks.submit }) => {
|
|
17
|
+
mocks.submit = options.onSubmit
|
|
18
|
+
return { reset: vi.fn(), Field: () => null, Subscribe: () => null }
|
|
19
|
+
},
|
|
20
|
+
}))
|
|
21
|
+
vi.mock('../../../services/admin-services-context.js', () => ({
|
|
22
|
+
useBylineAdminServices: () => ({ changeAccountPassword: mocks.change }),
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('@byline/i18n/react', () => ({
|
|
25
|
+
useTranslation: () => ({
|
|
26
|
+
t: (key: string) =>
|
|
27
|
+
({
|
|
28
|
+
'account.changePassword.feedback.updated': 'Password updated.',
|
|
29
|
+
'auth.signIn.title': 'Sign in',
|
|
30
|
+
})[key] ?? key,
|
|
31
|
+
}),
|
|
32
|
+
}))
|
|
33
|
+
vi.mock('@byline/ui/react', () => ({
|
|
34
|
+
Alert: ({ children, role }: { children: React.ReactNode; role?: string }) => (
|
|
35
|
+
<div role={role}>{children}</div>
|
|
36
|
+
),
|
|
37
|
+
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
|
38
|
+
<button type="button" onClick={onClick}>
|
|
39
|
+
{children}
|
|
40
|
+
</button>
|
|
41
|
+
),
|
|
42
|
+
InputPassword: () => null,
|
|
43
|
+
LoaderEllipsis: () => null,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
import { ChangeAccountPassword } from './change-password.js'
|
|
47
|
+
import type { AccountResponse } from '../index.js'
|
|
48
|
+
|
|
49
|
+
let root: Root
|
|
50
|
+
let container: HTMLDivElement
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
53
|
+
vi.clearAllMocks()
|
|
54
|
+
container = document.createElement('div')
|
|
55
|
+
document.body.appendChild(container)
|
|
56
|
+
root = createRoot(container)
|
|
57
|
+
})
|
|
58
|
+
afterEach(async () => {
|
|
59
|
+
await act(async () => root.unmount())
|
|
60
|
+
container.remove()
|
|
61
|
+
})
|
|
62
|
+
it('keeps success confirmation and a sign-in action visible after completion', async () => {
|
|
63
|
+
const account = { id: 'account', vid: 1 } as AccountResponse
|
|
64
|
+
mocks.change.mockResolvedValue({ ...account, vid: 2 })
|
|
65
|
+
await act(async () => root.render(<ChangeAccountPassword account={account} />))
|
|
66
|
+
await act(async () => {
|
|
67
|
+
await mocks.submit?.({ value: { currentPassword: 'old', newPassword: 'new', confirm: 'new' } })
|
|
68
|
+
})
|
|
69
|
+
expect(container.querySelector('[role="status"]')?.textContent).toBe('Password updated.')
|
|
70
|
+
expect(container.querySelector('button')?.textContent).toBe('Sign in')
|
|
71
|
+
expect(container.querySelector('form')).toBeNull()
|
|
72
|
+
})
|
|
@@ -19,10 +19,8 @@
|
|
|
19
19
|
* password surfaces as `admin.account.invalidCurrentPassword`.
|
|
20
20
|
* - Confirmation field catches typos before round-trip.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (~15 min); a "sign out everywhere on password change" follow-up
|
|
25
|
-
* will close that gap.
|
|
22
|
+
* Successful password changes end native sessions. Confirmation remains visible
|
|
23
|
+
* until the user reloads the protected account page to reach sign-in.
|
|
26
24
|
*/
|
|
27
25
|
|
|
28
26
|
import { useMemo, useState } from 'react'
|
|
@@ -127,6 +125,19 @@ export function ChangeAccountPassword({ account, onClose, onSuccess }: ChangePas
|
|
|
127
125
|
},
|
|
128
126
|
})
|
|
129
127
|
|
|
128
|
+
if (successMessage) {
|
|
129
|
+
return (
|
|
130
|
+
<div className={cx('byline-account-change-password-wrap', styles.wrap)}>
|
|
131
|
+
<div role="status">
|
|
132
|
+
<Alert intent="success">{successMessage}</Alert>
|
|
133
|
+
</div>
|
|
134
|
+
<Button type="button" intent="primary" onClick={() => window.location.reload()}>
|
|
135
|
+
{t('auth.signIn.title')}
|
|
136
|
+
</Button>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
130
141
|
return (
|
|
131
142
|
<div className={cx('byline-account-change-password-wrap', styles.wrap)}>
|
|
132
143
|
<form
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { toAdminUser } from '../admin-users/dto.js'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ERR_ADMIN_USER_EMAIL_IN_USE,
|
|
12
|
+
ERR_ADMIN_USER_VERSION_CONFLICT,
|
|
13
|
+
} from '../admin-users/errors.js'
|
|
11
14
|
import { hashPassword, verifyPassword } from '../auth/password.js'
|
|
12
15
|
import {
|
|
13
16
|
ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD,
|
|
@@ -38,12 +41,8 @@ import type {
|
|
|
38
41
|
* in the new hash. A hijacked session cannot use this flow to lock
|
|
39
42
|
* out the legitimate owner.
|
|
40
43
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* valid until their 15-minute expiry, and other refresh tokens remain
|
|
44
|
-
* useable. A "sign out everywhere on password change" follow-up should
|
|
45
|
-
* call `RefreshTokensRepository.revokeAllExcept(adminUserId, currentJti)`
|
|
46
|
-
* once that lands.
|
|
44
|
+
* Native adapter password writes atomically advance the session generation and
|
|
45
|
+
* revoke every refresh session. External providers own their own revocation.
|
|
47
46
|
*/
|
|
48
47
|
export class AdminAccountService {
|
|
49
48
|
readonly #repo: AdminUsersRepository
|
|
@@ -97,6 +96,8 @@ export class AdminAccountService {
|
|
|
97
96
|
const ok = await verifyPassword(request.currentPassword, withHash.password_hash)
|
|
98
97
|
if (!ok) throw ERR_ADMIN_ACCOUNT_INVALID_CURRENT_PASSWORD()
|
|
99
98
|
|
|
99
|
+
if (withHash.vid !== request.vid) throw ERR_ADMIN_USER_VERSION_CONFLICT()
|
|
100
|
+
|
|
100
101
|
const newHash = await hashPassword(request.newPassword)
|
|
101
102
|
const row = await this.#repo.setPasswordHash(actorId, request.vid, newHash)
|
|
102
103
|
return toAdminUser(row)
|
|
@@ -68,6 +68,8 @@ export interface AdminUserRow {
|
|
|
68
68
|
*/
|
|
69
69
|
export interface AdminUserWithPasswordRow extends AdminUserRow {
|
|
70
70
|
password_hash: string
|
|
71
|
+
/** Native session generation, independent of edit revisions. */
|
|
72
|
+
session_version: number
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
export interface CreateAdminUserInput {
|
|
@@ -158,16 +160,23 @@ export interface AdminUsersRepository {
|
|
|
158
160
|
* Content update with optimistic concurrency. Throws
|
|
159
161
|
* `AdminUsersError(VERSION_CONFLICT)` if the stored `vid` differs from
|
|
160
162
|
* `expectedVid`. Bumps `vid` on success and returns the fresh row.
|
|
163
|
+
* A false `is_enabled` patch must atomically advance the native session
|
|
164
|
+
* generation and revoke refresh sessions, under the account row lock.
|
|
161
165
|
*/
|
|
162
166
|
update(id: string, expectedVid: number, patch: UpdateAdminUserInput): Promise<AdminUserRow>
|
|
163
167
|
/**
|
|
164
168
|
* Replace the stored password hash with optimistic concurrency.
|
|
165
169
|
* Version-gated on `expectedVid`. Caller supplies a pre-hashed PHC string.
|
|
170
|
+
* Atomically advance the native session generation and revoke every refresh
|
|
171
|
+
* session in the same transaction. Lock the account before refresh rows.
|
|
166
172
|
* Returns the updated row so callers holding the edit form can refresh
|
|
167
173
|
* their cached `vid` without a second round-trip.
|
|
168
174
|
*/
|
|
169
175
|
setPasswordHash(id: string, expectedVid: number, passwordHash: string): Promise<AdminUserRow>
|
|
170
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Toggle enabled state. Disable must atomically advance the native session
|
|
178
|
+
* generation and revoke refresh sessions. Enable never resets the generation.
|
|
179
|
+
*/
|
|
171
180
|
setEnabled(id: string, enabled: boolean): Promise<void>
|
|
172
181
|
/**
|
|
173
182
|
* Set the admin interface locale preference. Vid-less — user preference
|
|
@@ -21,9 +21,21 @@
|
|
|
21
21
|
* against `@byline/auth` rather than added here.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
JwtSessionProvider,
|
|
26
|
+
type JwtSessionProviderConfig,
|
|
27
|
+
type NativeSessionEvent,
|
|
28
|
+
} from './jwt-session-provider.js'
|
|
25
29
|
export { hashPassword, verifyPassword } from './password.js'
|
|
26
30
|
export { resolveActor } from './resolve-actor.js'
|
|
31
|
+
export {
|
|
32
|
+
createPasswordSignInLimiter,
|
|
33
|
+
type SignInLimiterOptions,
|
|
34
|
+
type SignInRateLimitPolicy,
|
|
35
|
+
type SignInRateLimitStore,
|
|
36
|
+
type SignInSecurityEvent,
|
|
37
|
+
} from './sign-in-rate-limiter.js'
|
|
38
|
+
export type { LoginSessionRow, LoginSessionsRepository } from './login-sessions-repository.js'
|
|
27
39
|
export type {
|
|
28
40
|
IssueRefreshTokenInput,
|
|
29
41
|
RefreshTokenRow,
|