Files

26 KiB
Raw Permalink Blame History

管理员登录门禁 与 单容器打包 —— 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 参数:

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 表:

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() 是核心:

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 探测接口"。

src/server/admin-auth/csrf.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 做了什么

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。