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; /** 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): 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): 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): 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, 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) { 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) => ({ ip: clientIp(c), userAgent: c.req.header("user-agent") ?? null }); const startSession = async (c: Context, adminId: string): Promise => { 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): 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>; 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) }; }); }