fix(密码): 五个写入点统一走 hashPassword,默认切 argon2

## 那道「单向门」其实一直开着

`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>$<base64>`,
  登录时也不动存量哈希 —— 两边都验得了。

新加的 `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 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 23:47:02 -06:00
co-authored by Claude Opus 5
parent 36a4663193
commit 694e147a21
7 changed files with 225 additions and 72 deletions
+65 -1
View File
@@ -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 {