Files
UHD_Media_Tool/docs/design/auth-reference.md
T

251 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 管理员登录门禁 与 单容器打包 —— uhd_doc 调研
来源:`/opt/uhd_doc`(Next.js 15 + Bun + SQLite/Drizzle + Docker,单容器自托管文档 CMS)。只读调研,目的是给 `/opt/uhd_media_tool`(Node 24 + Hono + Kysely/pg 后端,React Router 7 SPA 前端)抄作业。全部路径均为 `/opt/uhd_doc/` 下相对路径。
## 1. 登录 / 会话
### 1.1 密码哈希:Argon2id(`src/lib/password.ts`)
用 `Bun.password`(内置,无原生依赖)生成/校验标准 PHC 字符串,OWASP 参数:
```ts
const ARGON_OPTS = { algorithm: "argon2id", memoryCost: 19_456, timeCost: 2 };
export function hashPassword(password: string) { return bunPassword.hash(password, ARGON_OPTS); }
export async function verifyPassword(password: string, digest: string) {
try { return await bunPassword.verify(password, digest); } catch { return false; }
}
```
uhd_media_tool 是纯 Node 24,没有 `Bun.password`。**建议照搬参数、不照搬实现**:改用 `node:crypto` 无内置 argon2,需引入 `@node-rs/argon2`(原生插件,Node 24 有预编译二进制)或退回 `bcrypt`/`node:crypto scrypt`。曾经该项目就是用 `@node-rs/argon2` 后来才切到 Bun 内置版(见 password.ts 注释),说明 `@node-rs/argon2` 产出的哈希格式与 Bun 版兼容,是稳妥选择。
### 1.2 首次初始化管理员:一次性 Setup Token,不是 env 密码
- `ADMIN_USERNAME` 是 env 配置(稳定),但**密码从不经 env**,只能通过浏览器首次访问 `/admin/login` 时的「安装向导」设置,写入数据库。
- 首启防抢注:`src/lib/env.ts` 的 `resolveInitToken()` 在没显式配置 `ADMIN_INIT_TOKEN` 时自动生成 16 字节随机 token,持久化到 `data/config/runtime-secrets.json`(权限 `0600`),**从不打印到应用日志**。
- 运维通过容器命令行取 token:`docker exec -it uhd-docs bun run admin:show-init-token`(`src/scripts/admin-show-init-token.ts`,已初始化则拒绝显示)。
- 前端 setup 表单(用户名+密码+确认密码+token)提交到 `POST /api/admin-auth/setup`(`src/app/api/admin-auth/setup/route.ts`):校验 `ADMIN_USERNAME` 匹配、token 常量时间比较(`timingSafeEqual`)、`initializeAdminPassword()` 用**条件 UPSERT**(`WHERE admin_credential.password_hash IS NULL`)保证并发首启只有一个请求能设成功;成功后 `rotateAdminInitToken()` 让旧 token 失效。
- 忘记密码的恢复路径是容器内命令行脚本 `src/scripts/admin-set-password.ts`(交互式或参数传入新密码,同时禁用 TOTP 并吊销其它会话),不走 Web UI——这是刻意设计,恢复权限只给到有容器 shell 的人。
**建议照搬**:一次性 setup token + 密码只落库不落 env + CLI 兜底重置,这套模型完全适用于 Hono 后端(用 `pnpm --filter @umt/server exec tsx scripts/admin-set-password.ts` 之类等价脚本)。
### 1.3 会话实现:服务端 session 表 + HttpOnly Cookie(不是 JWT)
`src/server/admin-auth/session.ts` + `src/db/schema.ts` 的 `admin_sessions` 表:
```ts
export const adminSessions = sqliteTable("admin_sessions", {
id: id(),
sessionIdHash: text("session_id_hash").notNull(), // 只存哈希,原始 id 只在 cookie 里
adminUsername: text("admin_username").notNull(),
createdAt: createdAt(),
lastSeenAt: integer(...).notNull(),
expiresAt: integer(...).notNull(),
revokedAt: integer(...), // 软删除吊销
}, (t) => [uniqueIndex("admin_sessions_hash_uq").on(t.sessionIdHash)]);
```
- `createAdminSession()`:生成 32 字节随机 id,库里只存 `sha256Hex(sessionId)`(泄库拿不到可用 cookie 值,类似 bcrypt 但用 sha256 因为 id 本身已是高熵随机数,不需要慢哈希),cookie 明文是原始 id。
- Cookie 属性:`httpOnly: true, secure: await isHttpsRequest(), sameSite: "lax", path: "/", expires: <会话过期时间>`。Cookie 名 `admin_session`(`src/lib/cookies.ts` 的 `COOKIES.adminSession`)。
- `secure` 判定不简单看 `req.protocol`:`isHttpsRequest()` 优先信任 **`APP_URL` 是否 `https://` 开头**(运维必须配对),兜底看 `X-Forwarded-Proto`/`CF-Visitor`/`X-Forwarded-Ssl`,这是因为反代链路可能把协议头丢掉或改写,`APP_URL` 是唯一权威来源。
- 过期与续期:固定 TTL(`ADMIN_SESSION_TTL_HOURS`,默认 12 小时),**没有滑动续期**,只在 `getAdminSession()` 校验时机会性节流更新 `lastSeenAt`(超过 60 秒才写库,减少写压力)。没有"记住我"选项。
- 登出/吊销:`destroyAdminSession()` 把该行 `revokedAt` 置为当前时间(数据库软删除)+ 清 cookie(session、csrf 两个)。`revokeOtherSessions(username, keepRawId?)` 用于改密后强制下线其它所有会话(留当前会话)。
- 校验逻辑:`getAdminSession()` 查询条件是 `sessionIdHash 匹配 AND revokedAt IS NULL AND expiresAt > now`,三者缺一都视为未登录,不抛异常。
**建议照搬**:服务端 session 表(而非无状态 JWT)对这类"单管理员、低 QPS"场景明显更合适——吊销、剔线、审计都是一条 SQL,JWT 做不到即时吊销。Kysely/pg 版对应建一张 `admin_session` 表,字段照抄即可(pg 用 `timestamptz`)。
### 1.4 鉴权中间件:Next Route Handler wrapper,对应 Hono 的中间件写法
`src/server/api/wrappers.ts` 里 `withAdminApi()` 是核心:
```ts
export function withAdminApi<P>(handler) {
return async (req, ctx) => {
const session = await getAdminSession();
if (!session) throw new ApiError(ErrorCode.ADMIN_REQUIRED, "需要管理员登录");
if (WRITE_METHODS.has(req.method) && !(await verifyCsrf(req))) {
throw new ApiError(ErrorCode.CSRF_INVALID, "CSRF 校验失败,请刷新后重试");
}
return handler(req, ctx, session);
};
}
```
每个 admin API route 用 `export const POST = withAdminApi(async (req, ctx, session) => {...})` 包一层,而不是全局 middleware——Next App Router 没有"路由组中间件"的干净写法,只能靠这种高阶函数在每个 handler 外面套。
**uhd_media_tool 的对应做法(Hono)**:这一层在 Hono 里就是标准 middleware,比 Next 的 wrapper 模式更直接。参照现有 `apps/server/src/http/auth.ts` 的 `apiKeyAuth()` / `requireScope()` 写法,新增一个 `adminSessionAuth(): MiddlewareHandler` 挂在 `app.use("/api/v1/admin/*", adminSessionAuth)`,内部读 `admin_session` cookie → 查表 → 校验过期/吊销 → `c.set("adminSession", ...)`;写方法上再叠一层 `requireCsrf` middleware。不需要 Next 那种"每个 route 手动包一层"的 workaround,直接用 Hono 路由分组即可,这是 Hono 优于 Next Route Handler 的地方,**不需要照搬 wrapper 形式,只照搬校验逻辑**。
### 1.5 网络边界快速拦截:Next middleware → Hono 等价物
`src/proxy.ts`(这个项目把 Next 的 `middleware.ts` 改名/复用为 `proxy.ts`,`config.matcher` 命中 `/admin/:path*` 等路径):只做**轻量**判断——cookie 是否存在(不查库,不校验有效性),命中 `/admin/*` 且无 cookie → 302 到 `/admin/login?next=...`;已登录还访问登录页 → 302 回 `/admin`。真正的会话有效性校验留给上面第 1.4 节的 API wrapper 和 admin 布局组件。
值得抄的两个细节:
- **相对路径重定向**而不是 `NextResponse.redirect(绝对 URL)`:反代场景下 Next 内部 origin(如 `http://localhost:3001`)和外部真实 host 不一致,直接构造绝对 URL 会把浏览器导向不可达地址。它对 `/admin` 命中场景用 `originRedirect()` 从 `X-Forwarded-Host`/`Host` 头重建 URL,而不是相对路径——但注释解释了这里选相对还是绝对是有权衡的(浏览器对相对 Location 会按当前请求地址解析,更安全)。
- `next` 参数做了严格校验(`/^\/(?!\/)/` 且不含 `\\`),防 Open Redirect。
**对应做法**:React Router 7 SPA 是纯前端路由,没有 Next middleware 这种服务端网络层拦截点。等价方案是在 SPA 的根 loader / 一个 `RequireAdmin` 路由包裹组件里,挂载时先带 cookie 发一个轻量 `GET /api/v1/admin/session`(或者复用 whoami)判断是否登录,未登录 `navigate("/admin/login?next=...")`。这一层拦截**不能省**,否则未登录用户会先看到页面骨架再跳转,体验和安全性都打折——但载体从"边缘 middleware"变成"SPA 路由 loader + 后端一个薄 session 探测接口"。
### 1.6 CSRF:HMAC 派生的 Double-Submit Cookie
`src/server/admin-auth/csrf.ts`:
```ts
export async function setCsrfCookie(sessionId: string) {
store.set(COOKIES.csrf, deriveCsrfToken(sessionId), {
httpOnly: false, secure: await isHttpsRequest(), sameSite: "lax", path: "/",
});
}
export async function verifyCsrf(req: Request) {
const session = await getAdminSession();
if (!session) return false;
const provided = req.headers.get("x-csrf-token");
return provided ? safeEqual(provided, deriveCsrfToken(session.sessionId)) : false;
}
```
`deriveCsrfToken(sessionId) = HMAC-SHA256(SESSION_SECRET, "csrf:" + sessionId)`(`src/lib/crypto.ts`)。CSRF cookie **非 HttpOnly**(前端 JS 要读出来塞进请求头),伪造需要拿到 HttpOnly 的 session id,而 XSS/CSRF 单独都拿不到两者组合。只在写方法(POST/PUT/PATCH/DELETE)校验,登录成功时和 session 一起签发。
**建议照搬**:这套 HMAC 派生 double-submit 比"随机 token 存表"更轻(不用额外建表/查库),`SESSION_SECRET` 换成 pg 项目自己的签名密钥即可,逻辑原样搬。
### 1.7 登录限速 / 失败锁定
`src/server/admin-auth/login.ts`,双重限速:
- **按 IP**:5 分钟窗口内失败 ≥5 次 → 锁(`MAX_PER_IP = 5`)。
- **按账号**(IP 无关兜底):同窗口失败 ≥15 次 → 锁(`MAX_PER_ACCOUNT = 15`),防止攻击者靠伪造/轮换 `X-Real-IP` 绕过 IP 限速。
- 查询用 `adminLoginAttempts` 表按 `success=false AND createdAt >= now-5min` 计数,不是内存令牌桶——多进程/重启都一致。
- 已经被限速的请求**不再写审计**,防止扫描器把 `audit_logs` 撑爆(注释原话强调这是刻意的磁盘防护)。
- 密码正确但 TOTP 未提供,**不计入失败次数**(不烧限速预算),因为这只是流程的第二步。
此外还有一层**人机验证**(见 §1.8)在触发密码校验前拦截。
**建议照搬**:IP + 账号 双维度限速、失败计数落表而非内存,这是防"分布式撞库/凭据填充"最实用的一层,pg 版直接建等价 `admin_login_attempt` 表。
### 1.8 人机验证(PoW,非传统验证码)
登录/初始化/公开密码解锁/反馈投票等敏感端点都要求先过 **Cap**(自托管工作量证明验证码,`capjs-core`,不依赖第三方 CAPTCHA 服务)。前端组件 `src/components/security/proof-of-work.tsx`(`<ProofOfWork endpoint="/api/cap/admin-login/" onTokenChange={setCapToken} />`),拿到 `capToken` 前登录按钮和输入框全部 `disabled`。后端 `src/server/cap/service.ts`:挑战/校验用 `HMAC(SESSION_SECRET, "uhd-docs/capjs-core/v1")` 派生的域分离密钥,redeem token 一次性消费(SQLite 表 `cap_redeem_tokens`,`DELETE ... WHERE token_key=? AND scope=? AND expires_at>?` 原子销毁)。登录路由里**先烧 capToken 再做密码哈希校验**(无论成败都烧掉,一次证明只授权一次尝试)。
这套东西相对重(引入 `capjs-core` 依赖 + WASM 求解器 + 两张表),**属于"锦上添花"而非"照抄底线"**:如果 uhd_media_tool 的管理端只是内网/团队自用工具而非公网暴露的公共产品,IP+账号限速(§1.7)通常已经够。若确实要抄,思路是"敏感端点前置一次一次性 redeem token",实现上可以先跳过、后续再加。
### 1.9 TOTP(有)/ Passkey(有,可选跳过)
TOTP:`src/lib/totp.ts` 纯 `node:crypto` 实现 RFC 6238(SHA1/6 位/30s),**无第三方 OTP 依赖**,含防重放(`totpLastCounter` 记录已消费的最高计数器,`verifyTotpCounter` 拒绝 ≤ 该值的候选),代码 Node 通用可**逐字复用**。Passkey(WebAuthn):`src/server/admin-auth/passkey.ts` + 前端 `@simplewebauthn/browser`,作为密码的替代首因子(不是二次因子),复杂度不低(注册/认证两个 ceremony + `admin_passkeys` 表存 COSE 公钥),**建议后置**,先做密码+session+TOTP。
### 1.10 密码修改流程
`src/app/api/admin-auth/password/route.ts`(`withAdminApi` 包裹):校验当前密码 → 写新 `passwordHash` → **吊销除当前会话外的所有其它会话**(`revokeOtherSessions(username, session.sessionId)`)→ 记审计 `admin.password_change`。修改密码不强制改 TOTP/passkey。**建议照搬**整个流程,尤其"改密后踢掉其它设备会话"这一步很容易被忽略。
### 1.11 审计
`src/server/audit/log.ts`:一张 `audit_logs` 表,固定的 `AuditAction` 联合类型(枚举式,不是自由字符串),记录 `action/actor/targetType/targetId/detailJson/ipHash`。写入包一层 `try/catch`——**审计失败绝不能影响主流程**。管理相关动作:`admin.login_success / admin.setup / admin.password_change / admin.totp_enable / admin.totp_disable / admin.passkey_register / admin.passkey_remove / admin.passkey_login`。注意:**限速拦截掉的失败尝试不写审计**(见 §1.7),只有真正进入业务逻辑的动作才记。
**建议照搬**:枚举式 action 类型 + try/catch 兜底,这是最低成本、最高性价比的一项,pg 版直接建 `audit_log` 表照抄字段。
## 2. 登录页 UI
- 文件:`src/app/admin/login/page.tsx`(服务端组件,读初始状态:是否已初始化、品牌、passkey 是否可用、`next` 回跳路径)+ `src/app/admin/login/login-client.tsx`(453 行,客户端交互全在这)。
- **布局**:桌面端左右两栏(`lg:grid-cols-[minmax(0,1fr)_23.5rem]`)——左侧大幅品牌/标语展示区(纯装饰,`hidden lg:block`),右侧固定宽度(23.5rem)登录卡片;移动端退化为单栏卡片,顶部品牌横排。背景是一张全屏蓝图风格插画(`blueprint-light.webp` / `blueprint-dark.webp`,按 `dark:` 切换)。顶部有一条 `sticky` 品牌 header(logo + 站点名 + 语言切换 + 主题切换)。
- **交互状态机**:同一个表单组件承担三种模式——**首启向导**(`setupMode`:多一个"确认密码" + "初始化口令"输入框)、**普通登录**(用户名+密码)、**二次验证**(`needsTotp`:提交密码后如果账号开了 TOTP,后端返回 `TOTP_REQUIRED`,前端原地插入 6 位数字输入框,用户名/密码字段变 `readOnly` 但保留值,不用重新输入)。三态由后端错误码驱动切换,不是前端猜的。
- **Passkey 按钮**:仅当 `initialPasskeyAvailable`(HTTPS/localhost 安全上下文 + 已注册过 passkey)且非 setup/非 TOTP 阶段显示,放在密码表单**上方**,用一条 "或" 分隔线隔开,视觉上是主按钮(`variant={showPasskey ? "outline" : "default"}`——有 passkey 时密码登录降级为 outline 按钮,把主按钮位置让给指纹登录)。
- **错误提示**:固定高度占位容器 `<div className="min-h-5" aria-live="polite">`(避免报错时页面跳动),图标 + 文案,`role="alert"`;根据后端错误码分流文案(`RATE_LIMITED` / `TOTP_INVALID` / 其它),而不是直接透传后端 message。
- **加载态**:提交按钮里 `LoaderCircle` 转圈图标替换文字前缀,同时所有输入框 `disabled={!capToken || loading}`——**没有 PoW token 之前整个表单都是禁用的**,这个"先过人机验证才能操作表单"的顺序是刻意设计。
- **没有"记住我"**:固定会话时长(12 小时),无相关 UI 选项。
- **密码显示切换**:眼睛图标 `Eye`/`EyeOff` 切换 `type="password"|"text"`,`aria-pressed` 标注状态。
- **视觉风格要点**(供前端复用同类布局参考):大圆角(`rounded-lg`)、高输入框(`h-14`,触屏友好)、图标前缀嵌在输入框内(`absolute left-3.5`)、品牌色仅用于强调线和主按钮,大量留白,弱化的 `text-muted-foreground` 二级文案,整体是"极简 SaaS 后台登录页"风格而非"企业系统"风格。
**建议照搬**:错误码驱动的三态表单(setup/login/TOTP 复用同一组件)、"PoW/前置校验完成前禁用整个表单"的交互顺序、固定高度错误占位防跳动。React Router 7 SPA 落地时可以做成一个 `<AdminLoginPage>` + 内部 state machine,不需要 Next 的服务端组件划分,直接 CSR 请求一个 `GET /api/v1/admin/auth/state` 拿初始状态即可。
## 3. Docker:单容器打包
### 3.1 Dockerfile 多阶段结构(`Dockerfile`)
五个 stage:`bunbin`(只为拿 pin 版 Bun 二进制)→ `base`(Debian trixie-slim + ca-certificates + Bun)→ `deps`(`bun install --frozen-lockfile`)→ `caddyfetch`(下载 pin 版 Caddy 静态二进制,校验 `caddy version`)→ `builder`(`bun run build`,清 `.next/cache`,预编译 `next.config.ts → next.config.js` 让运行时免装编译器)→ `runner`(最终镜像:非 root 用户 `app`(uid 10001),`libgomp1/ca-certificates/fontconfig/gosu/libcap2-bin`,`COPY --chown=app:app` 精确控制目录属主,`setcap cap_net_bind_service=+ep` 让非 root 用户能绑 80/443,`HEALTHCHECK` 打自身 `/api/health`,`VOLUME ["/app/data"]`,`ENTRYPOINT ["docker-entrypoint.sh"]` + `CMD ["bun","--bun","server.ts"]`)。
关键取舍点(可复用的思路,不是具体命令):
- **基础镜像按"最挑剔的依赖"选**(这里是 libsimple 需要 glibc ≥2.38 → trixie,而不是官方 Bun 镜像的 bookworm),迁移到 Node 项目应类比:先看有没有原生依赖(如 argon2、sharp)对 glibc/musl 有版本要求,再定基础镜像。
- **构建产物按运行时最小闭包裁剪**:Next standalone 输出 + 手动补回被裁掉但运行时脚本仍需要的包(`next`/`drizzle-orm`/`zod`/`pinyin-pro`)。Node/Hono 项目对应:如果用 `esbuild`/`tsup` 打包成单文件,这一步天然被规避;如果是裸 `tsc` + `node_modules`,要么整包拷贝要么用 `--production` + 显式补运行时脚本依赖。
- **非 root + 目录属主用 `COPY --chown` 而非事后 `chown -R`**(省一层镜像体积)。
- **内嵌反代二进制(Caddy)做压缩/静态资源缓存**,让镜像开箱即用不必外置 nginx——这是"单容器打包"的核心手法,值得照搬。
- **HEALTHCHECK 打应用自己的回环端口**而不是反代端口,因为反代端口会随 `SITE_ADDRESS` 配置切换(3000 或 80/443),回环端口永远稳定。
### 3.2 docker-entrypoint.sh 做了什么
```bash
if [ "$(id -u)" = "0" ]; then
mkdir -p /app/data/config /app/data/uploads /app/data/backups
chown -R app:app /app/data
exec gosu app "$0" "$@" # 降权到 app 用户后重跑自己
fi
bun src/scripts/migrate.ts # 迁移
bun src/scripts/preflight.ts # 启动自检(密钥、扩展加载、非 root 等,不过就退出)
bun src/scripts/seed.ts # 空库播种(no-op if docs exist)
"$@" & # 前台进程1:Next.js
APP_PID=$!
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & # 前台进程2:Caddy
CADDY_PID=$!
trap shutdown TERM INT
wait -n "$APP_PID" "$CADDY_PID" # 任一进程退出,整体退出
```
**核心手法**:容器以 root 启动只为修数据目录权限,然后 `gosu` 降权重跑自身;`migrate → preflight → seed → start` 四段式启动;**双前台进程用 `wait -n` 互相看护**,任一挂了就 kill 另一个再退出,把"半死容器"变成"整体退出",配合 compose 的 `restart: unless-stopped` 做自愈。
**建议照搬**:四段式启动顺序(迁移→自检→(可选)播种→启动)、root 降权模式、多进程互相看护退出码,这些和技术栈无关,Node/Hono 项目原样适用(把 `bun` 换成 `node`/`tsx`)。
### 3.3 docker-compose.yml 服务划分
单服务(`uhd-docs`),没有拆 db/app 两个容器——因为用的是内嵌 SQLite,数据库就是 `/app/data` 里的一个文件,天然单容器。
**不适用**:uhd_media_tool 后端是 Kysely/**pg**,数据库是独立进程,不能照搬"单容器"这个前提。对应做法应该是 `docker-compose.yml` 拆两个服务(`app` + `postgres`,当前仓库已有 `docker-compose.dev.yml` 大概率就是这个划分,开发环境用),生产部署时 Caddy/入口容器的思路(见下)仍可以照搬,只是数据库连接串指向另一个容器/托管实例而非本机文件。
日志限制(`logging.driver: json-file, max-size: 10m, max-file: 5`)这条**建议照搬**,防止日志把磁盘写满,和数据库形态无关。
### 3.4 Caddyfile 作用
作为容器内反代:压缩(zstd/gzip,排除 `/api/*` 避免小 JSON 响应上的 CPU 浪费)、静态资源长缓存(`/_next/static/*` 一年不可变缓存)、`trusted_proxies static private_ranges` + `trusted_proxies_strict`(只信任私网段来源的 `X-Forwarded-For`,且只取最后一跳,防止客户端自己伪造跳过限速)、请求体大小分级限制(大文件上传路径 600MB,其余 API 16MB)、`SITE_ADDRESS` 驱动的自动 HTTPS(Let's Encrypt,证书存 `/app/data/caddy` 随容器数据卷持久化)。
**建议照搬**:`trusted_proxies_strict` 这条对任何有登录限速的项目都是**必须**的——没有这条,客户端可以自己塞 `X-Forwarded-For` 头把所有请求伪装成不同 IP,IP 限速形同虚设。uhd_media_tool 无论是否内嵌 Caddy,只要部署在反代后面,这条規則都要在某一层(反代或应用自己解析 `X-Forwarded-For` 的逻辑)体现。
**部分不适用**:如果 uhd_media_tool 决定不内嵌反代(直接把 Hono 服务暴露给外部 nginx/Caddy),那么压缩/静态缓存这部分职责转移给外部反代,应用只需要正确读 `X-Real-IP`/信任的 `X-Forwarded-Proto`,和 uhd_doc 的 `src/lib/request.ts` 逻辑一致即可(见 §1.3、§1.7 提到的 `getClientIp`)。
### 3.5 健康检查
`GET /api/health`(`src/app/api/health/route.ts`):无鉴权,查 `SELECT 1` 证明数据库文件可打开可读,返回 `{ok, version, database}`,`no-store`。Dockerfile 里 `HEALTHCHECK` 直接用 Bun 内置 `fetch` 打这个端点,不额外装 `curl`。
**建议照搬**:一个无鉴权、极便宜(一次 `SELECT 1`)的健康检查端点 + 用运行时自带的 HTTP 客户端做探测(Node 项目可以用 `node --input-type=module -e "fetch(...)"`,不需要额外装 curl/wget)。
### 3.6 数据目录挂载与环境变量清单
单一数据卷 `/app/data`,子目录划分:`docs.db`(数据库文件)、`uploads/`、`backups/`、`config/`(含 `runtime-secrets.json`)、`caddy/`(证书状态)。`VOLUME ["/app/data"]` 声明 + compose 挂到宿主 `./data`。
`DEPLOY.md`/`.env.example` 归纳的环境变量类别(完整清单见这两个文件,不逐条照抄):
- **必须配置类**:`APP_URL`(影响 cookie Secure 判定,生产必须是 `https://`)、`ADMIN_USERNAME`。
- **首启一次性类**:`ADMIN_INIT_TOKEN`(可留空自动生成)。
- **自动生成免配置类**:`SESSION_SECRET`/`IP_HASH_SECRET`(首启生成,持久化到 `runtime-secrets.json`,要求 ≥32 字符且不能是占位符样式如 `change_me`/`REPLACE_ME`)。
- **TTL 类**:`PASSWORD_ACCESS_TTL_HOURS`、`ADMIN_SESSION_TTL_HOURS`。
- **容器层旁路变量**(不进 Zod schema,只给 Caddy/entrypoint 用):`SITE_ADDRESS`、`MAX_UPLOAD_BODY`、`SSR_MAX_CONCURRENCY`。
**建议照搬的设计**:①密钥类("能自动生成就自动生成,但生成后要落盘且警告"),`src/lib/env.ts` 的 `resolveRuntimeSecret()` 这套"占位符检测 + 自动生成 + 持久化到 CONFIG_DIR" 模式值得原样搬到 pg 版的 `env.ts`;②敏感变量分层(应用 schema 内 vs 容器旁路变量)让 `.env.example` 保持可读。
## 4. 建议照搬 / 不适用(需改造)一览表
| 项目 | 建议照搬 | 不适用 / 需改造 |
|---|---|---|
| 密码哈希 | Argon2id 参数(`memoryCost≈19456, timeCost=2`) | Bun 内置 `Bun.password` → 换 `@node-rs/argon2`(Node 24 原生插件) |
| 首启管理员 | 一次性 setup token(自动生成+持久化+首启后失效)+ 密码只落库不落 env + CLI 兜底重置 | 无需改造,逻辑与运行时无关 |
| 会话 | 服务端 session 表(hash 存库、原文进 cookie)+ 软删除吊销 + 固定 TTL 不续期 | SQLite→pg:表结构原样搬,`integer(timestamp_ms)`→`timestamptz` |
| 鉴权中间件位置 | 校验逻辑(查 session→查过期→查吊销) | Next 的"每个 route 手动包 `withAdminApi`" → Hono 用标准 `app.use(path, middleware)`,更简洁,不必照搬包裹形式 |
| 未登录跳转 | 轻量 cookie-存在性判断做快速拦截,真正校验留给业务层 | Next `middleware.ts`(边缘层)在 SPA 里没有等价物 → 改为路由 loader + 一个薄 session 探测 API |
| CSRF | HMAC 派生 double-submit cookie(`HMAC(secret, "csrf:"+sessionId)`) | 无需改造 |
| 登录限速 | IP+账号双维度、失败计数落表、命中限速不写审计 | 无需改造 |
| 人机验证(Cap PoW) | 若确实公网暴露且介意撞库自动化,可后期引入 | 复杂度高(WASM 求解器+两张表),团队内网工具**可先跳过** |
| TOTP / Passkey | TOTP:`node:crypto` 纯手写 RFC6238,可整段复用;Passkey:替代首因子的模式 | TOTP 无需改造;Passkey 实现成本高,**建议后置**到密码+TOTP 稳定之后 |
| 密码修改流程 | 校验旧密码→改密→吊销其它会话→审计 | 无需改造 |
| 审计 | 枚举式 action 类型 + try/catch 兜底不影响主流程 | 无需改造 |
| 登录页交互 | 错误码驱动三态表单、PoW/校验完成前禁用表单、固定高度错误占位 | Next 服务端组件读初始状态 → SPA 里改成挂载时 `GET` 一个 `/auth/state` 接口 |
| Dockerfile | 多阶段构建、非root+`COPY --chown`、`HEALTHCHECK`打自身回环端口、内嵌反代做压缩/静态缓存 | 基础镜像选型逻辑不同(Node 版按自身原生依赖定,而非 libsimple/glibc 那套) |
| entrypoint | 四段式启动(迁移→自检→播种→启动)、root降权、多进程 `wait -n` 互相看护 | 无需改造,把 `bun` 换 `node`/`tsx` |
| docker-compose | 日志大小限制(`max-size/max-file`) | **单容器前提不成立**:pg 是独立进程,需拆 `app`+`postgres` 两个服务,不能照搬"一个卷搞定一切" |
| Caddyfile | `trusted_proxies_strict`(只信任私网段反代的最后一跳)是安全必需项,压缩/静态缓存可选 | 若不内嵌反代,压缩/缓存职责转给外部 nginx/Caddy,应用只需正确读 `X-Real-IP` |
| 健康检查 | 无鉴权、`SELECT 1`级别开销、用运行时自带 fetch 探测 | 无需改造 |
| 环境变量管理 | 密钥"能自动生成就自动生成+持久化+占位符检测"模式 | 无需改造 |
关键文件速查(均在 `/opt/uhd_doc/`):`src/lib/{password,totp,crypto,cookies,request,env}.ts`、`src/server/admin-auth/{session,credential,csrf,login,passkey}.ts`、`src/server/{cap/service,audit/log,api/wrappers}.ts`、`src/app/api/admin-auth/{login,logout,setup,password}/route.ts`、`src/proxy.ts`、`src/db/schema.ts`(L382–530 附近)、`src/app/admin/login/{page,login-client}.tsx`、`src/components/security/proof-of-work.tsx`、`Dockerfile`、`docker-entrypoint.sh`、`docker-compose.yml`、`Caddyfile`、`DEPLOY.md`、`.env.example`、`src/scripts/{admin-set-password,admin-show-init-token,migrate,preflight,seed}.ts`。