refactor(登录): 登录态从 Cookie 换成 access token + refresh token
Deploy / deploy (push) Canceled after 0s

- access token 是 15 分钟的 JWT,走 Authorization: Bearer;refresh token 7 天,每次换都轮换
- 退出登录、改密码、禁用账号仍然立刻生效:每个请求核对 Redis 会话和库里新加的 user.token_version
- 在服务器后台改密码 / 禁用,机房那边登着的也会一起下线(原来两边 Redis 分开,管不到)
- WebSocket 握手借子协议带 token;JWT 密钥存在本站 Redis,部署不用加配置
- 去掉 COOKIE_SECURE;上线后所有人要重新登录一次

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-20 19:22:52 +08:00
1 parent 4be569d978
commit 793848b06f
31 files changed
+4624 -318

No files matched your search

+15 -6
View File
@@ -2,7 +2,7 @@ import { ADMIN_ROLES, TEACHER_ROLES } from "@oj2/contract"
import type { Context, MiddlewareHandler } from "hono"
import { failure } from "../http"
import { getSessionUser, resolveSession, type AuthUser } from "./session"
import { resolveSession, type AuthUser } from "./session"
export interface AppEnv {
Variables: {
@@ -10,8 +10,16 @@ export interface AppEnv {
}
}
/**
* 没带 token 当游客;带了 token 却不能用的**不能**当游客,要回 401 `token-expired`
* 让前端先去换一张。否则 access token 一过期,`/me` 这类可选登录的接口就回「没登录」,
* 前端据此把人当成已登出 —— 每 15 分钟掉一次线。禁用的账号照旧当游客,
* 和原来会话被删之后的表现一致。
*/
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
c.set("user", await getSessionUser(c))
const session = await resolveSession(c)
if (session.reason === "expired") return denied(c, "expired")
c.set("user", session.user)
await next()
}
@@ -25,10 +33,11 @@ export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
* 用 403 而不是 401:凭证是有效的,是这个账号不让用了,和 login 接口对禁用账号
* 的回法(403 `account-disabled`)也一致。
*/
function denied(c: Context, reason: "anonymous" | "disabled") {
return reason === "disabled"
? failure(c, 403, "account-disabled", "账号已被禁用,请联系老师")
: failure(c, 401, "login-required", "请先登录")
function denied(c: Context, reason: "anonymous" | "expired" | "disabled") {
if (reason === "disabled") return failure(c, 403, "account-disabled", "账号已被禁用,请联系老师")
// 前端见到这个码会拿 refresh token 换一张再重试,换不到才当没登录
if (reason === "expired") return failure(c, 401, "token-expired", "登录已过期,请重新登录")
return failure(c, 401, "login-required", "请先登录")
}
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { calendarDay } from "../time"
/**
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
*
* 会话本身判定不了在线:`session:<token>` 的 TTL 是 7 天且每次请求都续期,
* 会话本身判定不了在线:`auth-session:<sid>` 的 TTL 是 7 天且每次请求都续期,
* 「有会话」只说明这人一周内来过。所以这里单独记一个活动时间戳 ——
* 写入一律搭在已有的 pipeline 上(登录、每个带鉴权的请求、WebSocket 巡检),
* 不多一趟往返。
+342 -207
View File
@@ -1,15 +1,17 @@
import { randomBytes } from "node:crypto"
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
import {
toAdminType,
toProblemPermission,
type AdminType,
type AuthTokens,
type ProblemPermission,
WS_AUTH_PROTOCOL,
} from "@oj2/contract"
import { eq } from "drizzle-orm"
import { eq, sql } from "drizzle-orm"
import type { Context } from "hono"
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
import { sign, verify } from "hono/jwt"
import { config } from "../config"
import { db, schema } from "../db"
@@ -17,14 +19,86 @@ import { publishSessionRevoked, type SessionRevokedReason } from "../events"
import { clearOnline, markOnline } from "./presence"
import { redis } from "../redis"
const SESSION_PREFIX = "session:"
const USER_SESSIONS_PREFIX = "user-sessions:"
/**
* 登录态是两张 token:
*
* - **access token**:JWT(HS256),走 `Authorization: Bearer`,15 分钟。
* 载荷是 `{ uid, sid, ver }`:用户 id、会话 id、签发时用户的 token_version。
* - **refresh token**:`<sid>.<随机串>`,不透明,7 天(`SESSION_TTL_SECONDS`),
* 每换一次 access token 就轮换一次,有效期跟着往后顺延 —— 和原来 cookie 会话
* 「七天不来才过期」是同一个口径。
*
* 会话本体仍然在 Redis 的 `auth-session:<sid>` 里(refresh token 的哈希、上次登录时刻),所以这不是「无状态 JWT」:每个请求验完签名还要确认会话还在、
* token_version 没变。原因是这几件事必须**立刻**生效,等 access token 自然过期不行:
*
* - 退出登录 —— 删掉会话,同一张 access token 下一个请求就不认了;
* - 改密码、重置密码、禁用账号 —— 学生密码是明文存着给老师查的,改密码是发现泄露
* 之后唯一的补救手段,晚 15 分钟生效等于给盗号的人留 15 分钟。
*
* 这两道检查本来就在每个请求的路上(原来就要查一次用户表看禁没禁用、一次 Redis
* 续会话),没有多出往返。
*/
const SESSION_PREFIX = "auth-session:"
/**
* 输过的比赛密码,一张会话一个 hash(field = 比赛 id)。不塞进会话本体:
* 会话本体每换一次 token 就整份重写一次,两边一起读改写会互相冲掉。
*/
const CONTEST_PASSWORD_PREFIX = "auth-contest:"
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60
/**
* 换 token 的宽限期。refresh token 每用一次就轮换,而同一个浏览器里几个标签页共用
* localStorage 里的同一张:两个标签页前后脚去换,后到的那个拿着的已经是旧的了。
* 机房走 http,没有安全上下文,`navigator.locks` 用不了,前端没法让它们排队,
* 只能在这里放一手 —— 刚被换掉的上一张在这段时间里仍然能换出 access token。
* 响应丢了(换成功了但前端没收到)也靠它兜住,不然学生会被莫名登出。
*/
const REFRESH_GRACE_MS = 60_000
/**
* JWT 的签名密钥存在本站的 Redis 里,第一次启动时生成。
*
* 不走环境变量:两个站点各有各的 Redis,密钥自然各是各的;部署时不用往两台机器的
* env 文件里各补一行,漏了也不会起不来。要让全站所有人重新登录,删掉这个 key
* 再重启 api 即可。
*/
const JWT_SECRET_KEY = "auth:jwt-secret"
let jwtSecret: string | null = null
export async function loadJwtSecret() {
await redis.set(JWT_SECRET_KEY, randomBytes(48).toString("base64url"), "NX")
jwtSecret = await redis.get(JWT_SECRET_KEY)
if (!jwtSecret) throw new Error("JWT 密钥没能写进 Redis")
}
function secret() {
if (!jwtSecret) throw new Error("JWT 密钥还没加载,入口要先 await loadJwtSecret()")
return jwtSecret
}
interface StoredSession {
userId: number
/**
* 登录那一刻用户的 token_version。换 token 时和库里比,对不上就不给换 ——
* 不然改完密码,旧会话拿 refresh token 照样能换出一张带新版本号的 access token
*/
tokenVersion: number
/** 当前那张 refresh token 随机部分的 sha256 */
refreshHash: string
/** 上一张,宽限期内仍然认,见 REFRESH_GRACE_MS */
previousRefreshHash: string | null
/** 上一次轮换的毫秒时刻 */
rotatedAt: number
createdAt: string
previousLogin: string | null
contestPasswords: Record<string, string>
}
interface AccessClaims {
uid: number
sid: string
ver: number
}
/**
@@ -42,131 +116,61 @@ export interface AuthUser {
className: string | null
}
function sessionKey(token: string) {
return `${SESSION_PREFIX}${token}`
function sessionKey(sid: string) {
return `${SESSION_PREFIX}${sid}`
}
/**
* 某个用户名下所有还活着的会话 token。
*
* 会话本体是 `session:<token>`,里面记着 userId —— 从 token 找人很快,从人找 token
* 却只能 SCAN 整个 Redis。改密码、重置密码、禁用账号这三件事都要求「把这个人所有
* 设备上的会话立刻作废」,所以额外维护这张反向索引。
*
* **它是索引,不是真相**:成员可能指向已经过期的 token(集合成员没有各自的 TTL),
* 吊销时按成员逐个 DEL 即可,删到不存在的 key 没有代价。集合自己跟着会话续期,
* 整个人不活动满一个 TTL 之后自然消失。
*/
function userSessionsKey(userId: number) {
return `${USER_SESSIONS_PREFIX}${userId}`
function hashSecret(value: string) {
return createHash("sha256").update(value).digest("hex")
}
export async function createSession(
c: Context,
userId: number,
previousLogin: string | null = null,
) {
const token = randomBytes(32).toString("base64url")
const value: StoredSession = {
userId,
createdAt: new Date().toISOString(),
previousLogin,
contestPasswords: {},
}
// 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别
// 全压在登录这一下上
const pipeline = redis
.pipeline()
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
.sadd(userSessionsKey(userId), token)
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
markOnline(pipeline, userId)
await pipeline.exec()
setCookie(c, config.sessionCookie, token, {
httpOnly: true,
sameSite: "Lax",
secure: config.secureCookies,
path: "/",
maxAge: config.sessionTtlSeconds,
})
function sameHash(a: string, b: string | null) {
if (b === null || a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
/** 返回被删掉的 token:调用方要拿它去广播会话吊销,好断掉同一浏览器里其他标签页的连接 */
export async function destroySession(c: Context) {
const token = getCookie(c, config.sessionCookie)
if (token) {
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
const userId = await sessionUserId(token)
await redis.del(sessionKey(token))
if (userId !== null) {
await redis.srem(userSessionsKey(userId), token)
await clearOnline(userId)
}
}
deleteCookie(c, config.sessionCookie, { path: "/" })
return token ?? null
/** `<sid>.<随机串>` 拆开。格式不对返回 null */
function splitRefreshToken(token: string) {
const dot = token.indexOf(".")
if (dot <= 0 || dot === token.length - 1) return null
return { sid: token.slice(0, dot), secretHash: hashSecret(token.slice(dot + 1)) }
}
async function sessionUserId(token: string) {
const raw = await redis.get(sessionKey(token))
async function readSession(sid: string) {
const raw = await redis.get(sessionKey(sid))
if (!raw) return null
try {
const value = JSON.parse(raw) as StoredSession
return Number.isInteger(value.userId) ? value.userId : null
value.previousLogin ??= null
return value
} catch {
await redis.del(sessionKey(sid))
return null
}
}
function writeSession(sid: string, value: StoredSession) {
return redis.set(sessionKey(sid), JSON.stringify(value), "EX", config.sessionTtlSeconds)
}
async function signAccessToken(claims: AccessClaims) {
const now = Math.floor(Date.now() / 1000)
return sign({ ...claims, iat: now, exp: now + ACCESS_TOKEN_TTL_SECONDS }, secret(), "HS256")
}
/** 验签名和有效期,载荷形状不对也当无效。不碰 Redis 和库 */
async function verifyAccessToken(token: string): Promise<AccessClaims | null> {
try {
const payload = await verify(token, secret(), "HS256")
const { uid, sid, ver } = payload
if (!Number.isInteger(uid) || typeof sid !== "string" || !Number.isInteger(ver)) return null
return { uid: uid as number, sid, ver: ver as number }
} catch {
return null
}
}
/**
* 把一个用户所有设备上的会话真的删掉,并广播给还挂着的 WebSocket。
*
* 光广播是不够的:`publishSessionRevoked` 只断 WebSocket,HTTP 请求照样能拿着
* 那张 cookie 继续用。改密码之后旧密码登出来的会话必须立刻失效,否则「改密码」
* 对已经被别人登着的账号毫无作用 —— 而学生密码是明文存着给老师查的,
* 改密码正是发现密码泄露之后唯一的补救手段。
*/
export async function revokeUserSessions(userId: number, reason: SessionRevokedReason) {
const tokens = await redis.smembers(userSessionsKey(userId))
if (tokens.length) await redis.del(...tokens.map(sessionKey))
await redis.del(userSessionsKey(userId))
await clearOnline(userId)
await publishSessionRevoked({ userId }, reason)
return tokens.length
}
function readCookie(request: Request, name: string) {
const header = request.headers.get("cookie")
if (!header) return undefined
for (const part of header.split(";")) {
const [key, ...value] = part.trim().split("=")
if (key === name) return decodeURIComponent(value.join("="))
}
return undefined
}
/**
* 会话解析结果。之所以不只返回 `AuthUser | null`:拿不到用户有两种原因,
* 而它们对应完全不同的前端行为 —— 未登录该弹登录框,已禁用该说「账号已禁用」。
*/
export type SessionResult =
| { user: AuthUser; reason?: undefined }
| { user: null; reason: "anonymous" | "disabled" }
async function authenticateToken(token: string | undefined): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" }
const raw = await redis.get(sessionKey(token))
if (!raw) return { user: null, reason: "anonymous" }
let session: StoredSession
try {
session = JSON.parse(raw) as StoredSession
} catch {
await redis.del(sessionKey(token))
return { user: null, reason: "anonymous" }
}
async function selectUser(userId: number) {
const [user] = await db
.select({
id: schema.user.id,
@@ -176,133 +180,264 @@ async function authenticateToken(token: string | undefined): Promise<SessionResu
problemPermission: schema.user.problemPermission,
isDisabled: schema.user.isDisabled,
className: schema.user.className,
tokenVersion: schema.user.tokenVersion,
})
.from(schema.user)
.where(eq(schema.user.id, session.userId))
.where(eq(schema.user.id, userId))
.limit(1)
if (!user) {
await redis.del(sessionKey(token))
await redis.srem(userSessionsKey(session.userId), token)
return { user: null, reason: "anonymous" }
return user ?? null
}
if (user.isDisabled) {
// 会话照删(禁用要立即生效),但要把「是被禁用」这件事告诉调用方。
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
await redis.del(sessionKey(token))
await redis.srem(userSessionsKey(session.userId), token)
return { user: null, reason: "disabled" }
export async function issueTokens(
userId: number,
tokenVersion: number,
previousLogin: string | null = null,
): Promise<AuthTokens> {
const sid = randomBytes(16).toString("base64url")
const refreshSecret = randomBytes(32).toString("base64url")
const value: StoredSession = {
userId,
tokenVersion,
refreshHash: hashSecret(refreshSecret),
previousRefreshHash: null,
rotatedAt: Date.now(),
createdAt: new Date().toISOString(),
previousLogin,
}
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
// 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
// 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
const renew = redis
// 两条写进一个 pipeline:一个班四十号人同时登录时,两趟往返和一趟的差别
// 全压在登录这一下上
const pipeline = redis
.pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
// 在线状态就是搭在这条 pipeline 上记的,见 presence.ts
markOnline(renew, session.userId)
await renew.exec()
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
.set(sessionKey(sid), JSON.stringify(value), "EX", config.sessionTtlSeconds)
markOnline(pipeline, userId)
await pipeline.exec()
return {
user: {
...user,
adminType: toAdminType(user.adminType),
problemPermission: toProblemPermission(user.problemPermission),
accessToken: await signAccessToken({ uid: userId, sid, ver: tokenVersion }),
refreshToken: `${sid}.${refreshSecret}`,
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
}
}
export type RefreshResult =
| { tokens: AuthTokens; reason?: undefined }
| { tokens: null; reason: "invalid" | "disabled" }
/**
* 拿 refresh token 换一张新的 access token,同时轮换 refresh token。
*
* 认不出来的(伪造的、已经轮换过且出了宽限期的)只是拒掉,**不**顺手删会话:
* 「旧 token 被重放 = 被盗,整个会话作废」是教科书做法,可这里旧 token 被重放
* 更常见的原因是网络慢、两个标签页撞车,为此把学生踢下线代价太大。
*/
export async function refreshTokens(refreshToken: string): Promise<RefreshResult> {
const parts = splitRefreshToken(refreshToken)
if (!parts) return { tokens: null, reason: "invalid" }
const session = await readSession(parts.sid)
if (!session) return { tokens: null, reason: "invalid" }
const current = sameHash(parts.secretHash, session.refreshHash)
const inGrace =
!current &&
sameHash(parts.secretHash, session.previousRefreshHash) &&
Date.now() - session.rotatedAt < REFRESH_GRACE_MS
if (!current && !inGrace) return { tokens: null, reason: "invalid" }
const user = await selectUser(session.userId)
if (!user || user.tokenVersion !== session.tokenVersion) {
await redis.del(sessionKey(parts.sid))
return { tokens: null, reason: "invalid" }
}
if (user.isDisabled) {
await redis.del(sessionKey(parts.sid))
return { tokens: null, reason: "disabled" }
}
const accessToken = await signAccessToken({
uid: user.id,
sid: parts.sid,
ver: user.tokenVersion,
})
// 宽限期里只给 access token:当前那张 refresh token 已经发给先到的那个标签页了,
// 明文这里没有,前端沿用 localStorage 里那张
if (inGrace)
return { tokens: { accessToken, refreshToken: null, expiresIn: ACCESS_TOKEN_TTL_SECONDS } }
const refreshSecret = randomBytes(32).toString("base64url")
await writeSession(parts.sid, {
...session,
refreshHash: hashSecret(refreshSecret),
previousRefreshHash: session.refreshHash,
rotatedAt: Date.now(),
})
return {
tokens: {
accessToken,
refreshToken: `${parts.sid}.${refreshSecret}`,
expiresIn: ACCESS_TOKEN_TTL_SECONDS,
},
}
}
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
/**
* 退出登录:删掉 refresh token 对应的那张会话。返回会话 id,调用方要拿它去广播吊销,
* 好断掉同一浏览器里其他标签页的 WebSocket。token 不对(伪造、已过期)返回 null。
*
* 要核对随机部分,不能只认 sid:sid 就写在 access token 的载荷里,谁都能解出来。
*/
export async function destroySession(refreshToken: string) {
const parts = splitRefreshToken(refreshToken)
if (!parts) return null
const session = await readSession(parts.sid)
if (!session) return null
if (
!sameHash(parts.secretHash, session.refreshHash) &&
!sameHash(parts.secretHash, session.previousRefreshHash)
) {
return null
}
await redis.del(sessionKey(parts.sid), `${CONTEST_PASSWORD_PREFIX}${parts.sid}`)
await clearOnline(session.userId)
return parts.sid
}
/**
* 让一个用户所有设备上的登录立刻失效,并广播给还挂着的 WebSocket。
*
* 做法是把库里的 token_version 加一:每个请求、每次换 token 都会拿它和 access token
* 里签的 `ver` 比,对不上就不认。不去 Redis 里挨个删会话 —— 删得掉的只有本站的,
* 另一个站点的会话在它自己的 Redis 里。
*
* 光广播是不够的:`publishSessionRevoked` 只断 WebSocket,HTTP 请求照样能拿着
* 那张 token 继续用。改密码之后旧密码登出来的会话必须立刻失效,否则「改密码」
* 对已经被别人登着的账号毫无作用 —— 而学生密码是明文存着给老师查的,
* 改密码正是发现密码泄露之后唯一的补救手段。
*/
export async function revokeUserSessions(userId: number, reason: SessionRevokedReason) {
await db
.update(schema.user)
.set({ tokenVersion: sql`${schema.user.tokenVersion} + 1` })
.where(eq(schema.user.id, userId))
await clearOnline(userId)
await publishSessionRevoked({ userId }, reason)
}
/**
* 会话解析结果。之所以不只返回 `AuthUser | null`:拿不到用户有三种原因,
* 而它们对应完全不同的前端行为 ——
*
* - `anonymous`:根本没带 token,该当游客就当游客;
* - `expired`:带了 token 但不能用(过期、被吊销、伪造),前端该先拿 refresh token
* 换一张再重试,换不到才算没登录;
* - `disabled`:账号被禁用,该说「账号已禁用」,不能弹登录框。
*/
export type SessionResult =
| { user: AuthUser; sid: string; tokenVersion: number; reason?: undefined }
| { user: null; reason: "anonymous" | "expired" | "disabled" }
async function authenticateToken(token: string | null): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" }
const claims = await verifyAccessToken(token)
if (!claims) return { user: null, reason: "expired" }
// 会话还在不在、用户还在不在,两件事并行问。EXPIRE 顺手给会话续期:
// 不发 refresh 的请求也该算活动
const [alive, user] = await Promise.all([
redis.expire(sessionKey(claims.sid), config.sessionTtlSeconds),
selectUser(claims.uid),
])
if (alive !== 1 || !user || user.tokenVersion !== claims.ver) {
return { user: null, reason: "expired" }
}
if (user.isDisabled) return { user: null, reason: "disabled" }
// 在线状态就是搭在每个带鉴权的请求上记的,见 presence.ts
const online = redis.pipeline()
markOnline(online, user.id)
await online.exec()
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
const { tokenVersion, ...rest } = user
return {
user: {
...rest,
adminType: toAdminType(user.adminType),
problemPermission: toProblemPermission(user.problemPermission),
},
sid: claims.sid,
tokenVersion,
}
}
function bearerToken(header: string | null | undefined) {
if (!header) return null
const match = /^Bearer\s+(\S+)$/i.exec(header)
return match?.[1] ?? null
}
/** 中间件用这个:要区分「没登录」「token 不能用」「已被禁用」 */
export function resolveSession(c: Context) {
return authenticateToken(getCookie(c, config.sessionCookie))
return authenticateToken(bearerToken(c.req.header("authorization")))
}
/** 只关心「是谁」的调用方用这个 */
export async function getSessionUser(c: Context) {
return (await resolveSession(c)).user
}
export async function getRequestSessionUser(request: Request) {
return (await authenticateToken(readCookie(request, config.sessionCookie))).user
/** WebSocket 握手:access token 借子协议带过来,见契约里 WS_AUTH_PROTOCOL 的注释 */
export async function authenticateWebSocket(request: Request) {
const protocols = (request.headers.get("sec-websocket-protocol") ?? "")
.split(",")
.map((value) => value.trim())
if (protocols[0] !== WS_AUTH_PROTOCOL) return null
const result = await authenticateToken(protocols[1] || null)
return result.user ? result : null
}
/**
* WebSocket 升级时把 token 一起存进连接,之后才能定期确认这个会话还有效
* —— 握手时校验过一次,可这条连接能挂上好几个小时。
* 会话还在就续期、记一笔在线并返回 true,已登出或已过期返回 false。
*
* WebSocket 用:握手时校验过一次,可这条连接能挂上好几个小时。只开着页面挂 WebSocket
* 的人一次请求都不发,不该因此被算成不活跃踢下线,所以这里也续期。
*
* 只查 Redis 不查库:每条订阅消息都要过一遍,热路径。token_version 和禁用由
* websocket.ts 的 sweepSessions 每 60 秒统一对一次库。
*/
export function readRequestSessionToken(request: Request) {
return readCookie(request, config.sessionCookie) ?? ""
}
/**
* 会话还在就续期并返回 true,已登出或已过期返回 false。
*
* 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE
* 走一次 pipeline,仍然只有一趟。
*
* 续期这件事本身是要的:HTTP 请求会走 authenticateToken 里的 redis.expire 续期,
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
*
* **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接,
* 它碰不到 authenticateToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、
* 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS
* 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉,
* 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。
*/
export async function touchSession(token: string, userId: number) {
if (!token) return false
const pipeline = redis
.pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
export async function touchSession(sid: string, userId: number) {
if (!sid) return false
const pipeline = redis.pipeline().expire(sessionKey(sid), config.sessionTtlSeconds)
// 只挂着 WebSocket 不发请求的人,在线状态全靠这里(sweepSessions 每 60 秒一轮)
markOnline(pipeline, userId)
const results = await pipeline.exec()
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
// 续不到很正常,不能因此判定会话已死
return results?.[0]?.[1] === 1
}
async function getStoredSession(c: Context) {
const token = getCookie(c, config.sessionCookie)
if (!token) return null
const raw = await redis.get(sessionKey(token))
if (!raw) return null
try {
const value = JSON.parse(raw) as StoredSession
value.contestPasswords ??= {}
value.previousLogin ??= null
return { token, value }
} catch {
return null
}
/** 当前请求那张 access token 的载荷,验不过返回 null */
async function requestClaims(c: Context) {
const token = bearerToken(c.req.header("authorization"))
return token ? verifyAccessToken(token) : null
}
/**
* 比赛密码、上次登录时刻这几个读写点不一定挂了鉴权中间件,所以自己再验一遍
* access token(只是一次 HMAC,不碰库)。
*/
export async function setContestPassword(c: Context, contestId: number, password: string) {
const session = await getStoredSession(c)
if (!session) return false
session.value.contestPasswords[String(contestId)] = password
await redis.set(
sessionKey(session.token),
JSON.stringify(session.value),
"EX",
config.sessionTtlSeconds,
)
const claims = await requestClaims(c)
if (!claims) return false
const key = `${CONTEST_PASSWORD_PREFIX}${claims.sid}`
await redis
.pipeline()
.hset(key, String(contestId), password)
.expire(key, config.sessionTtlSeconds)
.exec()
return true
}
export async function getContestPassword(c: Context, contestId: number) {
const session = await getStoredSession(c)
return session?.value.contestPasswords[String(contestId)] ?? null
const claims = await requestClaims(c)
if (!claims) return null
return redis.hget(`${CONTEST_PASSWORD_PREFIX}${claims.sid}`, String(contestId))
}
/** 上次登录、这次登录的时刻 —— 首页「上次来」卡的窗口就是这两个之间 */
export async function getLoginWindow(c: Context) {
const session = await getStoredSession(c)
const claims = await requestClaims(c)
const session = claims && (await readSession(claims.sid))
if (!session) return null
return { previousLogin: session.value.previousLogin, loginAt: session.value.createdAt }
return { previousLogin: session.previousLogin, loginAt: session.createdAt }
}
+1 -1
View File
@@ -222,7 +222,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
}
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
if (!(await touchSession(ws.data.token, ws.data.userId))) {
if (!(await touchSession(ws.data.sid, ws.data.userId))) {
ws.close(1008, "Session expired")
return
}
+1 -3
View File
@@ -66,7 +66,6 @@ const envSchema = z.object({
),
REDIS_URL: requiredWhenCompiled("redis://localhost:6380"),
SESSION_TTL_SECONDS: positiveInt(7 * 24 * 60 * 60),
COOKIE_SECURE: z.preprocess(blankAsUnset, z.enum(["true", "false"]).optional()),
JUDGE_SERVER_URL: text("http://localhost:8081"),
// dev 不设时生成一次性随机值,见 judgeServerToken()
JUDGE_SERVER_TOKEN: isCompiled
@@ -138,9 +137,8 @@ export const config = {
port: env.PORT,
databaseUrl: env.DATABASE_URL,
redisUrl: env.REDIS_URL,
sessionCookie: "oj2_session",
/** refresh token 的有效期,每换一次 access token 往后顺延,见 auth/session.ts */
sessionTtlSeconds: env.SESSION_TTL_SECONDS,
secureCookies: env.COOKIE_SECURE === "true",
judgeServerUrl: env.JUDGE_SERVER_URL,
judgeServerToken: judgeServerToken(),
judgeConcurrency: env.JUDGE_CONCURRENCY,
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -260,6 +260,13 @@
"when": 1792415463343,
"tag": "0036_fix_flowchart_criteria_numbers",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1792494707274,
"tag": "0037_add_user_token_version",
"breakpoints": true
}
]
}
+8
View File
@@ -1246,6 +1246,14 @@ export const user = pgTable(
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
*/
rankHiddenAt: timestamp("rank_hidden_at", { withTimezone: true, mode: "string" }),
/**
* 签进 access token 的版本号。改密码、重置密码、禁用账号时加一,这个人所有设备上
* 签出去的 token 立刻作废(见 auth/session.ts 的 revokeUserSessions)。
*
* 放在库里而不是 Redis:机房和服务器共用这个库、各有各的 Redis,
* 在一边改了密码,另一边登着的也得一起下线。
*/
tokenVersion: integer("token_version").default(0).notNull(),
},
(table) => [
unique("user_username_key").on(table.username),
+6 -6
View File
@@ -20,23 +20,23 @@ export async function publishConfigUpdate(key: string, value: unknown) {
* 会话吊销广播。让还挂着的 WebSocket 立刻知道自己该下线了。
*
* 两种作用域,**不能混**:
* - `{ token }` 用户登出。只该断这一张会话 —— 同一个人在别的设备上是另一张会话,
* - `{ sid }` 用户登出。只该断这一张会话 —— 同一个人在别的设备上是另一张会话,
* 不该被牵连。
* - `{ userId }` 账号被禁用。所有设备都得断。禁用只改数据库的 isDisabled 列、
* 不动 Redis 里的会话,WebSocket 那边的会话巡检永远发现不了,只能靠这条主动通知。
* - `{ userId }` 账号被禁用。所有设备都得断。改密码同理。这两件事只改库、
* 不动 Redis 里的会话,WebSocket 那边要等最多 60 秒的巡检才发现,靠这条立刻通知。
*/
export const sessionRevokedChannel = "session:revoked"
export type SessionRevokedReason = "session-ended" | "account-disabled"
export interface SessionRevoked {
token?: string
sid?: string
userId?: number
reason: SessionRevokedReason
}
export async function publishSessionRevoked(
target: { token: string } | { userId: number },
target: { sid: string } | { userId: number },
reason: SessionRevokedReason,
) {
await redis.publish(sessionRevokedChannel, JSON.stringify({ ...target, reason }))
@@ -45,7 +45,7 @@ export async function publishSessionRevoked(
export function parseSessionRevoked(raw: string): SessionRevoked | null {
try {
const value = JSON.parse(raw) as SessionRevoked
if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null
if (typeof value.sid !== "string" && !Number.isInteger(value.userId)) return null
if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null
return value
} catch {
+12 -4
View File
@@ -1,7 +1,9 @@
import { Hono } from "hono"
import { basename, resolve } from "node:path"
import { getRequestSessionUser, readRequestSessionToken } from "./auth/session"
import { WS_AUTH_PROTOCOL } from "@oj2/contract"
import { authenticateWebSocket, loadJwtSecret } from "./auth/session"
import { config } from "./config"
import { adminRoutes } from "./routes/admin"
import { authRoutes } from "./routes/auth"
@@ -88,6 +90,8 @@ async function serveUpload(pathname: string, prefix: string, directory: string)
return null
}
await loadJwtSecret()
const server = Bun.serve<SubmissionSocketData>({
port: config.port,
// Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默
@@ -126,8 +130,9 @@ const server = Bun.serve<SubmissionSocketData>({
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
return new Response("Forbidden", { status: 403 })
}
const user = await getRequestSessionUser(request)
if (!user) return new Response("Unauthorized", { status: 401 })
const session = await authenticateWebSocket(request)
if (!session) return new Response("Unauthorized", { status: 401 })
const { user } = session
const kind =
url.pathname === "/ws/config"
? "config"
@@ -136,10 +141,13 @@ const server = Bun.serve<SubmissionSocketData>({
: "submissions"
if (
bunServer.upgrade(request, {
// 前端是借子协议带 token 来的,不回一个它传过的子协议 Chrome 就判握手失败
headers: { "Sec-WebSocket-Protocol": WS_AUTH_PROTOCOL },
data: {
userId: user.id,
kind,
token: readRequestSessionToken(request),
sid: session.sid,
tokenVersion: session.tokenVersion,
username: user.username,
adminType: user.adminType,
},
+1 -1
View File
@@ -127,7 +127,7 @@ accountRoutes.post("/users", async (c) => {
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// 对齐旧后端 account/views/oj.py 的 UserProfileAPI.get 首行:
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经排名接口(/rankings/board)公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
// 否则用户名可经排名接口(/rankings/board)公开枚举,进而不登录就批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null)
const [target] = await db
.select({ id: schema.user.id })
+26 -8
View File
@@ -1,9 +1,9 @@
import { loginRequestSchema } from "@oj2/contract"
import { loginRequestSchema, refreshTokenRequestSchema, type AuthTokens } from "@oj2/contract"
import { eq, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, type AppEnv } from "../auth/middleware"
import { createSession, destroySession } from "../auth/session"
import { destroySession, issueTokens, refreshTokens } from "../auth/session"
import { publishSessionRevoked } from "../events"
import { hashPassword, verifyPassword } from "../auth/password"
import { db, schema } from "../db"
@@ -79,17 +79,35 @@ authRoutes.post("/auth/login", async (c) => {
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)
const tokens = await issueTokens(user.id, user.tokenVersion, user.lastLogin)
return success(c, { ok: true })
return success(c, tokens satisfies AuthTokens)
})
authRoutes.delete("/auth/session", async (c) => {
const token = await destroySession(c)
authRoutes.post("/auth/refresh", async (c) => {
const parsed = await parseBody(c, refreshTokenRequestSchema)
if (!parsed.success) return parsed.response
const result = await refreshTokens(parsed.data.refreshToken)
if (result.reason === "disabled") {
return failure(c, 403, "account-disabled", "账号已被禁用,请联系老师")
}
// 不回 login-required:前端拦截器见到它就弹登录框,而换不到 token 的那一刻
// 前端还要先拿掉 token 重试一次原请求,可选登录的页面照样能当游客看
if (!result.tokens) {
return failure(c, 401, "refresh-invalid", "登录已过期,请重新登录")
}
return success(c, result.tokens satisfies AuthTokens)
})
/** 退出登录。凭 refresh token 而不是 access token:后者可能刚好过期,退出不该因此失败 */
authRoutes.post("/auth/logout", async (c) => {
const parsed = await parseBody(c, refreshTokenRequestSchema)
if (!parsed.success) return parsed.response
const sid = await destroySession(parsed.data.refreshToken)
// 同一个浏览器的其他标签页还挂着 WebSocket,页面上仍显示着登录态。推一条让它们
// 立刻清掉,不用等最多 60 秒的会话巡检。
// 按 token 而不是按用户:这个人在别的设备上的登录是另一张会话,不该被牵连。
if (token) await publishSessionRevoked({ token }, "session-ended")
// 按会话而不是按用户:这个人在别的设备上的登录是另一张会话,不该被牵连。
if (sid) await publishSessionRevoked({ sid }, "session-ended")
return success(c, null)
})
+50 -29
View File
@@ -1,5 +1,5 @@
import { submissionUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
import { and, eq } from "drizzle-orm"
import { and, eq, inArray } from "drizzle-orm"
import { touchSession } from "./auth/session"
import {
@@ -31,11 +31,11 @@ const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
/**
* WebSocket 升级的来源校验。
*
* 会话 cookie 是 SameSite=Lax,而 WebSocket 握手不是导航,跨站页面本来就带不上
* 这个 cookie —— 所以这里是防御纵深,不是唯一防线。
* 握手要带 access token(子协议),跨站页面拿不到别人 localStorage 里的 token,
* 所以这里是防御纵深,不是唯一防线。
*
* 不发 Origin 的一律放行:真正的攻击面是「带着受害者 cookie 的浏览器页面」,
* 而浏览器一定会带 Origin;脚本客户端本来就能伪造任意请求头,拦它没有意义。
* 不发 Origin 的一律放行:浏览器一定会带 Origin;脚本客户端本来就能伪造任意请求头,
* 拦它没有意义。
*/
export function isAllowedWebSocketOrigin(origin: string | null, url: URL) {
if (!origin) return true
@@ -60,8 +60,10 @@ export interface SubmissionSocketData {
userId: number
/** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分通道 */
kind: "submissions" | "config" | "collab"
/** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */
token: string
/** 握手时那张会话的 id,留着定期确认它还没被登出 / 过期,见 sweepSessions */
sid: string
/** 握手时 access token 里签的 token_version,巡检时和库里比,见 sweepSessions */
tokenVersion: number
/** 文本控制帧的令牌桶,open 时初始化,见 allowMessage */
rate?: RateBucket
/**
@@ -156,37 +158,56 @@ function forceLogout(targets: Bun.ServerWebSocket<SubmissionSocketData>[], reaso
/**
* 定期把会话已经失效的连接断掉。
*
* 握手时校验过一次会话,但这条连接能挂几个小时 —— 期间用户可能在别的标签页登出,
* 或者会话本身到期。只靠消息触发的校验不够:一条连接完全可能除了心跳什么都不发,
* 而心跳是**故意**不查会话的(否则每客户端每 30 秒一趟 Redis 又回来了)。
* 握手时校验过一次,但这条连接能挂几个小时 —— 期间用户可能在别的标签页登出,
* 或者会话本身到期,或者在另一个站点被改了密码、禁用了。只靠消息触发的校验不够:
* 一条连接完全可能除了心跳什么都不发,而心跳是**故意**不查会话的
* (否则每客户端每 30 秒一趟 Redis 又回来了)。
*
* 注意这里不查 isDisabled:管理员禁用只改数据库列、不删会话,所以 token 校验
* 覆盖不到它。禁用由推送路径上的 bridgeSubmissionEvents 挡着 —— 被禁用的学生
* 收不到任何数据,socket 还挂着只是根空管子。
* 两步:Redis 里会话还在不在(顺带续期、记在线),库里 token_version 变没变、
* 禁没禁用。后者一轮只打一条 IN 查询。本站改密码、禁用会走 publishSessionRevoked
* 立刻断,这里兜的是另一个站点做的 —— 两边共用库、不共用 Redis 的 pub/sub。
*/
export async function sweepSessions() {
// 一个学生至少有配置和提交两条通道,多开几个标签页还会更多,而它们共用同一张
// 会话 —— 一轮里同一个 token 只查一次
// 会话 —— 一轮里同一个会话只查一次
const checked = new Map<string, boolean>()
const dead: Bun.ServerWebSocket<SubmissionSocketData>[] = []
for (const ws of liveSockets) {
const token = ws.data.token
let alive = checked.get(token)
if (alive === undefined) {
const sockets = [...liveSockets]
let users: { id: number; tokenVersion: number; isDisabled: boolean }[]
try {
alive = await touchSession(token, ws.data.userId)
for (const ws of sockets) {
if (checked.has(ws.data.sid)) continue
checked.set(ws.data.sid, await touchSession(ws.data.sid, ws.data.userId))
}
const userIds = [...new Set(sockets.map((ws) => ws.data.userId))]
users = userIds.length
? await db
.select({
id: schema.user.id,
tokenVersion: schema.user.tokenVersion,
isDisabled: schema.user.isDisabled,
})
.from(schema.user)
.where(inArray(schema.user.id, userIds))
: []
} catch (error) {
// Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说
// Redis 或库抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说
console.error("Failed to verify websocket sessions", error)
return
}
checked.set(token, alive)
const byId = new Map(users.map((user) => [user.id, user]))
const ended: Bun.ServerWebSocket<SubmissionSocketData>[] = []
const disabled: Bun.ServerWebSocket<SubmissionSocketData>[] = []
for (const ws of sockets) {
const user = byId.get(ws.data.userId)
if (user?.isDisabled) disabled.push(ws)
else if (!checked.get(ws.data.sid) || user?.tokenVersion !== ws.data.tokenVersion) {
ended.push(ws)
}
if (!alive) dead.push(ws)
}
// 会话没了有两种可能:在别的标签页登出了,或者会话自己到期。对用户都是
// 会话没了有几种可能:在别的标签页登出、会话自己到期、改了密码。对用户都是
// 「要重新登录」,走 session-ended 这一支
forceLogout(dead, "session-ended")
forceLogout(ended, "session-ended")
forceLogout(disabled, "account-disabled")
}
export function startSessionSweep() {
@@ -289,7 +310,7 @@ async function handleMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>, raw:
// 会话可能在连接期间就失效了:用户在别的标签页登出,或者会话自己到期。
// 握手时校验过一次不算数 —— 这条连接能挂几个小时。
if (!(await touchSession(ws.data.token, ws.data.userId))) {
if (!(await touchSession(ws.data.sid, ws.data.userId))) {
ws.close(1008, "Session expired")
return
}
@@ -390,11 +411,11 @@ export async function bridgeSubmissionEvents(server: Bun.Server<SubmissionSocket
if (channel === sessionRevokedChannel) {
const revoked = parseSessionRevoked(raw)
if (!revoked) return
// 按 token 还是按 userId,取决于是「这张会话登出了」还是「这个账号被禁用了」
// 按会话还是按 userId,取决于是「这张会话登出了」还是「这个账号被禁用 / 改密码了」
forceLogout(
[...liveSockets].filter((ws) =>
revoked.token !== undefined
? ws.data.token === revoked.token
revoked.sid !== undefined
? ws.data.sid === revoked.sid
: ws.data.userId === revoked.userId,
),
revoked.reason,
+8 -4
View File
@@ -95,10 +95,14 @@ shared → ./src/shared
### HTTP Client
`utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`,
`withCredentials`). It unwraps both the axios envelope and the backend's
`{ data }` envelope, so callers get the payload directly. All API calls proxy
through the dev server (see `vite.config.ts`).
`utils/api.ts` — Axios instance with interceptors (`baseURL: "/api"`). It unwraps both
the axios envelope and the backend's `{ data }` envelope, so callers get the payload
directly. All API calls proxy through the dev server (see `vite.config.ts`).
登录态是 `Authorization: Bearer` 的 access token(15 分钟)+ refresh token,都在
localStorage,换 token 的逻辑全在 `utils/auth.ts`。**不走 axios 的请求用 `authFetch`
代替裸 `fetch`**,否则不带 token、也不会在 `token-expired` 时换一张重试。
WebSocket 的 token 借子协议带(浏览器设不了请求头),见契约 `WS_AUTH_PROTOCOL`。
### Contract guard (`utils/contract.ts`)
+3 -4
View File
@@ -60,6 +60,7 @@ import {
type TodaySubmissionStatistics,
} from "@oj2/contract"
import api from "utils/api"
import { authFetch } from "utils/auth"
import { contract } from "utils/contract"
import { toProblemRow } from "utils/transforms"
import type {
@@ -532,9 +533,8 @@ export function reportExerciseAttempt(
exerciseId: number,
payload: { correct: boolean; answer?: string },
) {
return fetch(`/api/exercises/${exerciseId}/attempts`, {
return authFetch(`/api/exercises/${exerciseId}/attempts`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -559,9 +559,8 @@ export function reportLearnProgress(
tutorialId: number,
payload: { seconds: number; opened: boolean },
) {
return fetch(`/api/tutorials/${tutorialId}/progress`, {
return authFetch(`/api/tutorials/${tutorialId}/progress`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
+8 -5
View File
@@ -1,4 +1,4 @@
import type { LoginRequest, RegisterRequest } from "@oj2/contract"
import type { AuthTokens, LoginRequest, RefreshTokenRequest, RegisterRequest } from "@oj2/contract"
import type {
ClassActivity,
MarkAchievementsRead,
@@ -8,20 +8,23 @@ import type {
WebsiteConfig,
} from "@oj2/contract"
import api from "utils/api"
import { getRefreshToken, saveTokens } from "utils/auth"
import { contractDeferred } from "utils/contract"
import { toProblemRow } from "utils/transforms"
import type { PendingAchievement, Profile, Tag } from "utils/types"
export function login(data: { username: string; password: string }) {
return api.post("auth/login", data satisfies LoginRequest)
export async function login(data: { username: string; password: string }) {
saveTokens(await api.post<AuthTokens>("auth/login", data satisfies LoginRequest))
}
export function signup(data: { username: string; email: string; password: string }) {
return api.post("users", data satisfies RegisterRequest)
}
export function logout() {
return api.delete("auth/session")
/** 吊销服务端那张会话。本地的 token 由调用方清(user store 的 clearProfile) */
export async function logout() {
const refreshToken = getRefreshToken()
if (refreshToken) await api.post("auth/logout", { refreshToken } satisfies RefreshTokenRequest)
}
export async function getProfile(username: string = ""): Promise<Profile | null> {
+22 -2
View File
@@ -1,8 +1,10 @@
import { WS_AUTH_PROTOCOL } from "@oj2/contract"
import { createDiscreteApi } from "naive-ui"
import { ref, getCurrentScope, onScopeDispose, type Ref } from "vue"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { getAccessToken } from "utils/auth"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import type { ClassBoardCell } from "utils/types"
@@ -93,6 +95,8 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
*/
protected closedByUser = false
protected reviveBound = false
/** 正在等 access token、连接还没建出来,见 connect() */
protected opening = false
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
@@ -117,11 +121,27 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
this.bindReviveListeners()
this.status.value = "connecting"
if (this.opening) return
// 握手要带 access token,快过期了得先换一张 —— 所以真正建连接是异步的
this.opening = true
void getAccessToken()
.catch(() => null)
.then((token) => {
this.opening = false
// 等 token 的这段时间里被 disconnect() 了,就不建了
if (this.closedByUser) return
this.open(token)
})
}
/** 建连接、挂回调。只由 connect() 调 */
private open(token: string | null) {
try {
// 所有回调都闭包住这个局部 ws 而不是读 this.ws:一条被换掉的旧连接
// 迟到的 onclose / onerror 不该去改现在这条连接的状态
const ws = new WebSocket(this.url)
// 迟到的 onclose / onerror 不该去改现在这条连接的状态。
// 浏览器的 WebSocket 设不了请求头,token 借子协议带过去,见契约 WS_AUTH_PROTOCOL
const ws = new WebSocket(this.url, token ? [WS_AUTH_PROTOCOL, token] : undefined)
this.ws = ws
ws.binaryType = "arraybuffer"
+2 -2
View File
@@ -115,8 +115,8 @@ export const useUserStore = defineStore("user", () => {
for (const [key, value] of keep) if (value !== null) storage.set(key, value)
}
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由 ——
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地的话,服务端那张
// 会话还活着,refresh token 被人拿走照样能用。跳转留给调用方,store 里不碰路由 ——
// 调用方要**整页**跳走,别用 router:各个 store 里还留着这个人的数据。
async function signOut() {
await logout()
+39 -4
View File
@@ -1,6 +1,11 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
import axios, {
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from "axios"
import { createDiscreteApi } from "naive-ui"
import { useAuthModalStore } from "shared/store/authModal"
import { getAccessToken, refreshAccessToken } from "./auth"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
@@ -91,10 +96,16 @@ export function handleGlobalApiError(code: string, message?: string) {
const instance = axios.create({
baseURL: "/api",
withCredentials: true,
})
instance.interceptors.request.use((config) => {
/** 已经因为 token-expired 重试过一次的请求,别再进第二轮,也别再被请求拦截器换 token */
type RetriableConfig = InternalAxiosRequestConfig & { authRetried?: boolean }
instance.interceptors.request.use(async (config: RetriableConfig) => {
if (!config.authRetried) {
const token = await getAccessToken()
if (token) config.headers.set("Authorization", `Bearer ${token}`)
}
if (config.params) {
config.params = Object.fromEntries(
Object.entries(config.params).filter(
@@ -112,12 +123,36 @@ instance.interceptors.response.use(
((response: AxiosResponse) => response.data.data) as unknown as (
response: AxiosResponse,
) => AxiosResponse,
(error) => {
async (error) => {
const payload = error.response?.data as ErrorPayload | undefined
const code = payload?.error?.code ?? "network-error"
// 没有响应体 = 请求根本没到后端(断网、反代挂了、超时),学生看到的就是这一句
const message = payload?.error?.message ?? "网络异常,请检查网络后重试"
// access token 不能用了(过期、被吊销):拿 refresh token 换一张,原请求重发一次。
// 重发的结果照样走这两个拦截器,调用方拿到的和一次成功没有区别
const config = error.config as RetriableConfig | undefined
if (code === "token-expired" && config && !config.authRetried) {
config.authRetried = true
const sent = String(config.headers.get("Authorization") ?? "").replace(/^Bearer /, "")
const outcome = await refreshAccessToken(sent)
if (outcome.token) {
config.headers.set("Authorization", `Bearer ${outcome.token}`)
return instance.request(config)
}
if (outcome.code === "network-error") {
return Promise.reject(new ApiError("network-error", "网络异常,请检查网络后重试"))
}
if (outcome.code === "account-disabled") {
handleGlobalApiError("account-disabled")
return Promise.reject(new ApiError("account-disabled", "账号已被禁用,请联系老师"))
}
// 登录真的过期了。拿掉 token 重发:要登录的接口回 login-required 弹登录框,
// 可选登录的(首页、题目列表……)照常当游客看,不平白弹框
config.headers.delete("Authorization")
return instance.request(config)
}
handleGlobalApiError(code, message)
return Promise.reject(new ApiError(code, message))
},
+168
View File
@@ -0,0 +1,168 @@
import type { AuthTokens } from "@oj2/contract"
import { STORAGE_KEY } from "./constants"
import storage from "./storage"
/**
* 登录态的两张 token,存 localStorage。
*
* 不用 sessionStorage:「在新标签页打开」是全站常用操作(提交详情、题目、统计……),
* sessionStorage 不跨标签页,开出去的页面全是未登录。
*
* access token 15 分钟过期,到期前由这里拿 refresh token 去换,后端的口径见
* apps/api/src/auth/session.ts。
*/
/** 剩不到这么久就提前换:别让请求带着还剩两秒的 token 出门、到后端正好过期 */
const REFRESH_AHEAD_MS = 60_000
interface StoredAccess {
token: string
/** 本机时钟的毫秒时刻。用后端给的秒数加本机时钟算,机房电脑时钟不准也不受影响 */
expiresAt: number
}
export function saveTokens(tokens: AuthTokens) {
storage.set(STORAGE_KEY.ACCESS_TOKEN, {
token: tokens.accessToken,
expiresAt: Date.now() + tokens.expiresIn * 1000,
} satisfies StoredAccess)
// null = 换 token 的宽限期,沿用本地那张,见契约 AuthTokens 的注释
if (tokens.refreshToken) storage.set(STORAGE_KEY.REFRESH_TOKEN, tokens.refreshToken)
}
export function clearTokens() {
storage.remove(STORAGE_KEY.ACCESS_TOKEN)
storage.remove(STORAGE_KEY.REFRESH_TOKEN)
}
export function getRefreshToken(): string | null {
const value = storage.get(STORAGE_KEY.REFRESH_TOKEN)
return typeof value === "string" ? value : null
}
function readAccess(): StoredAccess | null {
const value = storage.get(STORAGE_KEY.ACCESS_TOKEN) as StoredAccess | null
return value && typeof value.token === "string" && typeof value.expiresAt === "number"
? value
: null
}
/**
* 换 token 的结果。`network-error` 时**不清** token:断网、后端重启都是暂时的,
* 清掉等于让学生重新登录;只有后端明确说 refresh token 不认了才清。
*/
export type RefreshOutcome =
| { token: string; code?: undefined }
| { token: null; code: "refresh-invalid" | "account-disabled" | "network-error" }
let inflight: Promise<RefreshOutcome> | null = null
/**
* 拿 refresh token 换一张新的 access token。同一页面里同时只有一次在飞。
*
* `stale` 是刚被后端拒掉的那张:排队的这段时间里别的标签页可能已经换过了,
* 本地存的要是另一张、而且还新鲜,直接用它,不再去换。
*/
export function refreshAccessToken(stale?: string) {
inflight ??= withRefreshLock(() => runRefresh(stale)).finally(() => (inflight = null))
return inflight
}
/**
* 跨标签页排队。`navigator.locks` 只在安全上下文里有,机房走 http 直连 IP 时是
* undefined —— 那就不排,撞车由后端的宽限期兜住(REFRESH_GRACE_MS)。
*/
function withRefreshLock<T>(task: () => Promise<T>): Promise<T> {
const locks = (navigator as Navigator & { locks?: LockManager }).locks
return locks ? (locks.request("oj2-token-refresh", task) as Promise<T>) : task()
}
async function runRefresh(stale?: string): Promise<RefreshOutcome> {
const access = readAccess()
if (access && access.token !== stale && access.expiresAt - Date.now() > REFRESH_AHEAD_MS) {
return { token: access.token }
}
const refreshToken = getRefreshToken()
if (!refreshToken) return { token: null, code: "refresh-invalid" }
let response: Response
try {
// 裸 fetch:走 utils/api 的 axios 会再进它的拦截器,绕回这里
response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
})
} catch {
return { token: null, code: "network-error" }
}
const body = (await response.json().catch(() => null)) as {
data?: AuthTokens
error?: { code?: string }
} | null
if (response.ok && body?.data?.accessToken) {
saveTokens(body.data)
return { token: body.data.accessToken }
}
const code = body?.error?.code
if (code === "refresh-invalid" || code === "account-disabled") {
clearTokens()
return { token: null, code }
}
// 5xx、网关的错误页:当成暂时的,同上
return { token: null, code: "network-error" }
}
/**
* 发请求前取一张能用的 access token:快过期了先换。没登录返回 null。
* 换的时候断网就先用手上这张碰碰运气,后端不认的话请求方会再走一次换 token。
*/
export async function getAccessToken() {
const access = readAccess()
if (access && access.expiresAt - Date.now() > REFRESH_AHEAD_MS) return access.token
if (!access && !getRefreshToken()) return null
const outcome = await refreshAccessToken()
if (outcome.token) return outcome.token
return outcome.code === "network-error" ? (access?.token ?? null) : null
}
/** 后端回的错误码。读的是副本,原响应体留给调用方 */
async function responseCode(response: Response) {
const body = (await response
.clone()
.json()
.catch(() => null)) as { error?: { code?: string } } | null
return body?.error?.code
}
/**
* 不走 axios 的请求(AI 流式接口、离开页面时的 keepalive 上报)用它代替 fetch:
* 带上 token,后端回 `token-expired` 就换一张重试一次 —— 和 utils/api.ts 拦截器
* 是同一套规矩。
*/
export async function authFetch(input: string, init: RequestInit = {}) {
const send = (token: string | null) =>
fetch(input, {
...init,
headers: {
...(init.headers as Record<string, string> | undefined),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
const token = await getAccessToken()
const response = await send(token)
if (response.status !== 401 || !token) return response
if ((await responseCode(response)) !== "token-expired") return response
const outcome = await refreshAccessToken(token)
// 账号被禁用:换 token 那一步就知道了,原请求不必再发。造一个和后端同形的响应,
// 调用方按原样处理(弹「账号已被禁用」而不是登录框)
if (outcome.code === "account-disabled") {
return new Response(
JSON.stringify({ error: { code: "account-disabled", message: "账号已被禁用,请联系老师" } }),
{ status: 403, headers: { "Content-Type": "application/json" } },
)
}
// 换不到就拿掉 token 重发:要登录的接口回 login-required 弹登录框,可选登录的当游客
return send(outcome.token)
}
+2
View File
@@ -149,6 +149,8 @@ export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export const STORAGE_KEY = {
AUTHED: "authed",
ACCESS_TOKEN: "accessToken",
REFRESH_TOKEN: "refreshToken",
LANGUAGE: "problemLanguage",
ADMIN_PROBLEM: "adminProblem",
ADMIN_SQL_PROBLEM: "adminSqlProblem",
+7 -1
View File
@@ -1,12 +1,18 @@
import axios from "axios"
import { createDiscreteApi } from "naive-ui"
import { getAccessToken } from "./auth"
// 指向新后端的 /api/admin。响应是 zip 二进制,不走 { error, data } 信封,
// 所以不能复用 utils/api 的拦截器(它会把 response.data.data 取出来)。
const http = axios.create({
baseURL: "/api/admin",
responseType: "blob",
withCredentials: true,
})
http.interceptors.request.use(async (config) => {
const token = await getAccessToken()
if (token) config.headers.set("Authorization", `Bearer ${token}`)
return config
})
// 脱离 n-message-provider 也能弹,理由同 utils/api.ts
+2 -1
View File
@@ -1,4 +1,5 @@
import { handleGlobalApiError } from "./api"
import { authFetch } from "./auth"
export interface JSONEventStreamHandlers<T = any> {
onMessage: (data: T, event?: string) => void
@@ -152,7 +153,7 @@ export async function streamAI<Done = Record<string, unknown>>(
body: unknown,
handlers: AIStreamHandlers<Done>,
) {
const response = await fetch(`/api/${path}`, {
const response = await authFetch(`/api/${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
-3
View File
@@ -67,9 +67,6 @@ WEB_PORT=
JUDGE_STATE_DIR=
# --- 只有机房那套需要 ---
# 机房走 http 直连 IP,没有 TLS,必须 false,否则 Cookie 发不回来。
COOKIE_SECURE=false
# 机房没有本地库,连服务器的 5445。不填时默认指向服务器(默认值在 compose.school.yml 里),
# 换服务器才要填。服务器那套不读这两个。
DB_HOST=
+16 -18
View File
@@ -1,17 +1,13 @@
#!/usr/bin/env bash
#
# 一次性运维动作:清掉所有会话,强制全员重新登录。
# 运维动作:清掉所有会话,强制全员重新登录。
#
# ## 为什么要跑
# 会话是 `auth-session:<sid>`(见 apps/api/src/auth/session.ts),删掉之后 access token
# 下一个请求就不认、refresh token 也换不出新的,不用等 token 自然过期。
# 顺带清掉 cookie 时代留下的 `session:*` / `user-sessions:*`(换成 token 之后没人读了,
# 不清的话也会在 7 天内自己过期)。
#
# 会话的反向索引 `user-sessions:<uid>` 是 498fc1c 才加的。在那之前签发的会话不在索引
# 里,revokeUserSessions 靠 SMEMBERS 找不到它们 —— 也就是说**改密码、重置密码、禁用
# 账号对这批会话统统无效**,只能等最长一个 SESSION_TTL_SECONDS(默认 7 天)自然过期。
#
# 学生密码是明文存着给老师查的,改密码正是密码泄露之后唯一的补救手段,这个空窗不能留。
# 代价只是所有人重新登录一次。
#
# 跑过一次就不用再跑了:此后签发的会话都带索引。
# 只想让某一个人下线不用跑这个:后台改密码 / 禁用账号就会把他所有设备踢下线。
#
# ## 两个站点都要跑
#
@@ -23,7 +19,7 @@
# 同一个 Redis 里还装着 BullMQ 的判题队列(`bull:*`)。FLUSHALL 会把还在队列里的提交
# 一起丢掉,那些 submission 会永远停在 PENDING,只能超管逐条重判 —— 而重判还会把
# user_profile 的反范式计数带偏(见 apps/api/src/scripts/recount.ts)。
# 这里只删 `session:*` 和 `user-sessions:*`。
# 这里只删下面列出来的几种会话键。
#
# ## 用法
#
@@ -70,13 +66,14 @@ purge() {
count() { cli --scan --pattern "$1" 2>/dev/null | wc -l; }
PATTERNS=('auth-session:*' 'auth-contest:*' 'session:*' 'user-sessions:*')
say "容器 $CONTAINER"
before_sessions=$(count 'session:*')
before_index=$(count 'user-sessions:*')
for pattern in "${PATTERNS[@]}"; do
printf ' %-16s %s\n' "$pattern" "$(count "$pattern")"
done
before_bull=$(count 'bull:*')
printf ' session:* %s\n' "$before_sessions"
printf ' user-sessions:* %s\n' "$before_index"
printf ' bull:* %s(不动)\n' "$before_bull"
printf ' %-16s %s(不动)\n' 'bull:*' "$before_bull"
if [ "${YES:-}" != "1" ]; then
read -rp $'\n 删掉上面的会话、让所有人重新登录?[y/N] ' answer
@@ -84,8 +81,9 @@ if [ "${YES:-}" != "1" ]; then
fi
say "清理"
ok "session:* 删掉 $(purge 'session:*') 个"
ok "user-sessions:* 删掉 $(purge 'user-sessions:*') 个"
for pattern in "${PATTERNS[@]}"; do
ok "$(printf '%-16s' "$pattern") 删掉 $(purge "$pattern") 个"
done
after_bull=$(count 'bull:*')
[ "$after_bull" = "$before_bull" ] \
-2
View File
@@ -151,8 +151,6 @@ services:
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-2}
AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 走 NPM 终止 TLS,浏览器侧是 https,Cookie 必须带 Secure
COOKIE_SECURE: "true"
healthcheck:
test: ["CMD", "oj2-api", "healthcheck"]
interval: 30s
-3
View File
@@ -85,9 +85,6 @@ services:
TRIAL_CONCURRENCY: ${TRIAL_CONCURRENCY:-4}
AI_KEY: ${AI_KEY:-}
AI_HINT_DIAGNOSE: ${AI_HINT_DIAGNOSE:-}
# 机房走 http 直连 IP,没有 TLS。带 Secure 的 Cookie 浏览器不会回传,
# 学生会「登录成功但立刻又是未登录」。这里必须是 false。
COOKIE_SECURE: ${COOKIE_SECURE:-false}
healthcheck:
test: ["CMD", "oj2-api", "healthcheck"]
interval: 30s
+5 -3
View File
@@ -17,8 +17,10 @@
Redis)和 WebSocket 推送(本地 pub/sub)是每站独立的 —— 学生在哪边提交就在哪边判、
推送也只推得到连在本站的人。这和旧栈 Dramatiq + Channels 的拓扑一致,不是回归。
机房那套 `COOKIE_SECURE=false`:走 http 直连 IP,带 `Secure` 的 Cookie 浏览器不回传,
表现是「登录成功又立刻变未登录」。
登录态是 access token + refresh token(见 `apps/api/src/auth/session.ts`),会话存在
**本站的** Redis 里,所以在一个站点登录,到另一个站点还要再登一次。JWT 的签名密钥也存在
本站 Redis(`auth:jwt-secret`,首次启动自动生成),部署不用配;删掉它再重启 api 等于让
本站所有人重新登录。改密码、禁用账号走库里的 `user.token_version`,两个站点同时生效。
## 全部容器都归 OJ2
@@ -141,7 +143,7 @@ WebSocket)→ 后台判题机列表在线 → 后台题目列表翻页 → 带
|---|---|---|
| **Websockets Support** | 开 | 页面一切正常,唯独学生盯着的「判题中…」永远不动 |
| `client_max_body_size` | `200M` | 后台上传测试用例压缩包失败(和 Caddyfile 的 `200MiB` 对齐) |
| SSL | 签证书 | `COOKIE_SECURE=true` 依赖 https |
| SSL | 签证书 | 登录 token 明文走公网;浏览器也只在 https 下给 `navigator.locks`,几个标签页换 token 会撞车(有宽限期兜着,但别依赖它) |
## 备份与灾难恢复
@@ -139,6 +139,10 @@ Projects/OJ/
**选 Hono 而非 Elysia。** Elysia 类型推导更强、benchmark 更快,但生态小且绑定 Bun。本项目负载为机房数十名学生,框架性能不构成瓶颈;Hono 的生态成熟度、`@hono/zod-validator`、以及可将类型直接传递给前端的 `hc` RPC 客户端更有价值。
> 2026-10 改成了 access token(JWT,Bearer)+ refresh token,封禁和改密码的即时生效靠
> 每个请求核对 Redis 会话与库里的 `user.token_version`,见 `apps/api/src/auth/session.ts`。
> 下面是当初的取舍,留作记录。
**会话用 Redis opaque token 而非 JWT。** 理由是账号封禁需即时生效:`User.is_disabled` 一旦置位必须立刻踢人下线,JWT 做不到,得额外维护黑名单——那等于把 Redis 又加回来,白绕一圈。Redis 本就在架构内,直接用 opaque token 最省。
> 更正(2026-08-06 盘点时发现):本条最初的理由写的是"现有 `account/views/oj.py:214` 实现了查看并踢出其他登录会话"。核实后该功能实际是死的——`/api/sessions` 前端从不调用,`User.session_keys` 字段只写不读(`account/middleware.py:30` 每请求追加,无人读取)。结论不变,理由已换成上面成立的那条。新后端不要复刻 `session_keys`。
+28
View File
@@ -5,6 +5,33 @@ export const loginRequestSchema = z.object({
password: z.string().min(1).max(1024),
})
/** 换 access token、退出登录都拿 refresh token 当凭证 */
export const refreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1).max(512),
})
/**
* WebSocket 握手带 access token 用的子协议名。浏览器的 `new WebSocket()` 设不了请求头,
* 前端传 `[WS_AUTH_PROTOCOL, token]` 两个子协议,服务端回第一个 —— 必须回一个前端
* 传过的,否则 Chrome 判握手失败。不放 URL 查询参数:URL 会原样进反代的访问日志。
*/
export const WS_AUTH_PROTOCOL = "oj2.bearer"
/**
* 登录、换 token 的出参。
*
* `expiresIn` 是秒数而不是到期时刻:机房电脑的时钟不一定准,前端拿自己的时钟加上它
* 算到期,才不会因为差了几分钟一直以为 token 已过期。
*
* `refreshToken` 为 null 只出现在换 token 的宽限期里(同一张 refresh token 刚被别的
* 标签页换掉),这时沿用本地已经存着的那张,见 auth/session.ts 的 refreshTokens。
*/
export interface AuthTokens {
accessToken: string
refreshToken: string | null
expiresIn: number
}
export const sessionUserSchema = z.object({
id: z.number().int(),
username: z.string(),
@@ -29,5 +56,6 @@ export const userProfileSchema = z.object({
})
export type LoginRequest = z.input<typeof loginRequestSchema>
export type RefreshTokenRequest = z.input<typeof refreshTokenRequestSchema>
export type SessionUser = z.infer<typeof sessionUserSchema>
export type UserProfile = z.infer<typeof userProfileSchema>