- GET /auth/totp/setup returns the pending secret and otpauth URL (created on first call, reused on refresh) for the console to render as a QR code. - Enabling TOTP issues 8 one-time recovery codes (argon2id hashes in admin_recovery_code); login accepts recoveryCode in place of totpCode, disable accepts either, and recovery_codes regenerates with the password. - Passkeys via @simplewebauthn/server: register/list/rename/delete under the console session, public login options/verify that reuse the login rate limit and session cookies. Challenges are one-time rows in webauthn_challenge; RP ID and origins come from WEBAUTHN_RP_ID / WEBAUTHN_ORIGINS, falling back to the request host. Signature counter regressions are rejected. SessionInfo gains passkeyEnabled. - --role rotate-master-key re-encrypts the secret table from SECRET_MASTER_KEY to SECRET_MASTER_KEY_NEW one row per transaction, skips rows already on the new key (resumable, swap keys to roll back), logs progress and audits secret.master_key_rotate. Secrets now record the master key fingerprint in kdf_params.kid. - Runbook: recovery codes, passkey configuration, rotation steps and rollback. Co-Authored-By: Claude Opus 5.5 <[email protected]>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { Api, CSRF_COOKIE, SESSION_COOKIE } from "@umt/contracts";
|
|
import type { Context } from "hono";
|
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
import { type AuthEnv, deriveCsrfToken } from "../../http/auth.ts";
|
|
import { ApiException } from "../../http/errors.ts";
|
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
|
import {
|
|
adminActor,
|
|
assertNotRateLimited,
|
|
type CreatedSession,
|
|
changePassword,
|
|
createSession,
|
|
loadAdmin,
|
|
recordAttempt,
|
|
revokeSession,
|
|
sessionInfo,
|
|
setupAdmin,
|
|
totpBegin,
|
|
totpDisable,
|
|
totpEnable,
|
|
totpRegenerateRecoveryCodes,
|
|
totpSetup,
|
|
totpStatus,
|
|
verifyLogin,
|
|
} from "./auth-service.ts";
|
|
import { audit, type Def, endpoint } from "./endpoint.ts";
|
|
import {
|
|
authenticationOptions,
|
|
deletePasskey,
|
|
listPasskeys,
|
|
type RpInfo,
|
|
registrationOptions,
|
|
renamePasskey,
|
|
resolveRp,
|
|
toPasskeyInfo,
|
|
verifyAuthentication,
|
|
verifyRegistration,
|
|
} from "./passkey.ts";
|
|
|
|
// 登录门禁端点:/auth/setup、/auth/login、/auth/session 免鉴权(见 http/auth.ts PUBLIC_AUTH_PATHS),
|
|
// 其余需要已登录;改密与 TOTP 只接受控制台 cookie 会话。
|
|
|
|
const tags = ["auth"];
|
|
|
|
// 端点定义直接取 contracts 注册表(scope 与 auth 标注只维护一份):setup/login/session 为 auth:"public",
|
|
// password/totp 为 auth:"session"(endpoint() 据此跳过 requireScope 或追加 requireAdminSession)。
|
|
const D = {
|
|
setup: { ...Api.auth.setup, tags },
|
|
login: { ...Api.auth.login, tags },
|
|
logout: { ...Api.auth.logout, tags },
|
|
session: { ...Api.auth.session, tags },
|
|
password: { ...Api.auth.password, tags },
|
|
totpGet: { ...Api.auth.totpStatus, tags },
|
|
totpPost: { ...Api.auth.totp, tags },
|
|
totpSetup: { ...Api.auth.totpSetup, tags },
|
|
passkeys: { ...Api.auth.passkeys, tags },
|
|
passkeyRegisterOptions: { ...Api.auth.passkeyRegisterOptions, tags },
|
|
passkeyRegister: { ...Api.auth.passkeyRegister, tags },
|
|
passkeyRename: { ...Api.auth.passkeyRename, tags },
|
|
passkeyDelete: { ...Api.auth.passkeyDelete, tags },
|
|
passkeyLoginOptions: { ...Api.auth.passkeyLoginOptions, tags },
|
|
passkeyLogin: { ...Api.auth.passkeyLogin, tags },
|
|
} satisfies Record<string, Def>;
|
|
|
|
/** Passkey 登录失败 / 限速计数用的账号维度键(免输入用户名) */
|
|
const PASSKEY_ACCOUNT = "(passkey)";
|
|
|
|
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1$|fc|fd|::ffff:(127|10|192\.168)\.)/i;
|
|
|
|
/**
|
|
* 客户端 IP:只有直连对端是回环/私网(即前面有自己的反代)时才信 X-Real-IP / X-Forwarded-For 最后一跳,
|
|
* 否则用对端地址——防止客户端伪造头绕过 IP 限速(对应 Caddy trusted_proxies_strict)。
|
|
*/
|
|
export function clientIp(c: Context<AuthEnv>): string {
|
|
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
|
if (remote && !PRIVATE_IP.test(remote)) return remote;
|
|
const xri = c.req.header("x-real-ip")?.trim();
|
|
const xff = c.req
|
|
.header("x-forwarded-for")
|
|
?.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
.at(-1);
|
|
return xri || xff || remote || "unknown";
|
|
}
|
|
|
|
/** 请求主机(反代后面才信 X-Forwarded-Host),Passkey 未配置 RP ID 时用它 */
|
|
function requestHost(c: Context<AuthEnv>): string | null {
|
|
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
|
const trusted = !remote || PRIVATE_IP.test(remote);
|
|
return (trusted ? c.req.header("x-forwarded-host") : undefined) ?? c.req.header("host") ?? new URL(c.req.url).host;
|
|
}
|
|
|
|
function isHttps(c: Context<AuthEnv>): boolean {
|
|
if (new URL(c.req.url).protocol === "https:") return true;
|
|
const remote = (c.env as { remoteAddr?: { hostname?: string } } | undefined)?.remoteAddr?.hostname;
|
|
if (remote && !PRIVATE_IP.test(remote)) return false;
|
|
return c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
|
|
}
|
|
|
|
function issueCookies(c: Context<AuthEnv>, s: { token: string; expiresAt: Date }) {
|
|
const secure = isHttps(c);
|
|
setCookie(c, SESSION_COOKIE, s.token, { httpOnly: true, sameSite: "Lax", path: "/", secure, expires: s.expiresAt });
|
|
setCookie(c, CSRF_COOKIE, deriveCsrfToken(s.token), {
|
|
httpOnly: false,
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
secure,
|
|
expires: s.expiresAt,
|
|
});
|
|
}
|
|
|
|
function clearCookies(c: Context<AuthEnv>) {
|
|
deleteCookie(c, SESSION_COOKIE, { path: "/" });
|
|
deleteCookie(c, CSRF_COOKIE, { path: "/" });
|
|
}
|
|
|
|
export function registerAuthRoutes(app: ModuleApp, ctx: ServerContext) {
|
|
const deps = () => ({ db: ctx.db, logger: ctx.logger, secrets: ctx.secrets });
|
|
const meta = (c: Context<AuthEnv>) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null });
|
|
|
|
const startSession = async (c: Context<AuthEnv>, adminId: string): Promise<CreatedSession> => {
|
|
const s = await createSession(ctx.db, adminId, meta(c));
|
|
issueCookies(c, s);
|
|
return s;
|
|
};
|
|
|
|
endpoint(app, D.setup, async ({ body, c }) => {
|
|
const admin = await setupAdmin(deps(), body, clientIp(c));
|
|
const s = await startSession(c, admin.id);
|
|
const actor = adminActor(admin);
|
|
await audit(ctx, actor, "admin.setup", { type: "admin_user", id: admin.id }, { username: admin.username });
|
|
ctx.logger.info({ username: admin.username }, "管理员已初始化");
|
|
return await sessionInfo(ctx.db, actor, s.expiresAt);
|
|
});
|
|
|
|
endpoint(app, D.login, async ({ body, c }) => {
|
|
const r = await verifyLogin(deps(), body, clientIp(c));
|
|
if (r.status === "totp_required") return { status: "totp_required" as const };
|
|
const s = await startSession(c, r.admin.id);
|
|
const actor = adminActor(r.admin);
|
|
await audit(ctx, actor, "admin.login_success", { type: "admin_session", id: s.id }, { ip: clientIp(c) });
|
|
if (r.secondFactor === "recovery_code") {
|
|
const left = (await totpStatus(ctx.db, r.admin)).recoveryCodesRemaining;
|
|
await audit(ctx, actor, "admin.recovery_code_login", { type: "admin_user", id: r.admin.id }, { remaining: left });
|
|
}
|
|
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
|
});
|
|
|
|
endpoint(app, D.session, async ({ c }) => {
|
|
const session = c.get("session");
|
|
// CSRF cookie 丢了(被清)时顺手补发
|
|
if (session) {
|
|
setCookie(c, CSRF_COOKIE, deriveCsrfToken(session.token), {
|
|
httpOnly: false,
|
|
sameSite: "Lax",
|
|
path: "/",
|
|
secure: isHttps(c),
|
|
expires: session.expiresAt,
|
|
});
|
|
}
|
|
return await sessionInfo(ctx.db, c.get("actor"), session?.expiresAt ?? null);
|
|
});
|
|
|
|
endpoint(app, D.logout, async ({ c, actor }) => {
|
|
const session = c.get("session");
|
|
if (session) {
|
|
await revokeSession(ctx.db, session.id, "logout");
|
|
await audit(ctx, actor, "admin.logout", { type: "admin_session", id: session.id });
|
|
}
|
|
clearCookies(c);
|
|
return { ok: true as const };
|
|
});
|
|
|
|
endpoint(app, D.password, async ({ body, c, actor }) => {
|
|
const session = c.get("session");
|
|
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
|
|
const revoked = await changePassword(deps(), body, {
|
|
adminUserId: session.adminUserId,
|
|
sessionId: session.id,
|
|
ip: clientIp(c),
|
|
});
|
|
await audit(ctx, actor, "admin.password_change", { type: "admin_user", id: session.adminUserId }, { revoked });
|
|
return { revokedSessions: revoked };
|
|
});
|
|
|
|
endpoint(app, D.totpGet, async () => totpStatus(ctx.db, await loadAdmin(ctx.db)));
|
|
|
|
const initializedAdmin = async () => {
|
|
const admin = await loadAdmin(ctx.db);
|
|
if (!admin?.password_hash) throw new ApiException("precondition_failed", "管理员尚未初始化");
|
|
return admin;
|
|
};
|
|
|
|
endpoint(app, D.totpSetup, async () => {
|
|
const r = await totpSetup(deps(), await initializedAdmin());
|
|
return { otpauthUri: r.uri, secret: r.secret, issuer: r.issuer, account: r.account };
|
|
});
|
|
|
|
endpoint(app, D.totpPost, async ({ body, actor }) => {
|
|
const admin = await initializedAdmin();
|
|
const status = async () => totpStatus(ctx.db, await loadAdmin(ctx.db));
|
|
if (body.action === "begin") {
|
|
const r = await totpBegin(deps(), admin);
|
|
return { status: await status(), otpauthUri: r.uri, secret: r.secret };
|
|
}
|
|
if (body.action === "enable") {
|
|
const codes = await totpEnable(deps(), admin, body.code);
|
|
await audit(ctx, actor, "admin.totp_enable", { type: "admin_user", id: admin.id });
|
|
return { status: await status(), recoveryCodes: codes };
|
|
}
|
|
if (body.action === "recovery_codes") {
|
|
const codes = await totpRegenerateRecoveryCodes(deps(), admin, body);
|
|
await audit(ctx, actor, "admin.recovery_codes_regenerate", { type: "admin_user", id: admin.id });
|
|
return { status: await status(), recoveryCodes: codes };
|
|
}
|
|
await totpDisable(deps(), admin, body);
|
|
await audit(ctx, actor, "admin.totp_disable", { type: "admin_user", id: admin.id });
|
|
return { status: await status() };
|
|
});
|
|
|
|
// ── Passkey ──
|
|
|
|
const rp = (c: Context<AuthEnv>): RpInfo => resolveRp(ctx.config ?? {}, requestHost(c));
|
|
|
|
endpoint(app, D.passkeys, async ({ c }) => {
|
|
const session = c.get("session");
|
|
if (!session) throw new ApiException("forbidden", "只能在控制台登录后操作");
|
|
return { items: (await listPasskeys(ctx.db, session.adminUserId)).map(toPasskeyInfo) };
|
|
});
|
|
|
|
endpoint(app, D.passkeyRegisterOptions, async ({ c }) =>
|
|
registrationOptions(ctx.db, await initializedAdmin(), rp(c)),
|
|
);
|
|
|
|
endpoint(
|
|
app,
|
|
D.passkeyRegister,
|
|
async ({ body, c, actor }) => {
|
|
const admin = await initializedAdmin();
|
|
const row = await verifyRegistration(ctx.db, admin, rp(c), body);
|
|
await audit(ctx, actor, "admin.passkey_register", { type: "admin_passkey", id: row.id }, { name: row.name });
|
|
return toPasskeyInfo(row);
|
|
},
|
|
{ status: 201 },
|
|
);
|
|
|
|
endpoint(app, D.passkeyRename, async ({ params, body, actor }) => {
|
|
const admin = await initializedAdmin();
|
|
const row = await renamePasskey(ctx.db, admin.id, params.id, body.name);
|
|
await audit(ctx, actor, "admin.passkey_rename", { type: "admin_passkey", id: row.id }, { name: row.name });
|
|
return toPasskeyInfo(row);
|
|
});
|
|
|
|
endpoint(app, D.passkeyDelete, async ({ params, actor }) => {
|
|
const admin = await initializedAdmin();
|
|
const row = await deletePasskey(ctx.db, admin.id, params.id);
|
|
await audit(ctx, actor, "admin.passkey_remove", { type: "admin_passkey", id: row.id }, { name: row.name });
|
|
return { ok: true as const };
|
|
});
|
|
|
|
endpoint(app, D.passkeyLoginOptions, async ({ c }) => {
|
|
await assertNotRateLimited(ctx.db, clientIp(c), PASSKEY_ACCOUNT);
|
|
return authenticationOptions(ctx.db, await initializedAdmin(), rp(c));
|
|
});
|
|
|
|
// 与密码登录共用限速(IP + "(passkey)" 账号维度)与会话;Passkey 本身是强第一因子,不再要求 TOTP
|
|
endpoint(app, D.passkeyLogin, async ({ body, c }) => {
|
|
const ip = clientIp(c);
|
|
await assertNotRateLimited(ctx.db, ip, PASSKEY_ACCOUNT);
|
|
const admin = await initializedAdmin();
|
|
let key: Awaited<ReturnType<typeof verifyAuthentication>>;
|
|
try {
|
|
key = await verifyAuthentication(ctx.db, admin, rp(c), body);
|
|
} catch (err) {
|
|
if (err instanceof ApiException && (err.code === "unauthorized" || err.code === "bad_request"))
|
|
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: false, reason: "bad_passkey" });
|
|
throw err;
|
|
}
|
|
await recordAttempt(ctx.db, { ip, username: PASSKEY_ACCOUNT, success: true });
|
|
const s = await startSession(c, admin.id);
|
|
const actor = adminActor(admin);
|
|
await audit(ctx, actor, "admin.passkey_login", { type: "admin_session", id: s.id }, { ip, passkeyId: key.id });
|
|
return { status: "ok" as const, session: await sessionInfo(ctx.db, actor, s.expiresAt) };
|
|
});
|
|
}
|