refactor(密码): 删掉 PASSWORD_HASH_UPGRADE,无条件写 argon2
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
上一个 commit 把五个写入点收进 hashPassword,顺手把开关默认值翻成了 true 并 留下 false 分支当退路。这个退路是假的,删掉。 **开关只能拦住将来,修不了已经发生的。** 等真到了要把旧站拉回来的那天,活跃 账号早就都被登录/重置密码升成 argon2 了,这时候设 `=false` 一个也救不回来。 正经退路是切换手册「万一已经改坏了」那节的脚本 —— 拿 `raw_password` 重算 Django 的 `make_password`,能修已经变成 argon2 的账号。留着一个永远轮不到它 上场的开关,只是给后来人多一件要读懂的东西。 删掉的:`config.passwordHashUpgrade`、`hashDjangoPbkdf2` 和它那套 Django 参数 (1200000 迭代 / 22 位 salt / RANDOM_STRING_CHARS)、两个 compose 里的 `PASSWORD_HASH_UPGRADE` env。auth.ts 的登录升级变成无条件。 保留的: - `hashPassword()` —— 虽然现在只是一行 argon2,但「只有一个地方写密码」正是上 一个 commit 的重点。五处各写各的才是当初那个 bug 的根。 - `verifyPassword` 的 **pbkdf2 分支** —— 生产库 1710 个账号全是 Django 写的 pbkdf2(迭代次数 120000~1200000),只会在各自下次登录时才迁移。删了就是 全站登不上。代码注释里写死了这句话。 - 切换手册里的 `raw_password` 恢复脚本,以及回滚流程里新加的「密码要额外处理」 那一步。 ## 验证 改完重跑了一遍关键路径(临时造一个 Django 生成的 120000 迭代老哈希): - 存量账号登录 200 → 哈希变成 argon2 → 再登 200 → 错密码 401 - 后台重置密码 → argon2 → 新密码能登录 - 注册 → argon2 → 能登录 tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、build 通过、 两个 compose `config -q` 通过。测试用户已删干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user