Compare commits
2
Commits
36a4663193
...
dd6a6a04eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd6a6a04eb | ||
|
|
694e147a21 |
@@ -30,6 +30,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 {
|
||||
@@ -47,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,16 +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",
|
||||
// 登录成功时把 Django 的 pbkdf2 哈希升级成 argon2id 写回库。
|
||||
//
|
||||
// **默认关闭,这是一道单向门。** Django 存 argon2 的格式是
|
||||
// `argon2$argon2id$v=19$…`,而 Bun 写出来的是 `$argon2id$v=19$…`(少了算法标签),
|
||||
// 旧后端按 `$` 切第一段拿到空串、认不出这个哈希 —— 而且旧后端连 argon2-cffi
|
||||
// 都没装,格式对了也验不了。**只要在新站登录过一次,这个账号就回不去旧站。**
|
||||
//
|
||||
// 所以并行试跑期间必须关着,一次性切换后的回滚窗口内也该关着。
|
||||
// 等确定不会再回滚了,再设 PASSWORD_HASH_UPGRADE=true。
|
||||
passwordHashUpgrade: process.env.PASSWORD_HASH_UPGRADE === "true",
|
||||
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 }))
|
||||
|
||||
@@ -3,9 +3,8 @@ 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 { 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 +54,10 @@ authRoutes.post("/auth/login", async (c) => {
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const update: { lastLogin: string; password?: string } = { lastLogin: now }
|
||||
// 见 config.passwordHashUpgrade 的注释:升级成 argon2 之后旧后端就验不了这个账号了,
|
||||
// 是一道单向门。默认关闭,回滚窗口内不要打开。
|
||||
if (password.needsUpgrade && config.passwordHashUpgrade) {
|
||||
update.password = await Bun.password.hash(parsed.data.password, {
|
||||
algorithm: "argon2id",
|
||||
})
|
||||
// 存量 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))
|
||||
await createSession(c, user.id, user.lastLogin)
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
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}`,
|
||||
)
|
||||
|
||||
interface SeedAccount {
|
||||
username: string
|
||||
password: string
|
||||
adminType: "Regular User" | "Super Admin"
|
||||
problemPermission: "None" | "All"
|
||||
realName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 建号或就地更新。**user_profile 那一行不能省** —— 缺了它 `/api/me` 返回
|
||||
* profile-not-found,前端的 getMyProfile 抛异常、localStorage 的 authed 存不
|
||||
* 进去,于是所有 /admin 路由被守卫弹回首页,而且不报错。本机的 devadmin 原来
|
||||
* 就缺 profile 和 email,后台页面一律进不去,排查了很久才找到这里。
|
||||
*/
|
||||
async function seed(account: SeedAccount) {
|
||||
// 走 hashPassword,和线上五个写入点同一条路
|
||||
const passwordHash = await hashPassword(account.password)
|
||||
const email = `${account.username}@example.test`
|
||||
const [user] = await db
|
||||
.insert(schema.user)
|
||||
.values({
|
||||
username,
|
||||
username: account.username,
|
||||
password: passwordHash,
|
||||
rawPassword: password,
|
||||
email: `${username}@example.test`,
|
||||
rawPassword: account.password,
|
||||
email,
|
||||
createTime: now,
|
||||
adminType: "Regular User",
|
||||
problemPermission: "None",
|
||||
adminType: account.adminType,
|
||||
problemPermission: account.problemPermission,
|
||||
openApi: false,
|
||||
isDisabled: false,
|
||||
sessionKeys: [],
|
||||
@@ -30,14 +69,16 @@ const [user] = await db
|
||||
target: schema.user.username,
|
||||
set: {
|
||||
password: passwordHash,
|
||||
rawPassword: password,
|
||||
email: `${username}@example.test`,
|
||||
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 development user")
|
||||
if (!user) throw new Error(`Failed to seed ${account.username}`)
|
||||
|
||||
const [profile] = await db
|
||||
.select({ id: schema.userProfile.id })
|
||||
@@ -50,9 +91,26 @@ if (!profile) {
|
||||
userId: user.id,
|
||||
acmProblemsStatus: { problems: {}, contest_problems: {} },
|
||||
avatar: "/public/avatar/default.png",
|
||||
realName: "Phase 2 Student",
|
||||
realName: account.realName,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`Seeded development login: ${user.username} / ${password}`)
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -51,8 +51,24 @@ pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序
|
||||
> 于是「登录过新站的学生,回滚之后登不上旧站」—— 一道结构比对发现不了的单向门。
|
||||
> 试跑第一天就撞上了,现象是旧站登录失败 + WS 连不上(Channels 认 session)。
|
||||
>
|
||||
> 已改成 `PASSWORD_HASH_UPGRADE` 控制、**默认关闭**。
|
||||
> **回滚窗口内不要打开它**,确定不会再回滚了再说。
|
||||
> 当时的修法是加 `PASSWORD_HASH_UPGRADE` 开关、默认关闭。
|
||||
>
|
||||
> **⚠️ 2026-08-26 更新:开关已经删掉,现在无条件写 argon2。**
|
||||
>
|
||||
> 两个原因。**一是那个开关一直是假的**:新后端写密码的地方有五个(登录时自动
|
||||
> 升级、注册、管理员改密码、批量导入用户、**重置密码**),开关只管住了第一处,
|
||||
> 后面四处照写 argon2 不误 —— 也就是说开关关得好好的,老师给学生点一次
|
||||
> 「重置密码」,那个账号照样回不去旧站,而这正是老师天天在用的功能。
|
||||
>
|
||||
> **二是旧站已经下线**,没有回滚路径要照顾了。五处现在统一走
|
||||
> `auth/password.ts` 的 `hashPassword()`,只有一个地方写密码。
|
||||
>
|
||||
> 真要把旧站拉回来,正经退路是下面「万一已经改坏了」那节的脚本 —— 它拿
|
||||
> `raw_password` 重算 Django 的 `make_password`,能修**已经**变成 argon2 的账号。
|
||||
> 开关只能拦住将来,修不了已经发生的,这也是它不值得留的原因。
|
||||
>
|
||||
> `verifyPassword` 的 pbkdf2 分支**永远保留**:生产库 1710 个账号全是 Django 写的
|
||||
> pbkdf2(迭代次数 120000~1200000),它们只会在各自下次登录时才迁移成 argon2。
|
||||
|
||||
---
|
||||
|
||||
@@ -297,9 +313,10 @@ WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切
|
||||
|
||||
- **两边登录态不互通**。旧站是 Django session,新站是 Redis opaque token。
|
||||
学生到 oj2 要重新登录一次,这不是 bug。
|
||||
- **`PASSWORD_HASH_UPGRADE` 必须关着**(默认就是关的,别手贱打开)。打开的话,
|
||||
在 oj2 登录过的账号会被改成 argon2 哈希,**回旧站就登不上了** ——
|
||||
试跑第一天真撞过,现象是旧站登录失败 + WS 连不上。修复见下面的「万一已经改坏了」。
|
||||
- **在 oj2 登录过、注册的、被改过或重置过密码的账号,哈希会变成 argon2,
|
||||
回旧站就登不上了** —— 试跑第一天真撞过,现象是旧站登录失败 + WS 连不上。
|
||||
2026-08-26 起这是无条件行为(`PASSWORD_HASH_UPGRADE` 开关已删,见上面那条
|
||||
补充说明为什么它本来就拦不住)。修复见下面的「万一已经改坏了」。
|
||||
- **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**,
|
||||
不是沙盒。别拿它做破坏性试验。
|
||||
- 后台判题机列表会出现**两台**(新旧各自心跳),正常。
|
||||
@@ -426,10 +443,14 @@ backend 和判题机再 start 起来。**不需要恢复数据库,不需要动
|
||||
这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程,
|
||||
库出问题的可能性从流程里被整个拿掉了。
|
||||
|
||||
### ⚠️ 回滚成立的前提:`PASSWORD_HASH_UPGRADE` 关着
|
||||
### ⚠️ 回滚要额外处理密码
|
||||
|
||||
「不动任何数据」只在这个前提下成立。打开它的话,新站登录过的账号密码哈希会被换成
|
||||
argon2,旧后端验不了 —— 回滚之后那些学生登不上。**默认是关的,回滚窗口内别打开。**
|
||||
「不动任何数据」不适用于 `user.password` 这一列。在新站登录过、注册的、被老师
|
||||
改过或重置过密码的账号,哈希已经是 argon2,旧后端验不了 —— 回滚之后那些学生
|
||||
登不上。(2026-08-26 起这是无条件行为,`PASSWORD_HASH_UPGRADE` 开关已删。)
|
||||
|
||||
所以回滚流程里**必须**加一步:按下面那节把 argon2 的账号用 `raw_password`
|
||||
重算回 Django 的 pbkdf2。
|
||||
|
||||
### 万一已经改坏了
|
||||
|
||||
|
||||
Reference in New Issue
Block a user