feat(auth): TOTP setup endpoint, recovery codes, passkeys and master key rotation
- 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]>
This commit is contained in:
1 parent
5c6f483420
commit
b0e5d539ed
21 files changed
+1616
-80
No files matched your search
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
type AuthenticatorTransportFuture,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
type RegistrationResponseJSON,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { PasskeyChallenge, PasskeyInfo } from "@umt/contracts";
|
||||
import { type AdminPasskeyRow, type DB, type Kysely, sql } from "@umt/db";
|
||||
import { ApiException } from "../../http/errors.ts";
|
||||
import type { AdminRow } from "./auth-service.ts";
|
||||
|
||||
// Passkey(WebAuthn,照 uhd_doc src/server/admin-auth/passkey.ts):替代密码的第一因子,
|
||||
// 登录成功直接建会话(不再要求 TOTP)。@simplewebauthn/server 做两个 ceremony;
|
||||
// 挑战存 webauthn_challenge 表(一次性、5 分钟有效、条件更新消费),可以登记多把并命名。
|
||||
// RP ID 取配置 WEBAUTHN_RP_ID,不设时取请求主机名;来源取配置 WEBAUTHN_ORIGINS,不设时接受主机名属于 RP ID 的来源。
|
||||
|
||||
export const CHALLENGE_TTL_MS = 5 * 60_000;
|
||||
|
||||
export interface RpInfo {
|
||||
rpID: string;
|
||||
rpName: string;
|
||||
/** 配置的允许来源;null = 按 RP ID 校验客户端上报的来源 */
|
||||
origins: string[] | null;
|
||||
}
|
||||
|
||||
/** 由配置 + 请求主机名得到 RP 信息(纯函数,便于测试) */
|
||||
export function resolveRp(
|
||||
cfg: { WEBAUTHN_RP_ID?: string | undefined; WEBAUTHN_ORIGINS?: string | undefined; WEBAUTHN_RP_NAME?: string },
|
||||
requestHost: string | null | undefined,
|
||||
): RpInfo {
|
||||
const host = (requestHost ?? "").split(",")[0]?.trim() ?? "";
|
||||
const hostname = host
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.toLowerCase();
|
||||
const rpID = cfg.WEBAUTHN_RP_ID?.trim().toLowerCase() || hostname || "localhost";
|
||||
const origins = cfg.WEBAUTHN_ORIGINS
|
||||
? cfg.WEBAUTHN_ORIGINS.split(",")
|
||||
.map((s) => s.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
return { rpID, rpName: cfg.WEBAUTHN_RP_NAME ?? "UHD Media Tool", origins: origins?.length ? origins : null };
|
||||
}
|
||||
|
||||
/** 客户端上报的来源(clientDataJSON.origin);没配置允许来源时,主机名必须等于 RP ID 或是它的子域 */
|
||||
function expectedOrigins(rp: RpInfo, response: { response?: { clientDataJSON?: unknown } }): string[] {
|
||||
if (rp.origins) return rp.origins;
|
||||
let origin = "";
|
||||
try {
|
||||
const raw = String(response.response?.clientDataJSON ?? "");
|
||||
origin = String((JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as { origin?: unknown }).origin ?? "");
|
||||
} catch {
|
||||
throw new ApiException("bad_request", "Passkey 响应格式不正确");
|
||||
}
|
||||
let hostname = "";
|
||||
try {
|
||||
hostname = new URL(origin).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
} catch {
|
||||
throw new ApiException("unauthorized", "Passkey 来源无效");
|
||||
}
|
||||
if (hostname !== rp.rpID && !hostname.endsWith(`.${rp.rpID}`))
|
||||
throw new ApiException("unauthorized", `Passkey 来源 ${origin} 与站点 ${rp.rpID} 不符`);
|
||||
return [origin];
|
||||
}
|
||||
|
||||
// ── 挑战 ──
|
||||
|
||||
async function saveChallenge(
|
||||
db: Kysely<DB>,
|
||||
purpose: "register" | "login",
|
||||
challenge: string,
|
||||
adminUserId: string | null,
|
||||
): Promise<string> {
|
||||
// 顺手清理过期挑战
|
||||
await db
|
||||
.deleteFrom("webauthn_challenge")
|
||||
.where("expires_at", "<", new Date(Date.now() - CHALLENGE_TTL_MS))
|
||||
.execute();
|
||||
const r = await db
|
||||
.insertInto("webauthn_challenge")
|
||||
.values({ purpose, challenge, admin_user_id: adminUserId, expires_at: new Date(Date.now() + CHALLENGE_TTL_MS) })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return r.id;
|
||||
}
|
||||
|
||||
/** 取出并作废挑战(一次性);过期 / 用过 / 用途不符返回 null */
|
||||
async function takeChallenge(
|
||||
db: Kysely<DB>,
|
||||
id: string,
|
||||
purpose: "register" | "login",
|
||||
adminUserId: string | null,
|
||||
): Promise<string | null> {
|
||||
let q = db
|
||||
.updateTable("webauthn_challenge")
|
||||
.set({ used_at: new Date() })
|
||||
.where("id", "=", id)
|
||||
.where("purpose", "=", purpose)
|
||||
.where("used_at", "is", null)
|
||||
.where("expires_at", ">", new Date());
|
||||
if (adminUserId) q = q.where("admin_user_id", "=", adminUserId);
|
||||
const r = await q.returning("challenge").executeTakeFirst();
|
||||
return r?.challenge ?? null;
|
||||
}
|
||||
|
||||
// ── 登记的 Passkey ──
|
||||
|
||||
const transportsOf = (v: unknown): AuthenticatorTransportFuture[] =>
|
||||
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : [];
|
||||
|
||||
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
deviceType: r.device_type,
|
||||
backedUp: r.backed_up,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
lastUsedAt: r.last_used_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPasskeys(db: Kysely<DB>, adminUserId: string): Promise<AdminPasskeyRow[]> {
|
||||
return await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function renamePasskey(db: Kysely<DB>, adminUserId: string, id: string, name: string) {
|
||||
const r = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({ name: name.trim() })
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function deletePasskey(db: Kysely<DB>, adminUserId: string, id: string) {
|
||||
const r = await db
|
||||
.deleteFrom("admin_passkey")
|
||||
.where("id", "=", id)
|
||||
.where("admin_user_id", "=", adminUserId)
|
||||
.returning(["id", "name"])
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new ApiException("not_found", "Passkey 不存在");
|
||||
return r;
|
||||
}
|
||||
|
||||
// ── 注册(需要控制台会话) ──
|
||||
|
||||
export async function registrationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const existing = await listPasskeys(db, admin.id);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: admin.username ?? "admin",
|
||||
userID: new TextEncoder().encode(admin.id),
|
||||
attestationType: "none",
|
||||
excludeCredentials: existing.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "register", options.challenge, admin.id);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
export async function verifyRegistration(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; name: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as RegistrationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "register", admin.id);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "注册挑战已过期或已用过,重新开始");
|
||||
let v: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
|
||||
try {
|
||||
v = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = v.registrationInfo;
|
||||
try {
|
||||
return await db
|
||||
.insertInto("admin_passkey")
|
||||
.values({
|
||||
admin_user_id: admin.id,
|
||||
credential_id: credential.id,
|
||||
public_key: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: JSON.stringify(credential.transports ?? response.response?.transports ?? []),
|
||||
device_type: credentialDeviceType,
|
||||
backed_up: credentialBackedUp,
|
||||
name: input.name.trim(),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === "23505") throw new ApiException("conflict", "这把 Passkey 已经登记过");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 登录(免鉴权) ──
|
||||
|
||||
export async function authenticationOptions(db: Kysely<DB>, admin: AdminRow, rp: RpInfo): Promise<PasskeyChallenge> {
|
||||
const creds = await listPasskeys(db, admin.id);
|
||||
if (!creds.length) throw new ApiException("precondition_failed", "还没有登记 Passkey,用密码登录");
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: "preferred",
|
||||
allowCredentials: creds.map((c) => ({ id: c.credential_id, transports: transportsOf(c.transports) })),
|
||||
});
|
||||
const challengeId = await saveChallenge(db, "login", options.challenge, null);
|
||||
return { challengeId, options: options as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** 校验断言;成功后更新签名计数器与最近使用时间。失败抛 unauthorized / precondition_failed */
|
||||
export async function verifyAuthentication(
|
||||
db: Kysely<DB>,
|
||||
admin: AdminRow,
|
||||
rp: RpInfo,
|
||||
input: { challengeId: string; response: Record<string, unknown> },
|
||||
): Promise<AdminPasskeyRow> {
|
||||
const response = input.response as unknown as AuthenticationResponseJSON;
|
||||
const origins = expectedOrigins(rp, response);
|
||||
const challenge = await takeChallenge(db, input.challengeId, "login", null);
|
||||
if (!challenge) throw new ApiException("precondition_failed", "登录挑战已过期或已用过,重试");
|
||||
const cred = await db
|
||||
.selectFrom("admin_passkey")
|
||||
.selectAll()
|
||||
.where("admin_user_id", "=", admin.id)
|
||||
.where("credential_id", "=", String(response.id ?? ""))
|
||||
.executeTakeFirst();
|
||||
if (!cred) throw new ApiException("unauthorized", "这把 Passkey 没有登记");
|
||||
let v: Awaited<ReturnType<typeof verifyAuthenticationResponse>>;
|
||||
try {
|
||||
v = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origins,
|
||||
expectedRPID: rp.rpID,
|
||||
credential: {
|
||||
id: cred.credential_id,
|
||||
publicKey: new Uint8Array(cred.public_key),
|
||||
counter: Number(cred.counter),
|
||||
transports: transportsOf(cred.transports),
|
||||
},
|
||||
requireUserVerification: false,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiException("unauthorized", `Passkey 校验失败:${(err as Error).message}`);
|
||||
}
|
||||
if (!v.verified) throw new ApiException("unauthorized", "Passkey 校验失败");
|
||||
// 计数器只增不减(条件更新:并发重放同一个断言只有一个生效;计数器恒为 0 的认证器照常放行)
|
||||
const next = v.authenticationInfo.newCounter;
|
||||
const u = await db
|
||||
.updateTable("admin_passkey")
|
||||
.set({
|
||||
counter: next,
|
||||
last_used_at: new Date(),
|
||||
device_type: v.authenticationInfo.credentialDeviceType,
|
||||
backed_up: v.authenticationInfo.credentialBackedUp,
|
||||
})
|
||||
.where("id", "=", cred.id)
|
||||
.where(sql<boolean>`(counter < ${next} or (counter = 0 and ${next} = 0))`)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
if (!u) throw new ApiException("unauthorized", "Passkey 签名计数器回退(可能被克隆),已拒绝");
|
||||
return u;
|
||||
}
|
||||
Reference in new issue
Block a user