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.
This commit is contained in:
1 parent
3bebc68cd5
commit
8bea4c1a0c
15 files changed
+3090
-21
No files matched your search
+44
-16
@@ -1,3 +1,6 @@
|
||||
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 };
|
||||
@@ -9,15 +12,7 @@ const UUID =
|
||||
const KEY_PATTERN = new RegExp(`^dune-admin\\.(${UUID})\\.[0-9a-f]{64}$`);
|
||||
const TOKEN_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AdminError';
|
||||
}
|
||||
}
|
||||
export { AdminError };
|
||||
|
||||
function isRole(value: string): value is AdminRole {
|
||||
return value === 'owner' || value === 'operator' || value === 'viewer';
|
||||
@@ -54,9 +49,15 @@ export async function adminLogin(
|
||||
}> {
|
||||
const invalid = () =>
|
||||
new AdminError('Administrator access key is invalid or revoked.', 401);
|
||||
if (typeof key !== 'string') throw invalid();
|
||||
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) throw invalid();
|
||||
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(
|
||||
@@ -64,7 +65,12 @@ export async function adminLogin(
|
||||
)
|
||||
.bind(match[1], keyHash)
|
||||
.first<AdminAccount & { session_generation: number }>();
|
||||
if (!account || !isRole(account.role)) throw invalid();
|
||||
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);
|
||||
@@ -95,7 +101,14 @@ export async function adminLogin(
|
||||
)
|
||||
.bind(crypto.randomUUID(), now, JSON.stringify({ sessionId }), tokenHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) throw invalid();
|
||||
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,
|
||||
@@ -117,8 +130,10 @@ export async function requireAdmin(
|
||||
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(
|
||||
@@ -129,12 +144,25 @@ export async function requireAdmin(
|
||||
)
|
||||
.bind(sessionHash, now)
|
||||
.first<AdminAccount>();
|
||||
if (!account || !isRole(account.role)) throw signInRequired();
|
||||
if (roles && !roles.includes(account.role))
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { AdminError } from './admin-error';
|
||||
import type { AdminIdentity, AdminRole } from './admin-access';
|
||||
|
||||
/** Rejected authentication or authorization attempts, in durable, secret-free form. */
|
||||
export type AdminAttemptKind = 'login' | 'session' | 'role';
|
||||
export type AdminAttemptReason =
|
||||
| 'invalid_key_format'
|
||||
| 'unknown_or_disabled_key'
|
||||
| 'missing_session'
|
||||
| 'unknown_or_expired_session'
|
||||
| 'role_denied';
|
||||
|
||||
export type AdminAttempt = {
|
||||
createdAt: number;
|
||||
kind: AdminAttemptKind;
|
||||
reason: AdminAttemptReason;
|
||||
role: AdminRole | null;
|
||||
accountId: string | null;
|
||||
};
|
||||
|
||||
export type AdminAttempts = {
|
||||
observedAt: number;
|
||||
windowMs: number;
|
||||
total: number;
|
||||
byReason: { reason: AdminAttemptReason; count: number }[];
|
||||
recent: AdminAttempt[];
|
||||
};
|
||||
|
||||
/** Bounded owner sample; the page never lists every rejected request. */
|
||||
export const ADMIN_ATTEMPT_LIMIT = 20;
|
||||
/** Reporting window for the counters. */
|
||||
export const ADMIN_ATTEMPT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const ownerAuthority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
||||
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner'
|
||||
AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation`;
|
||||
|
||||
/** Never stores a key, token, hash, request body or address: only the reason. */
|
||||
export async function recordAdminAttempt(
|
||||
database: D1Database,
|
||||
attempt: {
|
||||
kind: AdminAttemptKind;
|
||||
reason: AdminAttemptReason;
|
||||
role?: AdminRole | null;
|
||||
accountId?: string | null;
|
||||
},
|
||||
now = Date.now(),
|
||||
): Promise<void> {
|
||||
try {
|
||||
await database
|
||||
.prepare(
|
||||
`INSERT INTO admin_attempts (id,created_at,kind,reason,role,account_id)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
)
|
||||
.bind(
|
||||
crypto.randomUUID(),
|
||||
now,
|
||||
attempt.kind,
|
||||
attempt.reason,
|
||||
attempt.role ?? null,
|
||||
attempt.accountId ?? null,
|
||||
)
|
||||
.run();
|
||||
} catch {
|
||||
// Recording is best-effort: a rejected request must keep its own answer
|
||||
// even when the attempt table is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner-only, read-only counters and a bounded recent sample. */
|
||||
export async function readAdminAttempts(
|
||||
database: D1Database,
|
||||
identity: AdminIdentity,
|
||||
now = Date.now(),
|
||||
): Promise<AdminAttempts> {
|
||||
const since = now - ADMIN_ATTEMPT_WINDOW_MS;
|
||||
const authority = database.prepare(ownerAuthority).bind(identity.sessionHash, identity.id, now);
|
||||
const counters = database
|
||||
.prepare(
|
||||
`SELECT reason, COUNT(*) count FROM admin_attempts WHERE created_at>=?
|
||||
GROUP BY reason ORDER BY reason`,
|
||||
)
|
||||
.bind(since);
|
||||
const recent = database
|
||||
.prepare(
|
||||
`SELECT created_at createdAt, kind, reason, role, account_id accountId
|
||||
FROM admin_attempts ORDER BY created_at DESC LIMIT ?`,
|
||||
)
|
||||
.bind(ADMIN_ATTEMPT_LIMIT);
|
||||
const [authorized, grouped, latest] = await database.batch([
|
||||
authority,
|
||||
counters,
|
||||
recent,
|
||||
]);
|
||||
if (!authorized.results.length) throw new AdminError('Owner sign-in required.', 403);
|
||||
const byReason = (grouped.results as { reason: AdminAttemptReason; count: number }[])
|
||||
.map((row) => ({ reason: row.reason, count: row.count }));
|
||||
return {
|
||||
observedAt: now,
|
||||
windowMs: ADMIN_ATTEMPT_WINDOW_MS,
|
||||
total: byReason.reduce((sum, row) => sum + row.count, 0),
|
||||
byReason,
|
||||
recent: (latest.results as AdminAttempt[]).map((row) => ({
|
||||
createdAt: row.createdAt,
|
||||
kind: row.kind,
|
||||
reason: row.reason,
|
||||
role: row.role ?? null,
|
||||
accountId: row.accountId ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Shared administrator failure type; its `status` drives the HTTP answer. */
|
||||
export class AdminError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AdminError';
|
||||
}
|
||||
}
|
||||
@@ -557,3 +557,29 @@ export const adminRoomBackupDownloads = sqliteTable(
|
||||
},
|
||||
(table) => [index('admin_room_backup_downloads_room').on(table.roomCode, table.createdAt)],
|
||||
);
|
||||
|
||||
// Rejected administrator authentication and authorization attempts. Secret-free:
|
||||
// only a fixed reason code, the attempted role and an already-known account id.
|
||||
export const adminAttempts = sqliteTable(
|
||||
'admin_attempts',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
role: text('role'),
|
||||
accountId: text('account_id'),
|
||||
},
|
||||
(table) => [
|
||||
index('admin_attempts_created').on(table.createdAt),
|
||||
check('admin_attempts_kind', sql`${table.kind} IN ('login','session','role')`),
|
||||
check(
|
||||
'admin_attempts_reason',
|
||||
sql`${table.reason} IN ('invalid_key_format','unknown_or_disabled_key','missing_session','unknown_or_expired_session','role_denied')`,
|
||||
),
|
||||
check(
|
||||
'admin_attempts_role',
|
||||
sql`${table.role} IS NULL OR ${table.role} IN ('owner','operator','viewer')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
Reference in new issue
Block a user