feat: implement TASK-WP1-04 admin security

This commit is contained in:
suyx
2026-07-28 18:32:50 +08:00
parent 66fe3b763a
commit 03f1509de7
29 changed files with 2344 additions and 40 deletions
+148
View File
@@ -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<string, string | string[] | undefined>) {
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",
{
+38 -4
View File
@@ -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",
+89 -11
View File
@@ -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();
+2
View File
@@ -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" },
+570 -1
View File
@@ -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<RegistrationCompleteResult, "status"> & {
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<T> {
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<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
private adminAllowlistHashes = new Set<string>();
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<RegistrationSendResult> {
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<RegistrationSendResult | RegistrationError>("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<AdminLoginCompleteResult | RegistrationError>("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<string, unknown> | null;
beforeSummary: Record<string, unknown> | 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,
+1 -1
View File
@@ -2,7 +2,7 @@ export interface RegistrationCodeMessage {
challengeId: string;
code: string;
email: string;
purpose: "register" | "login";
purpose: "register" | "login" | "admin_login";
}
export interface ResendAdapter {
+22
View File
@@ -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<string, unknown>;
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,
};
}
+3 -1
View File
@@ -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<void>) {