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:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user