Files
Dune/db/admin-access.ts
T
Quintine 8bea4c1a0c Record administrator failed attempts without storing credentials
Rejected administrator authentication and authorization attempts are now
recorded durably in a new additive admin_attempts table with a fixed reason
(invalid_key_format, unknown_or_disabled_key, missing_session,
unknown_or_expired_session, role_denied), the attempt kind, the attempted role
and an already-known account id. No key, key hash, session token, session hash,
request body, origin, address or user agent is stored, and the
unknown/disabled distinction is deliberately collapsed so the lookup cannot
become an account-enumeration oracle.

GET /api/admin/operations?attempts=1 returns seven-day per-reason counters plus
the twenty most recent attempts, newest first, to a live owner only, with
no-store and the same query guard as the other reads. The operations page adds
"List failed attempts" with the counters and the bounded sample, truncating each
account id to eight characters. Recording is best-effort inside the rejection
path, so a rejected request keeps its own answer even when the table is
unavailable, and the history never locks out, disables or revokes anything by
itself.

Evidence: tests/admin-attempts.test.ts asserts every recorded reason, the
kind/role/account fields, that successful sign-ins and privileged reads record
nothing, that no credential text appears anywhere in the page, the reporting
window, the twenty-row bound and the owner-only recheck after demotion, and that
a dropped attempts table leaves the original rejection answer unchanged. The
anonymous HTTP boundary denies ?attempts=1, and the panel was verified in the
real browser against a genuine recorded rejection. The shared AdminError moved
to db/admin-error.ts so the recorder and the access module no longer form a
value-import cycle. Offline suite 7,801/7,801 with typecheck and lint clean. No
game, seat, saved state or deployment changes.
2026-10-07 19:11:45 +11:00

243 lines
8.0 KiB
TypeScript

import { AdminError } from './admin-error';
import { recordAdminAttempt } from './admin-attempts';
export type AdminRole = 'owner' | 'operator' | 'viewer';
export type AdminAccount = { id: string; name: string; role: AdminRole };
export type AdminIdentity = AdminAccount & { sessionHash: string };
export const ADMIN_SESSION_DURATION_MS = 8 * 60 * 60 * 1000;
const UUID =
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
const KEY_PATTERN = new RegExp(`^dune-admin\\.(${UUID})\\.[0-9a-f]{64}$`);
const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
export { AdminError };
function isRole(value: string): value is AdminRole {
return value === 'owner' || value === 'operator' || value === 'viewer';
}
async function hash(value: string) {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(value),
);
return Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
function sessionToken() {
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}
const signInRequired = () =>
new AdminError('Administrator sign-in required.', 401);
/** Creates an independent admin session. Access keys and session tokens never enter audit records. */
export async function adminLogin(
database: D1Database,
key: unknown,
now = Date.now(),
): Promise<{
token: string;
admin: AdminAccount;
expiresAt: number;
}> {
const invalid = () =>
new AdminError('Administrator access key is invalid or revoked.', 401);
if (typeof key !== 'string') {
await recordAdminAttempt(database, { kind: 'login', reason: 'invalid_key_format' }, now);
throw invalid();
}
const match = KEY_PATTERN.exec(key);
if (!match || match[0] !== key) {
await recordAdminAttempt(database, { kind: 'login', reason: 'invalid_key_format' }, now);
throw invalid();
}
const keyHash = await hash(key);
const account = await database
.prepare(
'SELECT id,name,role,session_generation FROM admin_accounts WHERE id = ? AND key_hash = ? AND enabled = 1',
)
.bind(match[1], keyHash)
.first<AdminAccount & { session_generation: number }>();
if (!account || !isRole(account.role)) {
// The lookup cannot distinguish an unknown key from a disabled account
// without becoming an account-enumeration oracle, so both share one reason.
await recordAdminAttempt(database, { kind: 'login', reason: 'unknown_or_disabled_key' }, now);
throw invalid();
}
const token = sessionToken();
const tokenHash = await hash(token);
const sessionId = crypto.randomUUID();
const expiresAt = now + ADMIN_SESSION_DURATION_MS;
// Repeat the credential and generation fence at the write. A disable or
// logout-all between the read and this batch cannot resurrect an old session.
const results = await database.batch([
database
.prepare(
`INSERT INTO admin_sessions (token_hash,id,admin_id,generation,created_at,expires_at)
SELECT ?,?,id,session_generation,?,? FROM admin_accounts
WHERE id = ? AND key_hash = ? AND enabled = 1 AND session_generation = ?`,
)
.bind(
tokenHash,
sessionId,
now,
expiresAt,
account.id,
keyHash,
account.session_generation,
),
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
SELECT ?,admin_id,admin_id,'login',?,? FROM admin_sessions WHERE token_hash = ?`,
)
.bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash),
]);
if (results[0].meta.changes !== 1) {
await recordAdminAttempt(
database,
{ kind: 'login', reason: 'unknown_or_disabled_key', accountId: account.id },
now,
);
throw invalid();
}
// Return current role/name, and fail if revocation committed after creation.
const { id, name, role } = await requireAdmin(
database,
token,
undefined,
now,
);
return { token, admin: { id, name, role }, expiresAt };
}
/** Every privileged request checks live account status, role, generation and absolute expiry. */
export async function requireAdmin(
database: D1Database,
token: string | undefined,
roles?: AdminRole[],
now = Date.now(),
): Promise<AdminIdentity> {
if (
typeof token !== 'string' ||
token.length !== 64 ||
!TOKEN_PATTERN.test(token)
) {
await recordAdminAttempt(database, { kind: 'session', reason: 'missing_session' }, now);
throw signInRequired();
}
const sessionHash = await hash(token);
const account = await database
.prepare(
`SELECT a.id,a.name,a.role FROM admin_accounts a
JOIN admin_sessions s ON s.admin_id = a.id
WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > ?
AND s.generation = a.session_generation AND a.enabled = 1`,
)
.bind(sessionHash, now)
.first<AdminAccount>();
if (!account || !isRole(account.role)) {
await recordAdminAttempt(
database,
{ kind: 'session', reason: 'unknown_or_expired_session' },
now,
);
throw signInRequired();
}
if (roles && !roles.includes(account.role)) {
await recordAdminAttempt(
database,
{ kind: 'role', reason: 'role_denied', role: account.role, accountId: account.id },
now,
);
throw new AdminError(
'Your administrator role does not permit this action.',
403,
);
}
return {
id: account.id,
name: account.name,
role: account.role,
sessionHash,
};
}
/** Idempotent browser logout also accepts an expired/disabled session, without granting any privilege. */
export async function adminLogout(
database: D1Database,
token: string | undefined,
now = Date.now(),
): Promise<void> {
if (
typeof token !== 'string' ||
token.length !== 64 ||
!TOKEN_PATTERN.test(token)
)
return;
const tokenHash = await hash(token);
await database.batch([
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at,detail)
SELECT ?,admin_id,admin_id,'logout',?,json_object('sessionId',id)
FROM admin_sessions WHERE token_hash = ? AND revoked_at IS NULL`,
)
.bind(crypto.randomUUID(), now, tokenHash),
database
.prepare(
'UPDATE admin_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL',
)
.bind(now, tokenHash),
]);
}
/** Revoke every current browser session, including this one, while retaining the access key. */
export async function adminLogoutAll(
database: D1Database,
identity: AdminIdentity,
now = Date.now(),
): Promise<void> {
if (
!identity ||
typeof identity.sessionHash !== 'string' ||
identity.sessionHash.length !== 64 ||
!TOKEN_PATTERN.test(identity.sessionHash)
)
throw signInRequired();
const auditId = crypto.randomUUID();
const results = await database.batch([
database
.prepare(
`UPDATE admin_accounts SET session_generation = session_generation + 1, updated_at = MAX(updated_at + 1, ?)
WHERE id = ? AND enabled = 1 AND EXISTS (
SELECT 1 FROM admin_sessions s WHERE s.token_hash = ? AND s.admin_id = admin_accounts.id
AND s.generation = admin_accounts.session_generation AND s.revoked_at IS NULL AND s.expires_at > ?
)`,
)
.bind(now, identity.id, identity.sessionHash, now),
// changes() refers to the account update immediately before this statement.
database
.prepare(
`INSERT INTO admin_audit (id,actor_admin_id,target_admin_id,action,created_at)
SELECT ?,id,id,'logout_all',? FROM admin_accounts WHERE id = ? AND changes() = 1`,
)
.bind(auditId, now, identity.id),
database
.prepare(
`UPDATE admin_sessions SET revoked_at = ? WHERE admin_id = ? AND revoked_at IS NULL
AND EXISTS (SELECT 1 FROM admin_audit WHERE id = ? AND action = 'logout_all')`,
)
.bind(now, identity.id, auditId),
]);
if (results[0].meta.changes !== 1) throw signInRequired();
}