diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts index 19f78b4..8ae73d1 100644 --- a/apps/api/src/auth/password.ts +++ b/apps/api/src/auth/password.ts @@ -1,67 +1,8 @@ -import { pbkdf2, randomInt, timingSafeEqual } from "node:crypto" +import { pbkdf2, timingSafeEqual } from "node:crypto" import { promisify } from "node:util" -import { config } from "../config" - const pbkdf2Async = promisify(pbkdf2) -/** - * Django 的 salt 字符集与长度:`RANDOM_STRING_CHARS` + `BasePasswordHasher.salt()` - * 取 22 位。字符集必须一致 —— 旧后端验密码时只按 `$` 切段、不校验字符集, - * 但对齐了才能保证同一条哈希在两边长得一模一样。 - */ -const DJANGO_SALT_CHARS = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -/** - * 对齐 Django 6 的 `PBKDF2PasswordHasher.iterations`。 - * - * 这个数只影响**新写**的哈希;验旧哈希时迭代次数是从哈希串里读的,所以生产库里 - * 那些 120000 / 260000 / 720000 / 1000000 的老哈希照验不误(1710 个账号横跨 - * Django 3.x 到 6,全是 pbkdf2)。 - */ -const DJANGO_ITERATIONS = 1_200_000 - -function djangoSalt() { - let salt = "" - for (let i = 0; i < 22; i++) { - salt += DJANGO_SALT_CHARS[randomInt(DJANGO_SALT_CHARS.length)] - } - return salt -} - -/** 写成 Django 认得的 `pbkdf2_sha256$迭代次数$salt$base64` */ -export async function hashDjangoPbkdf2(password: string) { - const salt = djangoSalt() - const digest = await pbkdf2Async(password, salt, DJANGO_ITERATIONS, 32, "sha256") - return `pbkdf2_sha256$${DJANGO_ITERATIONS}$${salt}$${digest.toString("base64")}` -} - -/** - * 写密码。默认 argon2id;`PASSWORD_HASH_UPGRADE=false` 时改写 Django 格式的 - * pbkdf2(旧后端验得了),是万一要把旧站拉回来的退路。见 config 里的注释。 - * - * 原来五个写入点全都直接 `Bun.password.hash(argon2id)`,而 - * `config.passwordHashUpgrade` 只管住了登录时的自动升级那一处: - * - * POST /users 注册 - * PUT /admin/users/:id 管理员改密码 - * POST /admin/users 批量导入用户 - * POST /admin/users/:id/reset-password 重置密码 ← 老师天天在用 - * 登录成功后的自动升级 (只有这一处受开关管) - * - * 也就是说开关关着的时候,老师给学生点一次「重置密码」,那个账号就**立刻回不去 - * 旧站了**。而「老师帮学生查/改密码」恰恰是这套系统的日常功能 —— - * `raw_password` 那一列存在的理由就是它。 - * - * 现在五处统一走这里,开关两个方向都是真的。 - */ -export function hashPassword(password: string) { - return config.passwordHashUpgrade - ? Bun.password.hash(password, { algorithm: "argon2id" }) - : hashDjangoPbkdf2(password) -} - async function verifyDjangoPbkdf2(password: string, encoded: string) { const [algorithm, iterationsText, salt, digestText] = encoded.split("$") if ( @@ -111,3 +52,21 @@ export async function verifyPassword(password: string, encoded: string) { return { valid: false, needsUpgrade: false } } + +/** + * 写密码的**唯一入口**。五个调用方都走这里:注册、管理员改密码、批量导入用户、 + * 重置密码、登录时升级存量 pbkdf2。 + * + * 之所以特意收成一个函数:原来五处各写各的 `Bun.password.hash`,而当年那个 + * 「回滚窗口内不要升级成 argon2」的开关只管住了登录 + * 那一处,另外四处照写 argon2 不误 —— 老师给学生点一次「重置密码」,那个账号 + * 就回不去旧站了,开关关着也拦不住。旧站 2026-08-26 下线,开关已经删掉, + * 但「只有一个地方写密码」这件事留下来了。 + * + * 真要把旧站拉回来:切换手册「万一已经改坏了」那节的脚本才是正经退路 —— + * 它拿 `raw_password` 重算 Django 的 make_password,能修**已经**变成 argon2 的 + * 账号;靠开关只能拦住将来,修不了已经发生的。 + */ +export function hashPassword(password: string) { + return Bun.password.hash(password, { algorithm: "argon2id" }) +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index c6fd81f..4e1d812 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -66,24 +66,6 @@ export const config = { sessionCookie: "oj2_session", sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60), secureCookies: process.env.COOKIE_SECURE === "true", - // 写密码用 argon2id 还是 Django 格式的 pbkdf2。**默认 argon2** —— 旧站已经下线 - // (2026-08-26),没有回滚路径要照顾了。 - // - // true(默认):五个写入点全写 argon2id,登录成功时把存量 pbkdf2 顺手升级掉。 - // false: 全写 Django 格式的 `pbkdf2_sha256$1200000$…`,登录时也不动 - // 存量哈希 —— 旧后端验得了。**万一要把旧站拉回来,设这个。** - // - // ⚠️ **无论开关怎么设,`verifyPassword` 的 pbkdf2 分支永远不能删。** 生产库 - // 1710 个账号全是 Django 写的 pbkdf2(迭代次数横跨 120000~1200000,因为 - // 跨了好几个 Django 版本),它们只会在各自下次登录时才升级成 argon2; - // 删掉那个分支就是全站登不上。 - // - // 历史:这个开关原来默认关闭,用来堵「升级成 argon2 的账号回不去旧站」这道 - // 单向门(并行试跑第一天就撞过,见 phase5 切换手册)。但它当时**只管住了 - // 登录时的自动升级那一处**,注册 / 管理员改密码 / 批量导入 / 重置密码四条 - // 路无条件写 argon2 —— 老师给学生点一次「重置密码」,那个账号照样回不去。 - // 现在五处统一走 auth/password.ts 的 hashPassword,开关两个方向都是真的。 - passwordHashUpgrade: process.env.PASSWORD_HASH_UPGRADE !== "false", judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081", judgeServerToken: judgeServerToken(), judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2), diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 1cfcd34..1723c79 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -3,7 +3,6 @@ import { eq, sql } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, type AppEnv } from "../auth/middleware" -import { config } from "../config" import { createSession, destroySession } from "../auth/session" import { hashPassword, verifyPassword } from "../auth/password" import { db, schema } from "../db" @@ -55,10 +54,9 @@ authRoutes.post("/auth/login", async (c) => { const now = new Date().toISOString() const update: { lastLogin: string; password?: string } = { lastLogin: now } - // 存量 pbkdf2 顺手升级成 argon2。**只在总开关打开时做** —— 升过的账号回不去 - // 旧站,见 config.passwordHashUpgrade。开关关着时 hashPassword 写的也是 pbkdf2, - // 所以这里不升级、别处不写 argon2,回滚路径才是完整的。 - if (password.needsUpgrade && config.passwordHashUpgrade) { + // 存量 pbkdf2 顺手升级成 argon2。生产库 1710 个账号都是 Django 写的 pbkdf2, + // 靠这里随登录逐个迁移;没登录过的照旧由 verifyPassword 的 pbkdf2 分支兜着。 + if (password.needsUpgrade) { update.password = await hashPassword(parsed.data.password) } await db.update(schema.user).set(update).where(eq(schema.user.id, user.id)) diff --git a/apps/api/src/scripts/seed-dev.ts b/apps/api/src/scripts/seed-dev.ts index 3447abf..867ee6b 100644 --- a/apps/api/src/scripts/seed-dev.ts +++ b/apps/api/src/scripts/seed-dev.ts @@ -48,8 +48,7 @@ interface SeedAccount { * 就缺 profile 和 email,后台页面一律进不去,排查了很久才找到这里。 */ async function seed(account: SeedAccount) { - // 走 hashPassword 而不是直接 argon2:本机也跟着 PASSWORD_HASH_UPGRADE 走, - // 默认写 Django 格式的 pbkdf2,和线上一个行为 + // 走 hashPassword,和线上五个写入点同一条路 const passwordHash = await hashPassword(account.password) const email = `${account.username}@example.test` const [user] = await db diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index 5d34442..0f638b6 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -135,10 +135,6 @@ services: JUDGE_SERVER_TOKEN: ${OJ2_JUDGE_TOKEN:?} JUDGE_CONCURRENCY: ${JUDGE_CONCURRENCY:-2} AI_KEY: ${AI_KEY:-} - # 登录时把 Django 的 pbkdf2 哈希升级成 argon2 写回库。**默认关闭**: - # 升级过的账号旧后端再也验不了,是一道单向门,回滚窗口内打开会锁死学生。 - # 详见 apps/api/src/config.ts 里 passwordHashUpgrade 的注释。 - PASSWORD_HASH_UPGRADE: ${PASSWORD_HASH_UPGRADE:-} # 走 NPM 终止 TLS,浏览器侧是 https,Cookie 必须带 Secure COOKIE_SECURE: "true" healthcheck: diff --git a/docker/compose.school.yml b/docker/compose.school.yml index 9b09746..a2df6ca 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -81,7 +81,6 @@ services: AI_KEY: ${AI_KEY:-} # 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传, # 学生会「登录成功但立刻又是未登录」。这里必须是 false。 - PASSWORD_HASH_UPGRADE: ${PASSWORD_HASH_UPGRADE:-} COOKIE_SECURE: ${COOKIE_SECURE:-false} healthcheck: test: ["CMD", "oj2-api", "healthcheck"] diff --git a/docs/specs/phase5-cutover-runbook.md b/docs/specs/phase5-cutover-runbook.md index 92ae44c..6cd5c43 100644 --- a/docs/specs/phase5-cutover-runbook.md +++ b/docs/specs/phase5-cutover-runbook.md @@ -51,22 +51,24 @@ pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序 > 于是「登录过新站的学生,回滚之后登不上旧站」—— 一道结构比对发现不了的单向门。 > 试跑第一天就撞上了,现象是旧站登录失败 + WS 连不上(Channels 认 session)。 > -> 已改成 `PASSWORD_HASH_UPGRADE` 控制。 +> 当时的修法是加 `PASSWORD_HASH_UPGRADE` 开关、默认关闭。 > -> **⚠️ 2026-08-26 再补两件事。** +> **⚠️ 2026-08-26 更新:开关已经删掉,现在无条件写 argon2。** > -> **一、当时那次只堵住了登录那一处,门其实一直开着。** 新后端一共五个地方写密码: -> 登录时自动升级、注册、管理员改密码、批量导入用户、**重置密码**。 -> `PASSWORD_HASH_UPGRADE` 只管住了第一处,后面四处无条件写 argon2 —— 也就是说 -> 开关关着的时候,**老师给学生点一次「重置密码」,那个账号照样回不去旧站**, -> 而这恰恰是老师天天在用的功能。现在五处统一走 `auth/password.ts` 的 -> `hashPassword()`,开关两个方向都是真的。 +> 两个原因。**一是那个开关一直是假的**:新后端写密码的地方有五个(登录时自动 +> 升级、注册、管理员改密码、批量导入用户、**重置密码**),开关只管住了第一处, +> 后面四处照写 argon2 不误 —— 也就是说开关关得好好的,老师给学生点一次 +> 「重置密码」,那个账号照样回不去旧站,而这正是老师天天在用的功能。 > -> **二、旧站已经下线,这个开关的默认值翻成了 `true`。** 也就是默认写 argon2、 -> 登录时升级存量哈希。**要把旧站拉回来的话,先设 `PASSWORD_HASH_UPGRADE=false`**, -> 之后新写的密码就又是 Django 格式的 `pbkdf2_sha256$1200000$…` 了(已用旧后端的 -> Django 实打双向验过:新后端写的 `check_password` 通过,Django 写的新后端也认)。 -> 已经被升成 argon2 的存量账号仍然要按下面「万一已经改坏了」那节修。 +> **二是旧站已经下线**,没有回滚路径要照顾了。五处现在统一走 +> `auth/password.ts` 的 `hashPassword()`,只有一个地方写密码。 +> +> 真要把旧站拉回来,正经退路是下面「万一已经改坏了」那节的脚本 —— 它拿 +> `raw_password` 重算 Django 的 `make_password`,能修**已经**变成 argon2 的账号。 +> 开关只能拦住将来,修不了已经发生的,这也是它不值得留的原因。 +> +> `verifyPassword` 的 pbkdf2 分支**永远保留**:生产库 1710 个账号全是 Django 写的 +> pbkdf2(迭代次数 120000~1200000),它们只会在各自下次登录时才迁移成 argon2。 --- @@ -311,12 +313,10 @@ WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切 - **两边登录态不互通**。旧站是 Django session,新站是 Redis opaque token。 学生到 oj2 要重新登录一次,这不是 bug。 -- **想保住回滚路径就得设 `PASSWORD_HASH_UPGRADE=false`**(2026-08-26 起默认是 - `true`,因为旧站已经下线)。默认值下,在 oj2 登录过或被改过密码的账号会变成 - argon2 哈希,**回旧站就登不上了** —— 试跑第一天真撞过,现象是旧站登录失败 + - WS 连不上。修复见下面的「万一已经改坏了」。设成 `false` 之后是真安全:注册 / - 改密码 / 重置密码 / 批量导入写的也都是 Django 格式的 pbkdf2(以前这几条路 - 无视开关直接写 argon2)。 +- **在 oj2 登录过、注册的、被改过或重置过密码的账号,哈希会变成 argon2, + 回旧站就登不上了** —— 试跑第一天真撞过,现象是旧站登录失败 + WS 连不上。 + 2026-08-26 起这是无条件行为(`PASSWORD_HASH_UPGRADE` 开关已删,见上面那条 + 补充说明为什么它本来就拦不住)。修复见下面的「万一已经改坏了」。 - **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**, 不是沙盒。别拿它做破坏性试验。 - 后台判题机列表会出现**两台**(新旧各自心跳),正常。 @@ -443,14 +443,14 @@ backend 和判题机再 start 起来。**不需要恢复数据库,不需要动 这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程, 库出问题的可能性从流程里被整个拿掉了。 -### ⚠️ 回滚成立的前提:`PASSWORD_HASH_UPGRADE=false` +### ⚠️ 回滚要额外处理密码 -「不动任何数据」只在这个前提下成立。默认值(`true`,2026-08-26 旧站下线后改的) -下,新站登录过、注册的、被老师改过或重置过密码的账号,哈希都会变成 argon2, -旧后端验不了 —— 回滚之后那些学生登不上。 +「不动任何数据」不适用于 `user.password` 这一列。在新站登录过、注册的、被老师 +改过或重置过密码的账号,哈希已经是 argon2,旧后端验不了 —— 回滚之后那些学生 +登不上。(2026-08-26 起这是无条件行为,`PASSWORD_HASH_UPGRADE` 开关已删。) -**真要留回滚路径,先把 `PASSWORD_HASH_UPGRADE=false` 设上再说**,已经变成 -argon2 的按下面那节修。 +所以回滚流程里**必须**加一步:按下面那节把 argon2 的账号用 `raw_password` +重算回 Django 的 pbkdf2。 ### 万一已经改坏了