26 KiB
管理员登录门禁 与 单容器打包 —— 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 探测接口"。
1.6 CSRF:HMAC 派生的 Double-Submit Cookie
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。