From 694e147a21f2518c147c4d04cc7c715eb84acc90 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 25 Aug 2026 23:47:02 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E5=AF=86=E7=A0=81):=20=E4=BA=94=E4=B8=AA?= =?UTF-8?q?=E5=86=99=E5=85=A5=E7=82=B9=E7=BB=9F=E4=B8=80=E8=B5=B0=20hashPa?= =?UTF-8?q?ssword=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=88=87=20argon2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 那道「单向门」其实一直开着 `PASSWORD_HASH_UPGRADE` 是并行试跑第一天撞出来的补丁:新后端把 Django 的 pbkdf2 升级成 argon2 之后,旧后端验不了,登录过新站的学生回滚就登不上 (phase5 切换手册里记着这件事)。当时的修法是给**登录时的自动升级**加个开关, 默认关闭。 但写密码的地方有五个,开关只管住了一个: POST /users 注册 PUT /admin/users/:id 管理员改密码 POST /admin/users 批量导入用户 POST /admin/users/:id/reset-password 重置密码 ← 老师天天在用 登录成功后的自动升级 ← 只有这一处受开关管 后面四处无条件 `Bun.password.hash(argon2id)`。也就是说开关关得好好的, **老师给学生点一次「重置密码」,那个账号就回不去旧站了** —— 而「老师帮学生 查/改密码」正是这套系统的日常功能,`raw_password` 那一列存在的理由就是它。 所以那半年里「默认关闭 = 回滚安全」是个假象。 ## 改法 五处统一走 `auth/password.ts` 的 `hashPassword()`,开关两个方向都变成真的: - `true`:五处全写 argon2id,登录时把存量 pbkdf2 顺手升级掉。 - `false`:五处全写 Django 格式的 `pbkdf2_sha256$1200000$<22位salt>$`, 登录时也不动存量哈希 —— 两边都验得了。 新加的 `hashDjangoPbkdf2` 逐项对齐 Django 6 的 `PBKDF2PasswordHasher`: 1200000 迭代、22 位 salt、`RANDOM_STRING_CHARS` 字符集、sha256/32 字节。 **默认值定成 `true`** —— 旧站今天下线了,没有回滚路径要照顾。要把旧站拉回来 就先设 `PASSWORD_HASH_UPGRADE=false`,切换手册三处说明都改了。 ⚠️ `verifyPassword` 的 pbkdf2 分支**永远不能删**,代码和注释里都写了:生产库 1710 个账号全是 Django 写的 pbkdf2(迭代次数 120000~1200000,跨了好几个 Django 版本),它们只会在各自下次登录时才升级成 argon2。 ## 顺带:dev seed 只有学生号 `seed:dev` 只建 `student`(普通用户),本机想测后台得手工往库里塞 email 和 user_profile —— 而缺 user_profile 的表现极其隐蔽:`/api/me` 返回 profile-not-found → 前端 getMyProfile 抛异常 → localStorage 的 authed 存不进去 → **所有 /admin 路由被守卫静默弹回首页**,不报错。我在这上面卡了很久。 现在 seed 同时建学生和超管(`devadmin` / `devadmin123`),profile 和 email 一起 建好,并且写密码也走 hashPassword。另外加了一道防呆:DATABASE_URL 不是本机时 直接拒绝执行 —— 这个脚本会重置密码并把明文写进 raw_password,其中一个还是超管, 对着生产库跑一次就是把超管密码改掉。要绕过设 `OJ2_SEED_FORCE=true`。 ## 验证 全程拿**旧后端那个真的 Django venv** 对打,不是照着文档推: - 事实核对:Bun 写的 `$argon2id$…` 在 Django 里 `identify_hasher` 抛 `Unknown password hashing algorithm ''`;换成 Django 格式 `argon2$argon2id$…` 能识别,但 `verify()` 抛 `Couldn't load 'Argon2PasswordHasher' algorithm library: No module named 'argon2'`(旧后端确实没装 argon2-cffi)。 原注释说的「格式对了也验不了」结论对,机制略有出入。 - 双向互验:OJ2 写的哈希 Django `check_password` 通过、错密码不通过; Django `make_password` 写的哈希 OJ2 `verifyPassword` 也认。 - 默认(argon2):拿 Django 现造一个 120000 迭代的老哈希塞进库,登录 200 → 哈希变成 argon2 → 再登一次仍 200。 - 退路(`=false`):同一个老哈希登录 200 且**哈希不变**;走后台「重置密码」 之后落库是 `pbkdf2_sha256$1200000$…`,**旧站的 Django 验这个新密码通过**。 - 一次 1200000 迭代约 107ms(写密码时的开销;验旧哈希的开销本来就在)。 - tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过。 测试用户(pwtest1 / legacyuser)已删干净,本机库用新 seed 复位。 Co-Authored-By: Claude Opus 5 --- apps/api/src/auth/password.ts | 66 +++++++++++- apps/api/src/config.ts | 24 +++-- apps/api/src/routes/account.ts | 3 +- apps/api/src/routes/admin/account.ts | 7 +- apps/api/src/routes/auth.ts | 11 +- apps/api/src/scripts/seed-dev.ts | 149 +++++++++++++++++++-------- docs/specs/phase5-cutover-runbook.md | 37 +++++-- 7 files changed, 225 insertions(+), 72 deletions(-) diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts index 6e17ebb..19f78b4 100644 --- a/apps/api/src/auth/password.ts +++ b/apps/api/src/auth/password.ts @@ -1,8 +1,67 @@ -import { pbkdf2, timingSafeEqual } from "node:crypto" +import { pbkdf2, randomInt, 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 ( @@ -30,6 +89,11 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) { return timingSafeEqual(actual, expected) } +/** + * 验密码。**pbkdf2 那条分支永远不能删** —— 生产库 1710 个账号全是 Django 写的 + * pbkdf2,只会在各自下次登录时才升级成 argon2,删掉就是全站登不上。 + * 迭代次数是从哈希串里读的,所以 120000 到 1200000 的老哈希都验得了。 + */ export async function verifyPassword(password: string, encoded: string) { if (encoded.startsWith("pbkdf2_sha256$")) { return { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 7898b2a..c6fd81f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -66,16 +66,24 @@ export const config = { sessionCookie: "oj2_session", sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60), secureCookies: process.env.COOKIE_SECURE === "true", - // 登录成功时把 Django 的 pbkdf2 哈希升级成 argon2id 写回库。 + // 写密码用 argon2id 还是 Django 格式的 pbkdf2。**默认 argon2** —— 旧站已经下线 + // (2026-08-26),没有回滚路径要照顾了。 // - // **默认关闭,这是一道单向门。** Django 存 argon2 的格式是 - // `argon2$argon2id$v=19$…`,而 Bun 写出来的是 `$argon2id$v=19$…`(少了算法标签), - // 旧后端按 `$` 切第一段拿到空串、认不出这个哈希 —— 而且旧后端连 argon2-cffi - // 都没装,格式对了也验不了。**只要在新站登录过一次,这个账号就回不去旧站。** + // true(默认):五个写入点全写 argon2id,登录成功时把存量 pbkdf2 顺手升级掉。 + // false: 全写 Django 格式的 `pbkdf2_sha256$1200000$…`,登录时也不动 + // 存量哈希 —— 旧后端验得了。**万一要把旧站拉回来,设这个。** // - // 所以并行试跑期间必须关着,一次性切换后的回滚窗口内也该关着。 - // 等确定不会再回滚了,再设 PASSWORD_HASH_UPGRADE=true。 - passwordHashUpgrade: process.env.PASSWORD_HASH_UPGRADE === "true", + // ⚠️ **无论开关怎么设,`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/account.ts b/apps/api/src/routes/account.ts index 8ebe032..8f91b44 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -30,6 +30,7 @@ import { } from "drizzle-orm" import { Hono } from "hono" +import { hashPassword } from "../auth/password" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware" import { config } from "../config" import { db, schema } from "../db" @@ -63,7 +64,7 @@ accountRoutes.post("/users", async (c) => { } const now = new Date().toISOString() - const password = await Bun.password.hash(parsed.data.password, { algorithm: "argon2id" }) + const password = await hashPassword(parsed.data.password) await db.transaction(async (tx) => { const [created] = await tx.insert(schema.user).values({ username, diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 458cd9a..f6af4e0 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -12,6 +12,7 @@ import { randomInt } from "node:crypto" import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm" import { Hono } from "hono" +import { hashPassword } from "../../auth/password" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" import { db, schema } from "../../db" import { failure, success } from "../../http" @@ -198,7 +199,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => { if (data.password) { // 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求, // 老师要能查学生密码,见设计文档 7.1.1。 - patch.password = await Bun.password.hash(data.password, { algorithm: "argon2id" }) + patch.password = await hashPassword(data.password) patch.rawPassword = data.password } if (data.openApi) { @@ -236,7 +237,7 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) prepared.push({ username, - password: await Bun.password.hash(password, { algorithm: "argon2id" }), + password: await hashPassword(password), raw: password, email, realName, @@ -310,7 +311,7 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c // 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清 const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("") await db.update(schema.user).set({ - password: await Bun.password.hash(password, { algorithm: "argon2id" }), + password: await hashPassword(password), rawPassword: password, }).where(eq(schema.user.id, id)) return success(c, resetPasswordResponseSchema.parse({ password })) diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 18aa090..1cfcd34 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -5,7 +5,7 @@ import { Hono } from "hono" import { optionalAuth, type AppEnv } from "../auth/middleware" import { config } from "../config" import { createSession, destroySession } from "../auth/session" -import { verifyPassword } from "../auth/password" +import { hashPassword, verifyPassword } from "../auth/password" import { db, schema } from "../db" import { failure, success } from "../http" import { getUserProfileById } from "../services/profile" @@ -55,12 +55,11 @@ authRoutes.post("/auth/login", async (c) => { const now = new Date().toISOString() const update: { lastLogin: string; password?: string } = { lastLogin: now } - // 见 config.passwordHashUpgrade 的注释:升级成 argon2 之后旧后端就验不了这个账号了, - // 是一道单向门。默认关闭,回滚窗口内不要打开。 + // 存量 pbkdf2 顺手升级成 argon2。**只在总开关打开时做** —— 升过的账号回不去 + // 旧站,见 config.passwordHashUpgrade。开关关着时 hashPassword 写的也是 pbkdf2, + // 所以这里不升级、别处不写 argon2,回滚路径才是完整的。 if (password.needsUpgrade && config.passwordHashUpgrade) { - update.password = await Bun.password.hash(parsed.data.password, { - algorithm: "argon2id", - }) + update.password = await hashPassword(parsed.data.password) } await db.update(schema.user).set(update).where(eq(schema.user.id, user.id)) await createSession(c, user.id, user.lastLogin) diff --git a/apps/api/src/scripts/seed-dev.ts b/apps/api/src/scripts/seed-dev.ts index 892bd77..3447abf 100644 --- a/apps/api/src/scripts/seed-dev.ts +++ b/apps/api/src/scripts/seed-dev.ts @@ -1,58 +1,117 @@ import { eq, sql } from "drizzle-orm" +import { hashPassword } from "../auth/password" import { db, schema } from "../db" -const username = process.env.OJ2_DEV_USERNAME ?? "student" -const password = process.env.OJ2_DEV_PASSWORD ?? "student123" -const passwordHash = await Bun.password.hash(password, { algorithm: "argon2id" }) +/** + * 本机开发用的账号。**只能对本地库跑** —— 它会重置账号密码并把明文写进 + * raw_password,其中一个还是超管。对着生产库跑一次就是把超管密码改掉, + * 所以这里按 DATABASE_URL 的主机名拦一道,需要绕过时显式设 OJ2_SEED_FORCE=true。 + */ +const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" +const host = (() => { + try { + return new URL(url).hostname + } catch { + return "" + } +})() +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", ""]) +if (!LOCAL_HOSTS.has(host) && process.env.OJ2_SEED_FORCE !== "true") { + console.error( + `[seed:dev] DATABASE_URL 指向 ${host},不是本机。这个脚本会重置账号密码` + + `(含一个超管)并写明文 raw_password,拒绝执行。\n` + + ` 确实要对这个库跑,设 OJ2_SEED_FORCE=true。`, + ) + process.exit(1) +} + const now = new Date().toISOString() -// Phase 1 imports rows with explicit ids, so PostgreSQL's sequence has not moved. +// 阶段 1 是带着显式 id 导入的,PostgreSQL 的序列没跟着走,先对齐 await db.execute( sql`select setval(pg_get_serial_sequence('"user"', 'id'), coalesce(max(${schema.user.id}), 1), true) from ${schema.user}`, ) -const [user] = await db - .insert(schema.user) - .values({ - username, - password: passwordHash, - rawPassword: password, - email: `${username}@example.test`, - createTime: now, - adminType: "Regular User", - problemPermission: "None", - openApi: false, - isDisabled: false, - sessionKeys: [], - }) - .onConflictDoUpdate({ - target: schema.user.username, - set: { - password: passwordHash, - rawPassword: password, - email: `${username}@example.test`, - isDisabled: false, - }, - }) - .returning({ id: schema.user.id, username: schema.user.username }) - -if (!user) throw new Error("Failed to seed development user") - -const [profile] = await db - .select({ id: schema.userProfile.id }) - .from(schema.userProfile) - .where(eq(schema.userProfile.userId, user.id)) - .limit(1) - -if (!profile) { - await db.insert(schema.userProfile).values({ - userId: user.id, - acmProblemsStatus: { problems: {}, contest_problems: {} }, - avatar: "/public/avatar/default.png", - realName: "Phase 2 Student", - }) +interface SeedAccount { + username: string + password: string + adminType: "Regular User" | "Super Admin" + problemPermission: "None" | "All" + realName: string } -console.log(`Seeded development login: ${user.username} / ${password}`) +/** + * 建号或就地更新。**user_profile 那一行不能省** —— 缺了它 `/api/me` 返回 + * profile-not-found,前端的 getMyProfile 抛异常、localStorage 的 authed 存不 + * 进去,于是所有 /admin 路由被守卫弹回首页,而且不报错。本机的 devadmin 原来 + * 就缺 profile 和 email,后台页面一律进不去,排查了很久才找到这里。 + */ +async function seed(account: SeedAccount) { + // 走 hashPassword 而不是直接 argon2:本机也跟着 PASSWORD_HASH_UPGRADE 走, + // 默认写 Django 格式的 pbkdf2,和线上一个行为 + const passwordHash = await hashPassword(account.password) + const email = `${account.username}@example.test` + const [user] = await db + .insert(schema.user) + .values({ + username: account.username, + password: passwordHash, + rawPassword: account.password, + email, + createTime: now, + adminType: account.adminType, + problemPermission: account.problemPermission, + openApi: false, + isDisabled: false, + sessionKeys: [], + }) + .onConflictDoUpdate({ + target: schema.user.username, + set: { + password: passwordHash, + rawPassword: account.password, + email, + adminType: account.adminType, + problemPermission: account.problemPermission, + isDisabled: false, + }, + }) + .returning({ id: schema.user.id, username: schema.user.username }) + + if (!user) throw new Error(`Failed to seed ${account.username}`) + + const [profile] = await db + .select({ id: schema.userProfile.id }) + .from(schema.userProfile) + .where(eq(schema.userProfile.userId, user.id)) + .limit(1) + + if (!profile) { + await db.insert(schema.userProfile).values({ + userId: user.id, + acmProblemsStatus: { problems: {}, contest_problems: {} }, + avatar: "/public/avatar/default.png", + realName: account.realName, + }) + } + + console.log(` ${account.adminType.padEnd(13)} ${user.username} / ${account.password}`) +} + +console.log("Seeded development logins:") +await seed({ + username: process.env.OJ2_DEV_USERNAME ?? "student", + password: process.env.OJ2_DEV_PASSWORD ?? "student123", + adminType: "Regular User", + problemPermission: "None", + realName: "开发用学生", +}) +await seed({ + username: process.env.OJ2_DEV_ADMIN_USERNAME ?? "devadmin", + password: process.env.OJ2_DEV_ADMIN_PASSWORD ?? "devadmin123", + adminType: "Super Admin", + problemPermission: "All", + realName: "开发用超管", +}) process.exit(0) diff --git a/docs/specs/phase5-cutover-runbook.md b/docs/specs/phase5-cutover-runbook.md index 0b4cb67..92ae44c 100644 --- a/docs/specs/phase5-cutover-runbook.md +++ b/docs/specs/phase5-cutover-runbook.md @@ -51,8 +51,22 @@ pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序 > 于是「登录过新站的学生,回滚之后登不上旧站」—— 一道结构比对发现不了的单向门。 > 试跑第一天就撞上了,现象是旧站登录失败 + WS 连不上(Channels 认 session)。 > -> 已改成 `PASSWORD_HASH_UPGRADE` 控制、**默认关闭**。 -> **回滚窗口内不要打开它**,确定不会再回滚了再说。 +> 已改成 `PASSWORD_HASH_UPGRADE` 控制。 +> +> **⚠️ 2026-08-26 再补两件事。** +> +> **一、当时那次只堵住了登录那一处,门其实一直开着。** 新后端一共五个地方写密码: +> 登录时自动升级、注册、管理员改密码、批量导入用户、**重置密码**。 +> `PASSWORD_HASH_UPGRADE` 只管住了第一处,后面四处无条件写 argon2 —— 也就是说 +> 开关关着的时候,**老师给学生点一次「重置密码」,那个账号照样回不去旧站**, +> 而这恰恰是老师天天在用的功能。现在五处统一走 `auth/password.ts` 的 +> `hashPassword()`,开关两个方向都是真的。 +> +> **二、旧站已经下线,这个开关的默认值翻成了 `true`。** 也就是默认写 argon2、 +> 登录时升级存量哈希。**要把旧站拉回来的话,先设 `PASSWORD_HASH_UPGRADE=false`**, +> 之后新写的密码就又是 Django 格式的 `pbkdf2_sha256$1200000$…` 了(已用旧后端的 +> Django 实打双向验过:新后端写的 `check_password` 通过,Django 写的新后端也认)。 +> 已经被升成 argon2 的存量账号仍然要按下面「万一已经改坏了」那节修。 --- @@ -297,9 +311,12 @@ WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切 - **两边登录态不互通**。旧站是 Django session,新站是 Redis opaque token。 学生到 oj2 要重新登录一次,这不是 bug。 -- **`PASSWORD_HASH_UPGRADE` 必须关着**(默认就是关的,别手贱打开)。打开的话, - 在 oj2 登录过的账号会被改成 argon2 哈希,**回旧站就登不上了** —— - 试跑第一天真撞过,现象是旧站登录失败 + WS 连不上。修复见下面的「万一已经改坏了」。 +- **想保住回滚路径就得设 `PASSWORD_HASH_UPGRADE=false`**(2026-08-26 起默认是 + `true`,因为旧站已经下线)。默认值下,在 oj2 登录过或被改过密码的账号会变成 + argon2 哈希,**回旧站就登不上了** —— 试跑第一天真撞过,现象是旧站登录失败 + + WS 连不上。修复见下面的「万一已经改坏了」。设成 `false` 之后是真安全:注册 / + 改密码 / 重置密码 / 批量导入写的也都是 Django 格式的 pbkdf2(以前这几条路 + 无视开关直接写 argon2)。 - **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**, 不是沙盒。别拿它做破坏性试验。 - 后台判题机列表会出现**两台**(新旧各自心跳),正常。 @@ -426,10 +443,14 @@ backend 和判题机再 start 起来。**不需要恢复数据库,不需要动 这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程, 库出问题的可能性从流程里被整个拿掉了。 -### ⚠️ 回滚成立的前提:`PASSWORD_HASH_UPGRADE` 关着 +### ⚠️ 回滚成立的前提:`PASSWORD_HASH_UPGRADE=false` -「不动任何数据」只在这个前提下成立。打开它的话,新站登录过的账号密码哈希会被换成 -argon2,旧后端验不了 —— 回滚之后那些学生登不上。**默认是关的,回滚窗口内别打开。** +「不动任何数据」只在这个前提下成立。默认值(`true`,2026-08-26 旧站下线后改的) +下,新站登录过、注册的、被老师改过或重置过密码的账号,哈希都会变成 argon2, +旧后端验不了 —— 回滚之后那些学生登不上。 + +**真要留回滚路径,先把 `PASSWORD_HASH_UPGRADE=false` 设上再说**,已经变成 +argon2 的按下面那节修。 ### 万一已经改坏了