Files
web/server/src/auth.ts
T
xuyueandClaude Opus 5.5 cdeb7c18ef fix: 修复中危问题:注册接口、模型名、LLM 超时、WebSocket 断线、未公开任务泄露等
服务端
- 删掉没人用的 /account/register,外网不能再批量注册小号
- 模型名只放行豆包和配置的 LLM_MODEL,浏览器里存的旧模型名换成默认模型;
  聊天默认模型改读 LLM_MODEL
- 流式生成改成 120 秒无数据才超时(另有 15 分钟总上限),思考模型不会被中途掐断;
  SSE 最后一行没有换行时不再丢内容
- WebSocket:token 无效 401、任务不存在 404,建对话失败时关闭连接;
  引导出错时把没有回复的提问移出上下文
- 未公开的教程只有超管能看,未公开的挑战和素材管理员也能看,其他人 404
- Caddy:/media 加 CSP sandbox 和 nosniff,上传的 html/svg 直接打开不能跑脚本;
  加 CORS,预览 iframe 里的代码可以读取字体和 fetch 素材

前端
- 新增 ReconnectingSocket:意外断开后退避重连,握手失败先刷新 token;
  断线时清掉“生成中”状态,旧连接的事件不再改写新连接
- marked 扩展抽到 utils/markdown.ts:教程用独立实例,不再改全局 marked;
  学生内容的渲染保留高亮,不带会渲染原始 HTML 的 preview 扩展
- 挑战页、创作页加载完成前离开时不再建立连接,离开时关闭引导弹窗
- 教程、提交列表、用户管理跟随浏览器前进后退;快速翻页只用最后一次结果,
  改筛选条件不再请求两次
- 随机评分弹窗在 AI 回复完之后不再弹出

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:33:44 +08:00

168 lines
5.9 KiB
TypeScript

import { createHash, pbkdf2 as pbkdf2Cb, randomBytes, timingSafeEqual } from "node:crypto"
import { promisify } from "node:util"
import { sign, verify } from "hono/jwt"
import type { MiddlewareHandler } from "hono"
import { config } from "./config"
import { get, run, now, toDbTime } from "./db"
import { HttpError, type Ctx, type Env } from "./http"
const pbkdf2 = promisify(pbkdf2Cb)
export const Role = {
SUPER: "super",
ADMIN: "admin",
NORMAL: "normal",
} as const
export type Role = (typeof Role)[keyof typeof Role]
export interface User {
id: number
username: string
role: Role
classname: string
is_active: number
}
// ── 密码:兼容 Django 的 pbkdf2_sha256 格式,老用户无需重置密码 ──
const ITERATIONS = 600_000
export async function makePassword(raw: string): Promise<string> {
const salt = randomBytes(16).toString("base64url").slice(0, 22)
const hash = await pbkdf2(raw, salt, ITERATIONS, 32, "sha256")
return `pbkdf2_sha256$${ITERATIONS}$${salt}$${hash.toString("base64")}`
}
export async function checkPassword(raw: string, encoded: string) {
const [algorithm, iterations, salt, hash] = encoded.split("$")
if (algorithm !== "pbkdf2_sha256" || !iterations || !salt || !hash) {
return false
}
const expected = Buffer.from(hash, "base64")
const actual = await pbkdf2(
raw,
salt,
Number(iterations),
expected.length,
"sha256",
)
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
export async function setPassword(userId: number, raw: string) {
run(
`UPDATE account_user SET password = $password, raw_password = $raw WHERE id = $id`,
{ password: await makePassword(raw), raw: raw.slice(0, 20), id: userId },
)
// 改密码后让该用户的 refresh token 全部失效(与 Django 行为一致)
run(`DELETE FROM auth_refresh_token WHERE user_id = $id`, { id: userId })
}
// ── 会话 ──
export async function authenticate(username: string, password: string) {
const row = get<User & { password: string }>(
`SELECT id, username, role, classname, is_active, password
FROM account_user WHERE username = $username`,
{ username },
)
if (!row || !row.is_active) {
// 防止通过响应时间判断用户是否存在
await makePassword(password)
return null
}
if (!(await checkPassword(password, row.password))) return null
const { password: _, ...user } = row
return user as User
}
// access token 是短期 JWT(只带用户 id,每次请求仍查一次用户,封号立即生效);
// refresh token 是随机串,表 auth_refresh_token 里只存它的 sha256,用来换新的 access token
const hashToken = (token: string) => createHash("sha256").update(token).digest("hex")
function signAccessToken(userId: number) {
const exp = Math.floor(Date.now() / 1000) + config.accessTokenSeconds
return sign({ sub: userId, exp }, config.jwtSecret, "HS256")
}
export async function issueTokens(user: User) {
const refreshToken = randomBytes(32).toString("base64url")
const expire = new Date(Date.now() + config.refreshTokenSeconds * 1000)
run(`DELETE FROM auth_refresh_token WHERE expire_at < $now`, { now: now() })
run(
`INSERT INTO auth_refresh_token (token_hash, user_id, expire_at) VALUES ($hash, $user_id, $expire_at)`,
{ hash: hashToken(refreshToken), user_id: user.id, expire_at: toDbTime(expire) },
)
run(`UPDATE account_user SET last_login = $now WHERE id = $id`, {
now: now(),
id: user.id,
})
return { access_token: await signAccessToken(user.id), refresh_token: refreshToken }
}
export async function refreshAccessToken(refreshToken: string) {
const row = get<{ id: number }>(
`SELECT u.id FROM auth_refresh_token t JOIN account_user u ON u.id = t.user_id
WHERE t.token_hash = $hash AND t.expire_at > $now AND u.is_active = 1`,
{ hash: hashToken(refreshToken), now: now() },
)
if (!row) throw new HttpError(401, "登录已过期,请重新登录")
return signAccessToken(row.id)
}
export function revokeRefreshToken(refreshToken: string) {
run(`DELETE FROM auth_refresh_token WHERE token_hash = $hash`, { hash: hashToken(refreshToken) })
}
export async function userFromAccessToken(token: string | null | undefined): Promise<User | null> {
if (!token) return null
let payload
try {
payload = await verify(token, config.jwtSecret, "HS256")
} catch {
return null
}
return get<User>(
`SELECT id, username, role, classname, is_active FROM account_user
WHERE id = $id AND is_active = 1`,
{ id: Number(payload.sub) },
)
}
export const authMiddleware: MiddlewareHandler<Env> = async (c, next) => {
const header = c.req.header("authorization")
let user: User | null = null
if (header?.startsWith("Bearer ")) {
user = await userFromAccessToken(header.slice("Bearer ".length))
// 带了 token 却无效(过期、被封号)时返回 401,前端据此去刷新 token,而不是当成未登录
if (!user) throw new HttpError(401, "登录已过期")
}
c.set("user", user)
await next()
}
// ── 权限 ──
export function requireUser(c: Ctx): User {
const user = c.get("user")
if (!user) throw new HttpError(401, "请先登录")
return user
}
function requireRoles(c: Ctx, roles: Role[]): User {
const user = requireUser(c)
if (!roles.includes(user.role)) throw new HttpError(403, "没有权限")
return user
}
export const requireAdmin = (c: Ctx) =>
requireRoles(c, [Role.ADMIN, Role.SUPER])
export const requireSuper = (c: Ctx) => requireRoles(c, [Role.SUPER])
export const isStaff = (user: User) =>
user.role === Role.ADMIN || user.role === Role.SUPER
// 未公开的教程只有超管能看,未公开的挑战管理员也能看(和能编辑它们的人一致)
export const canSeeUnpublished = (user: User | null | undefined, kind: "tutorial" | "challenge") =>
!!user && (kind === "tutorial" ? user.role === Role.SUPER : isStaff(user))