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
+35
-6
@@ -14,6 +14,8 @@
|
||||
```sql
|
||||
update admin_user set username = null, password_hash = null, totp_secret_ref = null, totp_enabled_at = null;
|
||||
update admin_session set revoked_at = now(), revoke_reason = 'reset' where revoked_at is null;
|
||||
delete from admin_recovery_code;
|
||||
delete from admin_passkey;
|
||||
```
|
||||
|
||||
然后重启 api 进程,按日志里新的 setup token 重新初始化。
|
||||
@@ -24,34 +26,61 @@ update admin_session set revoked_at = now(), revoke_reason = 'reset' where revok
|
||||
|---|---|---|
|
||||
| `GET /auth/session` | 免 | `{initialized, authenticated, actor, expiresAt, totpEnabled}`;有会话时顺带补发 CSRF cookie |
|
||||
| `POST /auth/setup` | 免 | 见上 |
|
||||
| `POST /auth/login` | 免 | `{username, password, totpCode?}` → `{status:"ok", session}` 或 `{status:"totp_required"}`(密码对但缺验证码,不计失败) |
|
||||
| `POST /auth/login` | 免 | `{username, password, totpCode? \| recoveryCode?}` → `{status:"ok", session}` 或 `{status:"totp_required"}`(密码对但缺验证码,不计失败) |
|
||||
| `POST /auth/passkeys/login/options` | 免 | Passkey 登录挑战 `{challengeId, options}`(没有登记任何 Passkey 时 412) |
|
||||
| `POST /auth/passkeys/login/verify` | 免 | `{challengeId, response}` → `{status:"ok", session}`(下发与密码登录相同的会话 cookie) |
|
||||
| `POST /auth/logout` | 登录 | 软删除吊销当前会话并清 cookie |
|
||||
| `POST /auth/password` | 仅 cookie 会话 | 校验旧密码 → 改密 → **吊销其它所有会话**(保留当前) |
|
||||
| `GET/POST /auth/totp` | 仅 cookie 会话 | `{action:"begin"}` 返回 otpauth URI;`{action:"enable", code}`;`{action:"disable", password, code}` |
|
||||
| `GET /auth/totp` | 仅 cookie 会话 | `{enabled, pending, recoveryCodesRemaining}` |
|
||||
| `GET /auth/totp/setup` | 仅 cookie 会话 | 待确认密钥 `{otpauthUri, secret, issuer, account}`,没有就生成、已有原样返回(刷新不作废已扫的码);已开启 409。控制台用 `qr-code-styling` 把 otpauthUri 渲染成二维码 |
|
||||
| `POST /auth/totp` | 仅 cookie 会话 | `{action:"begin"}` 重新生成密钥;`{action:"enable", code}` 返回 8 个恢复码;`{action:"disable", password, code}`(code 可以是验证码或恢复码);`{action:"recovery_codes", password}` 重新生成恢复码 |
|
||||
| `GET /auth/passkeys`、`POST /auth/passkeys/register/options`、`POST /auth/passkeys/register/verify {challengeId, name, response}`、`PATCH /auth/passkeys/{id} {name}`、`DELETE /auth/passkeys/{id}` | 仅 cookie 会话 | Passkey 列表 / 注册 / 重命名 / 删除 |
|
||||
|
||||
- 会话:32 字节随机 token 进 `umt_session` cookie(HttpOnly、SameSite=Lax、Path=/,HTTPS 时 Secure),库里只存 sha256;固定 12 小时,不续期;`last_seen_at` 60 秒节流更新。
|
||||
- CSRF:`umt_csrf` cookie(非 HttpOnly)= `HMAC-SHA256(key=会话 token, "umt/csrf/v1")`;cookie 会话的 POST/PUT/PATCH/DELETE 必须带 `x-csrf-token` 头等于该值,否则 403。Bearer 调用不校验 CSRF。
|
||||
- 限速(落 `login_attempt` 表,多进程一致):15 分钟窗口内同 IP 失败 ≥5 次、或同账号失败 ≥15 次 → 429(账号维度取 3 倍,和 uhd_doc 一致,否则单个 IP 就能把管理员锁死)。窗口从最近一次成功之后算起;被拦下的请求不落表、不写审计。setup 失败也计入 IP 维度。
|
||||
- 客户端 IP:直连对端是回环/私网时才信 `X-Real-IP` / `X-Forwarded-For` 最后一跳,否则用对端地址;部署在公网反代后面时反代必须覆盖这两个头。
|
||||
- 密码:argon2id(m=19456 KiB, t=2, p=1),用 `hash-wasm`(WASM)。没选 `@node-rs/argon2` / `@denosaurs/argon2`:都是原生库,需要 `--allow-ffi`,而编译后的二进制只授予 net/env/sys/read。输出标准 PHC 串,与 uhd_doc 的 Bun.password 互认。
|
||||
- TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),`totp_last_counter` 防重放。Passkey 未做。
|
||||
- TOTP:RFC 6238(SHA1/6 位/30 秒),密钥存 secret(purpose=other),`totp_last_counter` 防重放。
|
||||
- 恢复码:开启 TOTP 时一次性生成 8 个(`XXXXX-XXXXX`,不含易混的 I/O/0/1),明文只在开启 / 重新生成的响应里出现一次,库里 `admin_recovery_code` 存 argon2id 哈希;登录第二步用 `recoveryCode` 代替 `totpCode`,用过即作废(条件更新,并发只成功一次),审计 `admin.recovery_code_login`。输错计入失败限速(reason=`bad_recovery_code`)。停用 TOTP 时恢复码一并删除。
|
||||
- Passkey(`@simplewebauthn/server`,做法照 uhd_doc):替代密码的第一因子,登录成功直接建会话(不再要求 TOTP)。可以登记多把,各自命名;`admin_passkey` 存 COSE 公钥与签名计数器(计数器回退即拒绝,防克隆)。挑战存 `webauthn_challenge`,一次性、5 分钟有效。失败计入 IP 维度限速(账号维度记为 `(passkey)`),与密码登录共用 15 分钟窗口。
|
||||
- RP ID:环境变量 `WEBAUTHN_RP_ID`(浏览器地址栏里的主机名,不含协议和端口);不设时取请求 Host(反代后面取 `X-Forwarded-Host`)。**换域名后已登记的 Passkey 全部失效**,需要用密码登录后重新登记。
|
||||
- 来源:`WEBAUTHN_ORIGINS`(逗号分隔,例如 `https://media.example.com`);不设时接受主机名等于 RP ID 或其子域的来源。站点名 `WEBAUTHN_RP_NAME`(缺省 UHD Media Tool)。
|
||||
- 浏览器只在安全上下文(HTTPS 或 localhost)里提供 Passkey;用 IP 访问时登录页不显示入口。
|
||||
- 清理:worker 每小时删 7 天前的 `login_attempt`、过期/吊销超过 30 天的 `admin_session`。
|
||||
|
||||
## 鉴权中间件(其他模块怎么用)
|
||||
|
||||
`/api/v1/*`(health 与上面三个免鉴权端点除外)统一经过 `apiKeyAuth`:
|
||||
`/api/v1/*`(health 与上面五个免鉴权端点除外)统一经过 `apiKeyAuth`:
|
||||
|
||||
- `Authorization: Bearer <key>` → API Key(开发模式另认 `DEV_ADMIN_KEY`,生产忽略);
|
||||
- 否则 `umt_session` cookie → 管理员会话(scopes 视为 `["admin"]`)。
|
||||
|
||||
处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 `auth: "session"`(路由封装据此自动挂 `requireAdminSession()`,API Key 包括 admin scope 与 `DEV_ADMIN_KEY` 一律 403),免鉴权端点标 `auth: "public"` 且必须与 `PUBLIC_AUTH_PATHS` 一致(`api-registry.test` 断言)。目前标 session 的有:`POST /auth/password`、`GET/POST /auth/totp`、`POST /approvals`、`PATCH /approvals/{id}`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。
|
||||
处理器里:`c.get("actor")` → `{ type: "admin" | "api_key", id, name, scopes }`(contracts `Actor`);`c.get("session")` 仅 cookie 会话有值。路由照旧挂 `requireScope("read" | "organize.read" | "organize.execute" | "admin")`,两种调用者一视同仁;只允许控制台会话的操作在 contracts 注册表里标 `auth: "session"`(路由封装据此自动挂 `requireAdminSession()`,API Key 包括 admin scope 与 `DEV_ADMIN_KEY` 一律 403),免鉴权端点标 `auth: "public"` 且必须与 `PUBLIC_AUTH_PATHS` 一致(`api-registry.test` 断言)。目前标 session 的有:`POST /auth/password`、`GET/POST /auth/totp`、`GET /auth/totp/setup`、`/auth/passkeys*`(登录两步除外)、`POST /approvals`、`PATCH /approvals/{id}`。旧的 `c.get("principal")` 仍可用(`kind` 多了 `admin_session`)。写审计时 actor_type:admin → `user`,API Key → `api_key`。
|
||||
|
||||
## 密钥服务 `ctx.secrets`
|
||||
|
||||
- `create({purpose, plaintext, label?}) → secretRef`、`resolve(ref) → 明文`、`replace(ref, plaintext)`(轮换)、`remove(ref)`。
|
||||
- AES-256-GCM;每条 secret 随机 16 字节盐经 HKDF-SHA256 从 `SECRET_MASTER_KEY` 派生独立密钥(info=`umt/secret/v1`),随机 12 字节 nonce,AAD 绑定 `(id, purpose)`(密文挪行/改用途即解密失败)。
|
||||
- 其它表只存 `secret_ref`;需要明文时现场 `resolve`,不缓存、不写日志、不进模型上下文。失败抛 `SecretError`(消息只含 ref)。
|
||||
- `SECRET_MASTER_KEY` 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。主密钥轮换(重新加密全表)尚未实现,`key_version` 已预留。
|
||||
- `SECRET_MASTER_KEY` 与数据库备份分开保管;丢失即所有凭证不可解密(需要重新录入)。`kdf_params.kid` 记着加密用的主密钥指纹(HMAC 派生,不能反推密钥)。
|
||||
|
||||
### 主密钥轮换
|
||||
|
||||
一次性命令 `--role rotate-master-key`:用 `SECRET_MASTER_KEY` 解密、`SECRET_MASTER_KEY_NEW` 重新加密 secret 表。逐行一个事务(`select … for update` → 解密 → 新盐新 nonce 加密 → 写回);已经能被新密钥解开的行跳过,所以**中断后原样重跑即可**。每 50 行一条进度日志,结束写审计 `secret.master_key_rotate`(只有计数与新密钥指纹)。新旧密钥都解不开的行保持原样,在日志里列出 id,命令退出码 1。
|
||||
|
||||
1. 备份数据库(`docs/runbook/backup-restore.md`),旧密钥保留在原处。
|
||||
2. 生成新密钥:`openssl rand -hex 32`,存进密码管理器。
|
||||
3. 停掉 api 与 worker(轮换期间还在跑的进程只认旧密钥,读到已换过的行会解密失败):`docker compose stop app`。
|
||||
4. 执行轮换,两个密钥同时给:
|
||||
```sh
|
||||
docker compose run --rm --no-deps -e SECRET_MASTER_KEY_NEW=<新密钥> app rotate-master-key
|
||||
```
|
||||
日志最后一条是"主密钥轮换完成"(退出码 0)才算完成;中断或报错就原样再执行一次。
|
||||
5. 把 `.env` 里的 `SECRET_MASTER_KEY` 换成新密钥,删掉 `SECRET_MASTER_KEY_NEW`,启动:`docker compose up -d app`。到设置页各实例点"测试连接"确认凭证可用。
|
||||
6. 旧密钥在确认无误、且新密钥之后做过一次备份以前**不要销毁**:旧备份只能用旧密钥解。
|
||||
|
||||
回滚(第 5 步之前任何时候,或启动后发现凭证不可用):停 app,新旧对调再跑一遍——`docker compose run --rm --no-deps -e SECRET_MASTER_KEY=<新密钥> -e SECRET_MASTER_KEY_NEW=<旧密钥> app rotate-master-key`(开发环境:`deno run -A --env-file=.env apps/server/src/main.ts --role rotate-master-key`),完成后 `.env` 保持旧密钥启动。也可以直接用第 1 步的备份恢复。
|
||||
- 管理面:`GET/POST /secrets`、`PATCH /secrets/{id}`(轮换)、`DELETE /secrets/{id}`(仍被引用 → 409)。只返回元数据。
|
||||
|
||||
## 设置 `ctx.settings`
|
||||
|
||||
Reference in new issue
Block a user