From 03f1509de72bf2fa79e7ae0642279f1cc76450e5 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 28 Jul 2026 18:32:50 +0800 Subject: [PATCH] feat: implement TASK-WP1-04 admin security --- apps/api/src/app.ts | 148 +++++ apps/api/src/main.ts | 42 +- apps/api/src/managed-storage.ts | 100 ++- apps/api/src/registration-errors.ts | 2 + apps/api/src/registration.ts | 571 +++++++++++++++++- apps/api/src/resend-adapter.ts | 2 +- apps/api/src/secure-config.ts | 22 + apps/api/src/supervisor-channel.ts | 4 +- apps/web/src/admin-auth.css | 157 +++++ apps/web/src/admin-auth.tsx | 150 +++++ apps/web/src/generated/api/sdk.gen.ts | 27 +- apps/web/src/generated/api/types.gen.ts | 33 + apps/web/src/main.tsx | 6 +- apps/web/src/user-auth.tsx | 2 +- openapi/openapi.json | 347 +++++++++++ package.json | 6 +- packages/shared-contracts/src/auth.ts | 50 ++ scripts/run-wp1-04-validation.mjs | 127 ++++ scripts/supervisor-channel-smoke.mjs | 6 +- supervisor/Dada.Supervisor.Tests/Program.cs | 34 +- supervisor/Dada.Supervisor/Credentials.cs | 2 +- .../Dada.Supervisor/OfflineCommandRouter.cs | 66 +- .../Dada.Supervisor/SupervisorRuntime.cs | 1 + tests/api/wp1-04-admin-auth.test.ts | 111 ++++ tests/e2e/admin-auth.spec.ts | 74 +++ tests/e2e/entry-state-ui.spec.ts | 2 +- tests/e2e/user-auth.spec.ts | 2 +- tests/integration/wp1-04-admin-auth.test.ts | 129 ++++ .../integration/wp1-04-secure-config.test.ts | 161 +++++ 29 files changed, 2344 insertions(+), 40 deletions(-) create mode 100644 apps/api/src/secure-config.ts create mode 100644 apps/web/src/admin-auth.css create mode 100644 apps/web/src/admin-auth.tsx create mode 100644 scripts/run-wp1-04-validation.mjs create mode 100644 tests/api/wp1-04-admin-auth.test.ts create mode 100644 tests/e2e/admin-auth.spec.ts create mode 100644 tests/integration/wp1-04-admin-auth.test.ts create mode 100644 tests/integration/wp1-04-secure-config.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 900f5ca..6fdc62c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,11 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { + AdminAuthenticatedUserSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminLoginSendRequestSchema, + AdminSessionResponseSchema, BootstrapResponseSchema, CorrelationIdSchema, AuthenticatedUserSchema, @@ -30,6 +35,8 @@ import { createErrorEnvelope, isCorrelationId, type BootstrapResponse, + type AdminLoginCompleteRequest, + type AdminLoginSendRequest, type LoginCompleteRequest, type LoginSendRequest, type RegistrationCompleteRequest, @@ -101,6 +108,8 @@ const contentSecurityPolicy = [ ].join("; "); const authFlowCookieName = "dada_auth_flow"; const userSessionCookieName = "dada_session"; +const adminAuthFlowCookieName = "dada_admin_auth_flow"; +const adminSessionCookieName = "dada_admin_session"; function requestCorrelationId(headers: Record) { const header = headers["x-correlation-id"]; @@ -211,6 +220,11 @@ export async function createApp(options: CreateAppOptions = {}) { ErrorDetailsSchema, ErrorEnvelopeSchema, AuthenticatedUserSchema, + AdminAuthenticatedUserSchema, + AdminLoginSendRequestSchema, + AdminLoginCompleteRequestSchema, + AdminLoginCompleteResponseSchema, + AdminSessionResponseSchema, CreditSummarySchema, RegistrationSendRequestSchema, RegistrationSendResponseSchema, @@ -302,6 +316,140 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.post( + "/api/v1/admin-auth/login/send", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginSendRequestSchema), + operationId: "sendAdminLoginCode", + response: { + 200: Type.Ref(RegistrationSendResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const existingFlow = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const clientKey = existingFlow ?? randomBytes(32).toString("base64url"); + try { + const body = request.body as AdminLoginSendRequest; + const result = await options.registration.sendAdminLoginCode({ clientKey, email: body.email }); + if (!existingFlow) { + reply.header( + "Set-Cookie", + `${adminAuthFlowCookieName}=${clientKey}; Max-Age=${10 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + } + return { + challenge_expires_at: new Date(result.challengeExpiresAt).toISOString(), + registration_id: result.registrationId, + resend_available_at: new Date(result.resendAvailableAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.post( + "/api/v1/admin-auth/login/complete", + { + attachValidation: true, + schema: { + body: Type.Ref(AdminLoginCompleteRequestSchema), + headers: Type.Ref(RegistrationCompleteHeadersSchema), + operationId: "completeAdminLogin", + response: { + 200: Type.Ref(AdminLoginCompleteResponseSchema), + 400: Type.Ref(ErrorEnvelopeSchema), + 409: Type.Ref(ErrorEnvelopeSchema), + 429: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (request.validationError) return registrationValidationFailure(reply, request.id); + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const clientKey = cookieValue(headerValue(request.headers.cookie), adminAuthFlowCookieName); + const idempotencyKey = headerValue(request.headers["idempotency-key"]); + if (!clientKey || !idempotencyKey) return registrationValidationFailure(reply, request.id); + try { + const body = request.body as AdminLoginCompleteRequest; + const result = options.registration.completeAdminLogin({ + clientKey, + code: body.verification_code, + idempotencyKey, + registrationId: body.registration_id, + }); + reply.header( + "Set-Cookie", + `${adminSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`, + ); + return { + admin: { + role: result.admin.role, + status: result.admin.status, + user_id: result.admin.userId, + }, + audience: result.audience, + session_expires_at: new Date(result.sessionExpiresAt).toISOString(), + status: result.status, + }; + } catch (error) { + return registrationFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/admin-auth/session", + { + schema: { + operationId: "getAdminSession", + response: { + 200: Type.Ref(AdminSessionResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Admin Authentication"], + }, + }, + async (request, reply) => { + if (!options.registration) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + return { + acknowledged_private_content_notice_version: null, + admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id }, + audience: "admin" as const, + authenticated: true as const, + csrf_token: options.registration.issueAdminCsrfToken(token!), + current_private_content_notice_version: null, + expires_at: new Date(session.expires_at).toISOString(), + notice_acknowledged: false, + }; + }, + ); + app.post( "/api/v1/auth/login/send", { diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 416f9bc..6320357 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,19 +1,52 @@ +import { createHmac } from "node:crypto"; import { join, resolve } from "node:path"; +import { registrationNotice } from "@dada/shared-contracts"; + import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; -import { readConfiguredLocalDataRoot } from "./local-data-root.js"; +import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; +import { RegistrationService } from "./registration.js"; +import { MockResendAdapter } from "./resend-adapter.js"; +import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin"); +let registration: RegistrationService | undefined; +const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { - initializeApiCredentialClients(await receiveApiCredentials()); + const clients = initializeApiCredentialClients(await receiveApiCredentials()); + try { + const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) + .update(`Dada/P0A/${purpose}/v1`, "utf8") + .digest(); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); + registration = new RegistrationService({ + adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper), + challengePepper: derivePepper("challenge-pepper"), + currentPrivacyNoticeVersion: registrationNotice.version, + databasePath: join(dataRoot, "db", "dada.sqlite3"), + invitePepper: derivePepper("invite-pepper"), + resend: new MockResendAdapter(), + sessionPepper: derivePepper("session-pepper"), + }); + registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); + } catch (error) { + registration?.close(); + registration = undefined; + throw error; + } finally { + clients.adminAllowlistPepper.fill(0); + } } const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")); -const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {}); +const app = await createApp({ + ...(browserSupportRelease ? { browserSupportRelease } : {}), + ...(registration ? { registration } : {}), +}); await app.listen({ host: "127.0.0.1", @@ -27,10 +60,11 @@ if (controlPipeIndex >= 0) { let storage: ManagedStorage | undefined; const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + registration?.close(); storage?.close(); }); try { - const dataRoot = readConfiguredLocalDataRoot(); + const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); const logger = new StructuredJsonlLogger({ component: "api", diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 4b19087..8e45467 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -93,6 +93,10 @@ function now() { return new Date().toISOString(); } +function auditExpiry(occurredAt: string) { + return new Date(Date.parse(occurredAt) + 180 * 24 * 60 * 60 * 1_000).toISOString(); +} + function validatePositiveBytes(value: number, name: string) { if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name}_invalid`); } @@ -222,12 +226,23 @@ export class ManagedStorage { ); CREATE TABLE IF NOT EXISTS admin_operation_logs ( log_id TEXT PRIMARY KEY, - operation TEXT NOT NULL, - outcome TEXT NOT NULL, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, target_ref TEXT NOT NULL, - created_at TEXT NOT NULL + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at TEXT NOT NULL, + expires_at TEXT NOT NULL ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); + this.migrateLegacyAdminOperationLogs(); const initial = classifyCapacity(0, 0); this.database.prepare(` INSERT OR IGNORE INTO local_backend_storage_state @@ -236,6 +251,54 @@ export class ManagedStorage { `).run(HARD_LIMIT_BYTES, initial.capacity_notice_level, initial.storage_status, now()); } + private migrateLegacyAdminOperationLogs() { + const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "actor_type")) return; + const entries = this.database.prepare(` + SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs + `).all() as Array<{ created_at: string; log_id: string; operation: string; outcome: string; target_ref: string }>; + this.database.exec(` + ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy; + CREATE TABLE admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + `); + const insert = this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?) + `); + for (const entry of entries) { + insert.run( + entry.log_id, + entry.operation, + entry.target_ref, + entry.outcome.startsWith("denied") ? "failed" : "succeeded", + JSON.stringify({ legacy_outcome: entry.outcome }), + entry.created_at, + auditExpiry(entry.created_at), + ); + } + this.database.exec(` + DROP TABLE admin_operation_logs_legacy; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + `); + } + close() { this.database.close(); } @@ -561,9 +624,14 @@ export class ManagedStorage { return row.count > 0; }); if (conflict) { - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'denied_reference_conflict', ?, ?)") - .run(randomUUID(), requestId, now()); + const occurredAt = now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'denied', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'failed', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, JSON.stringify({ reason: "reference_conflict" }), occurredAt, auditExpiry(occurredAt)); return false; } for (const file of files) { @@ -574,9 +642,14 @@ export class ManagedStorage { VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?) `).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, now()); } - this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(now(), requestId); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'asset_cleanup', 'queued', ?, ?)") - .run(randomUUID(), requestId, now()); + const occurredAt = now(); + this.database.prepare("UPDATE asset_cleanup_requests SET status = 'queued', confirmed_at = ? WHERE request_id = ?").run(occurredAt, requestId); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'asset_cleanup', 'cleanup_request', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), requestId, JSON.stringify({ status: "queued" }), occurredAt, auditExpiry(occurredAt)); return true; }); if (!transaction()) throw new Error("ASSET_HISTORY_REFERENCE_CONFLICT"); @@ -605,8 +678,13 @@ export class ManagedStorage { } } this.database.prepare("UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL WHERE cleanup_id = ?").run(now(), row.cleanup_id); - this.database.prepare("INSERT INTO admin_operation_logs (log_id, operation, outcome, target_ref, created_at) VALUES (?, 'physical_file_cleanup', 'completed', ?, ?)") - .run(randomUUID(), row.cleanup_id, now()); + const occurredAt = now(); + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage', 'physical_file_cleanup', 'cleanup_queue_item', ?, 'succeeded', NULL, ?, ?, ?) + `).run(randomUUID(), row.cleanup_id, JSON.stringify({ status: "completed" }), occurredAt, auditExpiry(occurredAt)); this.recordPhysicalMeasurement(); }); finish(); diff --git a/apps/api/src/registration-errors.ts b/apps/api/src/registration-errors.ts index b2a91c7..2b80917 100644 --- a/apps/api/src/registration-errors.ts +++ b/apps/api/src/registration-errors.ts @@ -15,6 +15,7 @@ export type RegistrationErrorReason = | "login_registration_required" | "account_suspended" | "login_admin_required" + | "admin_not_allowed" | "resend_too_soon" | "too_many_attempts" | "csrf_invalid" @@ -68,6 +69,7 @@ export function registrationFieldError(reason: RegistrationErrorReason) { login_registration_required: { field: "email", message_key: "auth.login.registration_required" }, account_suspended: { field: "email", message_key: "auth.account.suspended" }, login_admin_required: { field: "email", message_key: "auth.login.admin_required" }, + admin_not_allowed: { field: "email", message_key: "admin.auth.not_allowed" }, resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" }, too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" }, csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" }, diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index ea67670..5504200 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -31,6 +31,9 @@ export interface RegistrationTransactionEvent { | "registration_send_compensation" | "login_send" | "login_complete" + | "admin_login_send" + | "admin_login_complete" + | "secure_config_apply" | "session_issue" | "session_revoke" | "csrf_issue"; @@ -38,6 +41,7 @@ export interface RegistrationTransactionEvent { } interface RegistrationServiceOptions { + adminAllowlistPepper?: Buffer; challengePepper: Buffer; clock?: () => number; codeGenerator?: () => string; @@ -151,6 +155,24 @@ export type LoginCompleteResult = Omit & { status: "authenticated"; }; +export interface AdminLoginCompleteResult { + admin: { + role: "super_admin"; + status: "active"; + userId: string; + }; + audience: "admin"; + sessionExpiresAt: number; + sessionToken: string; + status: "authenticated"; +} + +export interface SecureConfigCandidate { + adminAllowlistHashes: string[]; + adminRecoveryHashes: string[]; + secureConfigRevision: number; +} + interface ImmediateResult { outcome: RegistrationTransactionEvent["outcome"]; value: T; @@ -195,11 +217,13 @@ function constantTimeTextEqual(left: string, right: string) { export class RegistrationService { readonly database: BetterSqlite3.Database; readonly options: Required> & RegistrationServiceOptions; + private adminAllowlistHashes = new Set(); constructor(options: RegistrationServiceOptions) { assertSecret("invitePepper", options.invitePepper); assertSecret("challengePepper", options.challengePepper); assertSecret("sessionPepper", options.sessionPepper); + if (options.adminAllowlistPepper) assertSecret("adminAllowlistPepper", options.adminAllowlistPepper); this.options = { ...options, clock: options.clock ?? Date.now, @@ -571,6 +595,317 @@ export class RegistrationService { return outcome; } + applySecureConfig(candidate: SecureConfigCandidate) { + const now = this.options.clock(); + const fail = (reason: string): never => { + this.recordConfigApplyFailure(reason, now); + throw new Error(reason); + }; + if (!this.options.adminAllowlistPepper) return fail("admin_pepper_not_configured"); + if (!Number.isSafeInteger(candidate.secureConfigRevision) || candidate.secureConfigRevision < 0) { + return fail("secure_config_revision_invalid"); + } + const normalizeHashes = (values: string[], name: string) => { + if (!Array.isArray(values)) return fail(`${name}_invalid`); + const normalized = [...new Set(values.map((value) => value.toUpperCase()))]; + if (normalized.some((value) => !/^[A-F0-9]{64}$/.test(value))) return fail("hmac_invalid"); + return normalized; + }; + const allowlist = normalizeHashes(candidate.adminAllowlistHashes, "admin_allowlist"); + const recoveries = normalizeHashes(candidate.adminRecoveryHashes, "admin_recovery"); + const allowlistSet = new Set(allowlist); + if (recoveries.some((value) => !allowlistSet.has(value))) return fail("admin_recovery_invalid"); + + const state = this.database.prepare(` + SELECT applied_revision FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { applied_revision: number } | undefined; + const appliedRevision = state?.applied_revision ?? 0; + if (candidate.secureConfigRevision === appliedRevision) { + this.adminAllowlistHashes = allowlistSet; + return { appliedRevision, status: "unchanged" as const }; + } + if (candidate.secureConfigRevision !== appliedRevision + 1) return fail("secure_config_revision_out_of_sequence"); + + const ordinaryUsers = this.database.prepare(` + SELECT normalized_email FROM users WHERE role = 'user' AND status <> 'deleted' + `).all() as Array<{ normalized_email: string }>; + if (ordinaryUsers.some((user) => allowlistSet.has(this.adminAllowlistHmac(user.normalized_email)))) { + return fail("identity_conflict"); + } + + try { + const result = this.runImmediate("secure_config_apply", () => { + const recoverySet = new Set(recoveries); + const admins = this.database.prepare(` + SELECT u.user_id, u.normalized_email, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.role = 'super_admin' AND u.status <> 'deleted' + `).all() as Array<{ + allowed: 0 | 1; + normalized_email: string; + status: "active" | "suspended"; + user_id: string; + }>; + let revokedSessions = 0; + let recoveredAdmins = 0; + for (const admin of admins) { + const adminHash = this.adminAllowlistHmac(admin.normalized_email); + const allowed = allowlistSet.has(adminHash); + this.database.prepare(` + INSERT INTO admin_access (user_id, allowed) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET allowed = excluded.allowed + `).run(admin.user_id, allowed ? 1 : 0); + if (!allowed) { + revokedSessions += this.database.prepare(` + UPDATE sessions SET revoked_at = ? + WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL + `).run(now, admin.user_id).changes; + if (admin.allowed === 1) { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { access: "removed" }, + beforeSummary: { access: "allowed" }, + operationType: "admin_allowlist_remove", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } else if (admin.status === "suspended" && recoverySet.has(adminHash)) { + this.database.prepare("UPDATE users SET status = 'active' WHERE user_id = ?").run(admin.user_id); + this.database.prepare(` + DELETE FROM email_challenges WHERE email = ? AND purpose = 'admin_login' + `).run(admin.normalized_email); + recoveredAdmins += 1; + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { status: "active" }, + beforeSummary: { status: "suspended" }, + operationType: "admin_recover", + result: "succeeded", + targetRef: admin.user_id, + targetType: "admin_account", + }, now); + } + } + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { allowlist_count: allowlist.length, recovered_admins: recoveredAdmins, revoked_sessions: revokedSessions }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount(), revision: appliedRevision }, + operationType: "secure_config_apply", + result: "succeeded", + targetRef: `revision:${candidate.secureConfigRevision}`, + targetType: "secure_config_revision", + }, now); + this.database.prepare(` + INSERT INTO secure_config_apply_state (singleton, applied_revision, allowlist_count, applied_at) + VALUES (1, ?, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + applied_revision = excluded.applied_revision, + allowlist_count = excluded.allowlist_count, + applied_at = excluded.applied_at + `).run(candidate.secureConfigRevision, allowlist.length, now); + return { + outcome: "committed", + value: { appliedRevision: candidate.secureConfigRevision, status: "applied" as const }, + }; + }); + this.adminAllowlistHashes = allowlistSet; + return result; + } catch (error) { + const reason = error instanceof Error ? error.message : "secure_config_apply_failed"; + this.recordConfigApplyFailure(reason, now); + throw error; + } + } + + async sendAdminLoginCode(input: { clientKey: string; email: string }): Promise { + const email = normalizeEmail(input.email); + const clientKey = normalizeProfileValue(input.clientKey, 160); + const now = this.options.clock(); + const challengeId = randomUUID(); + const code = this.options.codeGenerator(); + if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits."); + + const outcome = this.runImmediate("admin_login_send", () => { + if (!this.isAdminAllowlisted(email)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + const user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user?.status === "suspended") { + this.recordAdminLoginRejection("suspended", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended") }; + } + if (user && (user.role !== "super_admin" || user.allowed !== 1)) { + this.recordAdminLoginRejection("not_allowed", now); + return { outcome: "rejected", value: new RegistrationError("AUTH_ENTRY_REJECTED", "admin_not_allowed") }; + } + this.assertChallengeSendAllowed(email, "admin_login", clientKey, now); + this.recordRateSend(email, clientKey, now); + this.database.prepare(` + INSERT INTO email_challenges ( + challenge_id, email, invite_id, code_hmac, purpose, expires_at, + resend_available_at, failure_count, consumed_at, created_at + ) VALUES (?, ?, NULL, ?, 'admin_login', ?, ?, 0, NULL, ?) + `).run( + challengeId, + email, + this.challengeHmac(challengeId, code), + now + challengeLifetimeMilliseconds, + now + resendDelayMilliseconds, + now, + ); + return { + outcome: "committed", + value: { + challengeExpiresAt: now + challengeLifetimeMilliseconds, + registrationId: challengeId, + resendAvailableAt: now + resendDelayMilliseconds, + status: "verification_sent" as const, + }, + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + try { + await this.options.resend.sendVerificationCode({ challengeId, code, email, purpose: "admin_login" }); + } catch { + this.runImmediate("registration_send_compensation", () => { + this.database.prepare("DELETE FROM email_challenges WHERE challenge_id = ? AND consumed_at IS NULL").run(challengeId); + this.recordAdminLoginRejection("service_unavailable", now); + return { outcome: "committed", value: undefined }; + }); + throw new Error("AUTH_SERVICE_UNAVAILABLE"); + } + return outcome; + } + + completeAdminLogin(input: LoginCompleteInput): AdminLoginCompleteResult { + if (!/^[0-9]{6}$/.test(input.code)) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"); + const clientKey = normalizeProfileValue(input.clientKey, 160); + if (input.idempotencyKey.length < 32 || input.idempotencyKey.length > 200 || !/^[A-Za-z0-9_-]+$/.test(input.idempotencyKey)) { + throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "idempotency_conflict"); + } + const now = this.options.clock(); + const idempotencyDigest = this.keyedHmac(this.options.sessionPepper, `admin-login-idempotency:${input.idempotencyKey}`); + const requestHash = this.keyedHmac(this.options.challengePepper, JSON.stringify({ + clientKey, + code: input.code, + registrationId: input.registrationId, + })); + const outcome = this.runImmediate("admin_login_complete", () => { + const previous = this.database.prepare(` + SELECT request_hash, outcome_code, failure_reason, user_id, session_id + FROM login_attempts WHERE idempotency_key_digest = ? + `).get(idempotencyDigest) as LoginAttemptRow | undefined; + if (previous) { + if (!constantTimeTextEqual(previous.request_hash, requestHash)) { + throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict"); + } + if (previous.outcome_code === "failure") { + return { + outcome: "idempotent_replay", + value: new RegistrationError("AUTH_ENTRY_REJECTED", previous.failure_reason ?? "challenge_invalid"), + }; + } + return { + outcome: "idempotent_replay", + value: this.readAdminLoginResult(previous.user_id!, previous.session_id!), + }; + } + const challenge = this.database.prepare(` + SELECT challenge_id, email, invite_id, code_hmac, expires_at, consumed_at + FROM email_challenges WHERE challenge_id = ? AND purpose = 'admin_login' + `).get(input.registrationId) as ChallengeRow | undefined; + if (!challenge || challenge.consumed_at !== null) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_invalid", now); + } + if (!this.isAdminAllowlisted(challenge.email)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + const rate = this.readRateLimit(challenge.email, clientKey, now); + if (rate.blocked_until !== null && rate.blocked_until > now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "too_many_attempts", now); + } + if (challenge.expires_at <= now) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "challenge_expired", now); + } + if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(challenge.challenge_id, input.code))) { + this.database.prepare("UPDATE email_challenges SET failure_count = failure_count + 1 WHERE challenge_id = ?") + .run(challenge.challenge_id); + const failedAttempts = this.recordRateFailure(challenge.email, clientKey, now); + return this.recordAdminLoginFailure( + idempotencyDigest, + requestHash, + input.registrationId, + failedAttempts >= maximumFailedAttempts ? "too_many_attempts" : "challenge_invalid", + now, + ); + } + let user = this.database.prepare(` + SELECT u.user_id, u.role, u.status, COALESCE(a.allowed, 0) AS allowed + FROM users u LEFT JOIN admin_access a ON a.user_id = u.user_id + WHERE u.normalized_email = ? AND u.status <> 'deleted' + `).get(challenge.email) as { allowed: 0 | 1; role: "user" | "super_admin"; status: "active" | "suspended"; user_id: string } | undefined; + if (user && (user.role !== "super_admin" || user.status !== "active" || user.allowed !== 1)) { + return this.recordAdminLoginFailure(idempotencyDigest, requestHash, input.registrationId, "admin_not_allowed", now); + } + if (!user) { + const userId = randomUUID(); + this.database.prepare(` + INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, + registration_id, created_at + ) VALUES (?, ?, 'super_admin', 'active', 0, ?, ?) + `).run(userId, challenge.email, challenge.challenge_id, now); + this.database.prepare("INSERT INTO admin_access (user_id, allowed) VALUES (?, 1)").run(userId); + user = { allowed: 1, role: "super_admin", status: "active", user_id: userId }; + this.recordAdminAudit({ + actorRef: userId, + actorType: "super_admin", + afterSummary: { role: "super_admin", status: "active" }, + beforeSummary: null, + operationType: "admin_create", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); + } + this.database.prepare("UPDATE email_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL") + .run(now, challenge.challenge_id); + const issued = this.insertSession(user.user_id, "admin", now); + this.database.prepare(` + INSERT INTO login_attempts ( + idempotency_key_digest, request_hash, challenge_id, outcome_code, + failure_reason, user_id, session_id, created_at + ) VALUES (?, ?, ?, 'success', NULL, ?, ?, ?) + `).run(idempotencyDigest, requestHash, input.registrationId, user.user_id, issued.sessionId, now); + this.recordAdminAudit({ + actorRef: user.user_id, + actorType: "super_admin", + afterSummary: { audience: "admin" }, + beforeSummary: null, + operationType: "admin_login", + result: "succeeded", + targetRef: user.user_id, + targetType: "admin_session", + }, now); + return { + outcome: "committed", + value: this.readAdminLoginResult(user.user_id, issued.sessionId), + }; + }); + if (outcome instanceof RegistrationError) throw outcome; + return outcome; + } + issueAuthenticatedSession(userId: string, audience: "user" | "admin") { const now = this.options.clock(); return this.runImmediate("session_issue", () => { @@ -614,6 +949,24 @@ export class RegistrationService { }); } + issueAdminCsrfToken(sessionToken: string) { + const now = this.options.clock(); + const csrfToken = randomBytes(32).toString("base64url"); + return this.runImmediate("csrf_issue", () => { + const session = this.database.prepare(` + SELECT s.session_id FROM sessions s + JOIN users u ON u.user_id = s.user_id + JOIN admin_access a ON a.user_id = u.user_id + WHERE s.token_digest = ? AND s.audience = 'admin' AND s.revoked_at IS NULL + AND s.expires_at > ? AND u.role = 'super_admin' AND u.status = 'active' AND a.allowed = 1 + `).get(digest(sessionToken), now) as { session_id: string } | undefined; + if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid"); + this.database.prepare("UPDATE sessions SET csrf_token_digest = ? WHERE session_id = ?") + .run(digest(csrfToken), session.session_id); + return { outcome: "committed", value: csrfToken }; + }); + } + logoutUser(input: { csrfToken: string; sessionToken: string }) { const now = this.options.clock(); this.runImmediate("session_revoke", () => { @@ -653,6 +1006,16 @@ export class RegistrationService { if (reason === "whitelist_removed") this.database.prepare("UPDATE admin_access SET allowed = 0 WHERE user_id = ?").run(userId); this.database.prepare("UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND audience = 'admin' AND revoked_at IS NULL") .run(now, userId); + this.recordAdminAudit({ + actorRef: reason === "whitelist_removed" ? "backend_secure_config" : userId, + actorType: reason === "whitelist_removed" ? "system" : "super_admin", + afterSummary: { access: reason === "whitelist_removed" ? "removed" : reason }, + beforeSummary: { access: "active" }, + operationType: reason === "disabled" ? "admin_disable" : reason === "logout" ? "admin_logout" : "admin_allowlist_remove", + result: "succeeded", + targetRef: userId, + targetType: "admin_account", + }, now); return { outcome: "committed", value: undefined }; }); } @@ -794,6 +1157,29 @@ export class RegistrationService { user_id TEXT PRIMARY KEY REFERENCES users(user_id), allowed INTEGER NOT NULL CHECK (allowed IN (0, 1)) ); + CREATE TABLE IF NOT EXISTS secure_config_apply_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + applied_revision INTEGER NOT NULL CHECK (applied_revision >= 0), + allowlist_count INTEGER NOT NULL CHECK (allowlist_count >= 0), + applied_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; CREATE TABLE IF NOT EXISTS auth_rate_limits ( rate_key TEXT PRIMARY KEY, window_started_at INTEGER NOT NULL, @@ -811,6 +1197,62 @@ export class RegistrationService { session_id TEXT REFERENCES sessions(session_id), created_at INTEGER NOT NULL ); + INSERT OR IGNORE INTO secure_config_apply_state ( + singleton, applied_revision, allowlist_count, applied_at + ) VALUES (1, 0, 0, 0); + `); + this.migrateLegacyAdminOperationLogs(); + } + + private migrateLegacyAdminOperationLogs() { + const columns = this.database.prepare("PRAGMA table_info(admin_operation_logs)").all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "actor_type")) return; + const legacy = this.database.prepare(` + SELECT log_id, operation, outcome, target_ref, created_at FROM admin_operation_logs + `).all() as Array<{ created_at: string | number; log_id: string; operation: string; outcome: string; target_ref: string }>; + this.database.exec(` + DROP TRIGGER IF EXISTS admin_operation_logs_no_update; + DROP TRIGGER IF EXISTS admin_operation_logs_no_delete; + ALTER TABLE admin_operation_logs RENAME TO admin_operation_logs_legacy; + CREATE TABLE admin_operation_logs ( + log_id TEXT PRIMARY KEY, + actor_type TEXT NOT NULL CHECK (actor_type IN ('system', 'super_admin')), + actor_ref TEXT NOT NULL, + operation_type TEXT NOT NULL, + target_type TEXT NOT NULL, + target_ref TEXT NOT NULL, + result TEXT NOT NULL CHECK (result IN ('succeeded', 'failed')), + before_summary TEXT, + after_summary TEXT, + occurred_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL + ); + `); + const insert = this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, 'system', 'managed_storage_migration', ?, 'legacy_operation', ?, ?, NULL, ?, ?, ?) + `); + for (const entry of legacy) { + const parsed = typeof entry.created_at === "number" ? entry.created_at : Date.parse(entry.created_at); + const occurredAt = Number.isFinite(parsed) ? parsed : this.options.clock(); + insert.run( + entry.log_id, + entry.operation, + entry.target_ref, + entry.outcome.startsWith("denied") ? "failed" : "succeeded", + JSON.stringify({ legacy_outcome: entry.outcome }), + occurredAt, + occurredAt + 180 * 24 * 60 * 60 * 1_000, + ); + } + this.database.exec(` + DROP TABLE admin_operation_logs_legacy; + CREATE TRIGGER admin_operation_logs_no_update + BEFORE UPDATE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; + CREATE TRIGGER admin_operation_logs_no_delete + BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END; `); } @@ -843,6 +1285,19 @@ export class RegistrationService { return this.keyedHmac(this.options.challengePepper, `${challengeId}:${code}`); } + private adminAllowlistHmac(email: string) { + if (!this.options.adminAllowlistPepper) throw new Error("admin_pepper_not_configured"); + return createHmac("sha256", this.options.adminAllowlistPepper) + .update(email.trim().toLowerCase(), "utf8") + .digest("hex") + .toUpperCase(); + } + + private isAdminAllowlisted(email: string) { + return Boolean(this.options.adminAllowlistPepper) + && this.adminAllowlistHashes.has(this.adminAllowlistHmac(email)); + } + private sessionToken(sessionId: string) { return createHmac("sha256", this.options.sessionPepper).update(`session:${sessionId}`, "utf8").digest("base64url"); } @@ -909,7 +1364,7 @@ export class RegistrationService { private assertChallengeSendAllowed( email: string, - purpose: "register" | "login", + purpose: "register" | "login" | "admin_login", clientKey: string, now: number, ) { @@ -939,6 +1394,120 @@ export class RegistrationService { return { sessionExpiresAt, sessionId, sessionToken }; } + private readAdminLoginResult(userId: string, sessionId: string): AdminLoginCompleteResult { + const row = this.database.prepare(` + SELECT u.user_id, s.expires_at + FROM users u + JOIN admin_access a ON a.user_id = u.user_id + JOIN sessions s ON s.user_id = u.user_id + WHERE u.user_id = ? AND u.role = 'super_admin' AND u.status = 'active' + AND a.allowed = 1 AND s.session_id = ? AND s.audience = 'admin' + `).get(userId, sessionId) as { expires_at: number; user_id: string } | undefined; + if (!row) throw new RegistrationError("AUTH_ENTRY_REJECTED", "challenge_invalid"); + return { + admin: { role: "super_admin", status: "active", userId: row.user_id }, + audience: "admin", + sessionExpiresAt: row.expires_at, + sessionToken: this.sessionToken(sessionId), + status: "authenticated", + }; + } + + private readAppliedAllowlistCount() { + const state = this.database.prepare(` + SELECT allowlist_count FROM secure_config_apply_state WHERE singleton = 1 + `).get() as { allowlist_count: number } | undefined; + return state?.allowlist_count ?? 0; + } + + private recordAdminAudit(input: { + actorRef: string; + actorType: "system" | "super_admin"; + afterSummary: Record | null; + beforeSummary: Record | null; + operationType: string; + result: "succeeded" | "failed"; + targetRef: string; + targetType: string; + }, now: number) { + this.database.prepare(` + INSERT INTO admin_operation_logs ( + log_id, actor_type, actor_ref, operation_type, target_type, target_ref, + result, before_summary, after_summary, occurred_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + input.actorType, + input.actorRef, + input.operationType, + input.targetType, + input.targetRef, + input.result, + input.beforeSummary === null ? null : JSON.stringify(input.beforeSummary), + input.afterSummary === null ? null : JSON.stringify(input.afterSummary), + now, + now + 180 * 24 * 60 * 60 * 1_000, + ); + } + + private recordAdminLoginRejection(reason: string, now: number) { + this.recordAdminAudit({ + actorRef: "admin_auth", + actorType: "system", + afterSummary: { reason }, + beforeSummary: null, + operationType: "admin_login", + result: "failed", + targetRef: "admin_login", + targetType: "admin_session", + }, now); + } + + private recordAdminLoginFailure( + idempotencyKeyDigest: string, + requestHash: string, + challengeId: string, + reason: RegistrationErrorReason, + now: number, + ) { + const failure = this.recordLoginFailure(idempotencyKeyDigest, requestHash, challengeId, reason, now); + this.recordAdminLoginRejection(reason === "admin_not_allowed" ? "not_allowed" : reason, now); + return failure; + } + + private recordConfigApplyFailure(reason: string, now: number) { + this.database.exec("BEGIN IMMEDIATE"); + try { + this.recordAdminAudit({ + actorRef: "backend_secure_config", + actorType: "system", + afterSummary: { reason: this.safeConfigFailureReason(reason) }, + beforeSummary: { allowlist_count: this.readAppliedAllowlistCount() }, + operationType: "secure_config_apply", + result: "failed", + targetRef: "candidate_revision", + targetType: "secure_config_revision", + }, now); + this.database.exec("COMMIT"); + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw new Error("secure_config_failed_audit_unavailable", { cause: error }); + } + } + + private safeConfigFailureReason(reason: string) { + const allowed = new Set([ + "admin_pepper_not_configured", + "secure_config_revision_invalid", + "admin_allowlist_invalid", + "admin_recovery_invalid", + "hmac_invalid", + "secure_config_revision_out_of_sequence", + "identity_conflict", + ]); + return allowed.has(reason) ? reason : "secure_config_apply_failed"; + } + private recordLoginFailure( idempotencyKeyDigest: string, requestHash: string, diff --git a/apps/api/src/resend-adapter.ts b/apps/api/src/resend-adapter.ts index a9f9516..81ef9d7 100644 --- a/apps/api/src/resend-adapter.ts +++ b/apps/api/src/resend-adapter.ts @@ -2,7 +2,7 @@ export interface RegistrationCodeMessage { challengeId: string; code: string; email: string; - purpose: "register" | "login"; + purpose: "register" | "login" | "admin_login"; } export interface ResendAdapter { diff --git a/apps/api/src/secure-config.ts b/apps/api/src/secure-config.ts new file mode 100644 index 0000000..4300f76 --- /dev/null +++ b/apps/api/src/secure-config.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; + +import type { SecureConfigCandidate } from "./registration.js"; + +export function readSecureConfigCandidate(path: string): SecureConfigCandidate { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Record; + if (parsed.schema_version !== 1 || !Number.isSafeInteger(parsed.secure_config_revision)) { + throw new Error("secure_config_integrity_invalid"); + } + if (!Array.isArray(parsed.admin_allowlist_hashes) || !Array.isArray(parsed.admin_recovery_hashes)) { + throw new Error("secure_config_integrity_invalid"); + } + if (parsed.admin_allowlist_hashes.some((value) => typeof value !== "string") + || parsed.admin_recovery_hashes.some((value) => typeof value !== "string")) { + throw new Error("secure_config_integrity_invalid"); + } + return { + adminAllowlistHashes: parsed.admin_allowlist_hashes as string[], + adminRecoveryHashes: parsed.admin_recovery_hashes as string[], + secureConfigRevision: parsed.secure_config_revision as number, + }; +} diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 45f41f4..17b8cc8 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -1,6 +1,6 @@ import { createConnection } from "node:net"; -const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap"] as const; +const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { const chunks: Buffer[] = []; @@ -26,8 +26,10 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); + const adminPepperValue = credentials["Dada/P0A/admin/pepper"]; for (const name of API_CREDENTIALS) credentials[name] = ""; if (!configured) throw new Error("API credential client initialization failed."); + return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") }; } export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { diff --git a/apps/web/src/admin-auth.css b/apps/web/src/admin-auth.css new file mode 100644 index 0000000..c54393d --- /dev/null +++ b/apps/web/src/admin-auth.css @@ -0,0 +1,157 @@ +:root { + color: #121212; + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.admin-auth-page { + min-height: 100vh; + background: #ffffff; + display: grid; + grid-template-rows: 8px 72px 1fr; +} + +.admin-auth-accent { + background: #eaff00; +} + +.admin-auth-header { + align-items: center; + border-bottom: 1px solid #dedede; + display: flex; + padding: 0 32px; +} + +.admin-auth-wordmark { + color: #111111; + font-family: Arial, sans-serif; + font-size: 20px; + font-weight: 800; + letter-spacing: 0; + text-decoration: none; +} + +.admin-auth-panel { + align-self: center; + justify-self: center; + margin: 48px 20px 96px; + width: min(440px, calc(100vw - 40px)); +} + +.admin-auth-kicker { + color: #606060; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + margin: 0 0 10px; +} + +.admin-auth-panel h1 { + font-size: 26px; + line-height: 1.35; + margin: 0 0 36px; +} + +.admin-auth-panel label { + display: block; + font-size: 14px; + font-weight: 650; + margin-bottom: 9px; +} + +.admin-auth-panel input { + border: 1px solid #b9b9b9; + border-radius: 4px; + font: inherit; + height: 48px; + min-width: 0; + padding: 0 13px; + width: 100%; +} + +.admin-auth-panel input:focus { + border-color: #111111; + box-shadow: 0 0 0 2px #eaff00; + outline: none; +} + +.admin-auth-send-row { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) 124px; +} + +.admin-auth-panel button { + border-radius: 4px; + cursor: pointer; + font: inherit; + font-weight: 700; + height: 48px; +} + +.admin-auth-panel button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + +.admin-auth-send { + background: #ffffff; + border: 1px solid #111111; + width: 124px; +} + +.admin-auth-code-field { + margin-top: 24px; +} + +.admin-auth-error { + border-left: 3px solid #c93333; + color: #8d1717; + font-size: 14px; + line-height: 1.55; + margin: 20px 0 0; + padding-left: 12px; +} + +.admin-auth-submit { + background: #151515; + border: 1px solid #151515; + color: #ffffff; + margin-top: 28px; + width: 100%; +} + +.admin-auth-return { + color: #363636; + display: inline-block; + font-size: 14px; + margin-top: 24px; + text-underline-offset: 4px; +} + +@media (max-width: 480px) { + .admin-auth-header { + padding: 0 20px; + } + + .admin-auth-panel { + align-self: start; + margin-top: 72px; + } + + .admin-auth-send-row { + grid-template-columns: minmax(0, 1fr) 112px; + } + + .admin-auth-send { + width: 112px; + } +} diff --git a/apps/web/src/admin-auth.tsx b/apps/web/src/admin-auth.tsx new file mode 100644 index 0000000..7b3ef42 --- /dev/null +++ b/apps/web/src/admin-auth.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useState, type FormEvent } from "react"; + +import "./admin-auth.css"; + +interface ErrorEnvelopeBody { + error?: { + details?: { field_errors?: Array<{ message_key?: string }> }; + }; +} + +function adminErrorMessage(body: ErrorEnvelopeBody) { + const key = body.error?.details?.field_errors?.[0]?.message_key; + if (key === "auth.challenge.invalid") return "验证码不正确,请检查后重试。"; + if (key === "auth.challenge.expired") return "验证码已过期,请重新获取。"; + if (key === "auth.challenge.resend_too_soon") return "请等待倒计时结束后重新获取验证码。"; + if (key === "auth.account.suspended" || key === "admin.auth.not_allowed") return "无法使用管理员入口,请联系部署维护人员。"; + return "管理员登录暂时无法完成,请稍后重试。"; +} + +export function AdminAuthPage() { + const emailId = useId(); + const codeId = useId(); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [registrationId, setRegistrationId] = useState(); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(); + const emailValid = /^[^@\s]+@[^@\s]+$/.test(email); + + useEffect(() => { + if (countdown <= 0) return; + const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1_000); + return () => window.clearInterval(timer); + }, [countdown]); + + async function sendCode() { + if (!emailValid || sending || countdown > 0) return; + setSending(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/send", { + body: JSON.stringify({ email }), + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody & { registration_id?: string }; + if (!response.ok || !body.registration_id) { + setError(adminErrorMessage(body)); + return; + } + setRegistrationId(body.registration_id); + setCountdown(60); + window.requestAnimationFrame(() => document.getElementById(codeId)?.focus()); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSending(false); + } + } + + async function completeLogin(event: FormEvent) { + event.preventDefault(); + if (!registrationId || !/^[0-9]{6}$/.test(code) || submitting) return; + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/v1/admin-auth/login/complete", { + body: JSON.stringify({ registration_id: registrationId, verification_code: code }), + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""), + }, + method: "POST", + }); + const body = await response.json() as ErrorEnvelopeBody; + if (!response.ok) { + setError(adminErrorMessage(body)); + return; + } + window.location.assign("/admin"); + } catch { + setError("管理员登录暂时无法完成,请稍后重试。"); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ ); +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index 8a19298..8e06543 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, UserSessionResponse, LogoutResponse, RegistrationSendResponse, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; +import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: { }>; } +export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/complete`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function completeLogin(body: LoginCompleteRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); @@ -63,6 +72,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op return response.json() as Promise; } +export async function getAdminSession(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getBootstrap(options: ClientOptions = {}): Promise<{ "app_version": string; "dependencies": Array<{ @@ -120,6 +136,15 @@ export async function logoutUser(options: ClientOptions = {}): Promise; } +export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/login/send`, { body: JSON.stringify(body), method: "POST", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function sendLoginCode(body: LoginSendRequest, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index cb2d72e..8727fc0 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -1,5 +1,38 @@ // Generated from openapi/openapi.json. Do not edit by hand. +export type AdminAuthenticatedUser = { + "role": "super_admin"; + "status": "active"; + "user_id": string; +}; + +export type AdminLoginCompleteRequest = { + "registration_id": string; + "verification_code": string; +}; + +export type AdminLoginCompleteResponse = { + "admin": AdminAuthenticatedUser; + "audience": "admin"; + "session_expires_at": string; + "status": "authenticated"; +}; + +export type AdminLoginSendRequest = { + "email": string; +}; + +export type AdminSessionResponse = { + "acknowledged_private_content_notice_version": string | null; + "admin": AdminAuthenticatedUser; + "audience": "admin"; + "authenticated": true; + "csrf_token": string; + "current_private_content_notice_version": string | null; + "expires_at": string; + "notice_acknowledged": boolean; +}; + export type AuthenticatedUser = { "creator_name": string; "role": "user"; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 75d77c7..18c176c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; +import { AdminAuthPage } from "./admin-auth.js"; import { UserAuthPage } from "./user-auth.js"; const root = document.getElementById("root"); @@ -18,9 +19,12 @@ let authRevision = 0; function renderAuthenticationEntry() { authRevision += 1; + const authenticationPage = window.location.pathname.startsWith("/admin") + ? + : ; appRoot.render( - + {authenticationPage} , ); } diff --git a/apps/web/src/user-auth.tsx b/apps/web/src/user-auth.tsx index 5c2970c..d9fd197 100644 --- a/apps/web/src/user-auth.tsx +++ b/apps/web/src/user-auth.tsx @@ -269,7 +269,7 @@ export function UserAuthPage() {
- 管理员登录 + 管理员登录