diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index e1c326c..1a6e7db 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -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 = 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 = 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 = async (c, next) => { diff --git a/apps/api/src/auth/presence.ts b/apps/api/src/auth/presence.ts index 9503ea0..acca6af 100644 --- a/apps/api/src/auth/presence.ts +++ b/apps/api/src/auth/presence.ts @@ -6,7 +6,7 @@ import { calendarDay } from "../time" /** * 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。 * - * 会话本身判定不了在线:`session:` 的 TTL 是 7 天且每次请求都续期, + * 会话本身判定不了在线:`auth-session:` 的 TTL 是 7 天且每次请求都续期, * 「有会话」只说明这人一周内来过。所以这里单独记一个活动时间戳 —— * 写入一律搭在已有的 pipeline 上(登录、每个带鉴权的请求、WebSocket 巡检), * 不多一趟往返。 diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 78f58c7..a5c8658 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -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**:`.<随机串>`,不透明,7 天(`SESSION_TTL_SECONDS`), + * 每换一次 access token 就轮换一次,有效期跟着往后顺延 —— 和原来 cookie 会话 + * 「七天不来才过期」是同一个口径。 + * + * 会话本体仍然在 Redis 的 `auth-session:` 里(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 +} + +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:`,里面记着 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 +/** `.<随机串>` 拆开。格式不对返回 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 { + 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 { - 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 { + 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, } - - if (user.isDisabled) { - // 会话照删(禁用要立即生效),但要把「是被禁用」这件事告诉调用方。 - // 都返回 null 的话,中途被禁用的学生看到的是 401 login-required, - // 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。 - await redis.del(sessionKey(token)) - await redis.srem(userSessionsKey(session.userId), token) - return { user: null, reason: "disabled" } - } - - // 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期, - // 之后再吊销就找不到这张会话了。两条走一次 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 { + 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, }, } } -/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */ -export function resolveSession(c: Context) { - return authenticateToken(getCookie(c, config.sessionCookie)) -} - -/** 只关心「是谁」的调用方用这个 */ -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 升级时把 token 一起存进连接,之后才能定期确认这个会话还有效 - * —— 握手时校验过一次,可这条连接能挂上好几个小时。 + * 退出登录:删掉 refresh token 对应的那张会话。返回会话 id,调用方要拿它去广播吊销, + * 好断掉同一浏览器里其他标签页的 WebSocket。token 不对(伪造、已过期)返回 null。 + * + * 要核对随机部分,不能只认 sid:sid 就写在 access token 的载荷里,谁都能解出来。 */ -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) - // 只挂着 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 { +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 { + 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(bearerToken(c.req.header("authorization"))) +} + +/** 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 +} + +/** + * 会话还在就续期、记一笔在线并返回 true,已登出或已过期返回 false。 + * + * WebSocket 用:握手时校验过一次,可这条连接能挂上好几个小时。只开着页面挂 WebSocket + * 的人一次请求都不发,不该因此被算成不活跃踢下线,所以这里也续期。 + * + * 只查 Redis 不查库:每条订阅消息都要过一遍,热路径。token_version 和禁用由 + * websocket.ts 的 sweepSessions 每 60 秒统一对一次库。 + */ +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 +} + +/** 当前请求那张 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 } } diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index fe2e029..8f4f413 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -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 } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index f0b8464..0755e19 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/db/0037_add_user_token_version.sql b/apps/api/src/db/0037_add_user_token_version.sql new file mode 100644 index 0000000..15cf394 --- /dev/null +++ b/apps/api/src/db/0037_add_user_token_version.sql @@ -0,0 +1 @@ +ALTER TABLE "user" ADD COLUMN "token_version" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/apps/api/src/db/meta/0037_snapshot.json b/apps/api/src/db/meta/0037_snapshot.json new file mode 100644 index 0000000..419c911 --- /dev/null +++ b/apps/api/src/db/meta/0037_snapshot.json @@ -0,0 +1,3839 @@ +{ + "id": "dffad954-afa0-4ead-b503-f0b0b0708190", + "prevId": "e30412c5-a294-451d-8c77-33f2cf13cdc4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.achievement": { + "name": "achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden": { + "name": "hidden", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "unlock_count": { + "name": "unlock_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.acm_contest_rank": { + "name": "acm_contest_rank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_time": { + "name": "total_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_info": { + "name": "submission_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "acm_rank_contest_user_idx": { + "name": "acm_rank_contest_user_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "acm_rank_order_idx": { + "name": "acm_rank_order_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "accepted_number", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "total_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "acm_contest_rank_contest_id_21030ccd_fk_contest_id": { + "name": "acm_contest_rank_contest_id_21030ccd_fk_contest_id", + "tableFrom": "acm_contest_rank", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "acm_contest_rank_user_id_40391ab2_fk_user_id": { + "name": "acm_contest_rank_user_id_40391ab2_fk_user_id", + "tableFrom": "acm_contest_rank", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_acm_rank_user_contest": { + "name": "unique_acm_rank_user_contest", + "nullsNotDistinct": false, + "columns": [ + "contest_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_analysis": { + "name": "ai_analysis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_analysis_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_prompt": { + "name": "user_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis": { + "name": "analysis", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_analysis_user_id_3aa23011": { + "name": "ai_analysis_user_id_3aa23011", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_analysis_user_id_3aa23011_fk_user_id": { + "name": "ai_analysis_user_id_3aa23011_fk_user_id", + "tableFrom": "ai_analysis", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_hint": { + "name": "ai_hint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "ai_hint_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "diagnosis": { + "name": "diagnosis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "diagnosis_error": { + "name": "diagnosis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_attempt": { + "name": "filter_attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "filter_blocked": { + "name": "filter_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "filter_reason": { + "name": "filter_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "helpful": { + "name": "helpful", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "feedback_time": { + "name": "feedback_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ai_hint_submission_id_idx": { + "name": "ai_hint_submission_id_idx", + "columns": [ + { + "expression": "submission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_hint_submission_id_fk_submission_id": { + "name": "ai_hint_submission_id_fk_submission_id", + "tableFrom": "ai_hint", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.announcement": { + "name": "announcement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "top": { + "name": "top", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "announcement_created_by_id_359ccf50": { + "name": "announcement_created_by_id_359ccf50", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "announcement_list_idx": { + "name": "announcement_list_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "top", + "isExpression": false, + "asc": false, + "nulls": "first" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "announcement_created_by_id_359ccf50_fk_user_id": { + "name": "announcement_created_by_id_359ccf50_fk_user_id", + "tableFrom": "announcement", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.class_lesson": { + "name": "class_lesson", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "class_lesson_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problem_ids": { + "name": "problem_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "class_lesson_created_by_idx": { + "name": "class_lesson_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "class_lesson_created_by_fk_user_id": { + "name": "class_lesson_created_by_fk_user_id", + "tableFrom": "class_lesson", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "class_lesson_class_day_unique": { + "name": "class_lesson_class_day_unique", + "nullsNotDistinct": false, + "columns": [ + "class_name", + "day" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contest": { + "name": "contest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contest_created_by_id_a763ca7e": { + "name": "contest_created_by_id_a763ca7e", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contest_created_by_id_a763ca7e_fk_user_id": { + "name": "contest_created_by_id_a763ca7e_fk_user_id", + "tableFrom": "contest", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise": { + "name": "exercise", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "exercise_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "exercise_tutorial_id_6fd04055": { + "name": "exercise_tutorial_id_6fd04055", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_tutorial_id_6fd04055_fk_tutorial_id": { + "name": "exercise_tutorial_id_6fd04055_fk_tutorial_id", + "tableFrom": "exercise", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exercise_attempt": { + "name": "exercise_attempt", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "exercise_id": { + "name": "exercise_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wrong_attempts": { + "name": "wrong_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "solved": { + "name": "solved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts_to_solve": { + "name": "attempts_to_solve", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_wrong_answer": { + "name": "last_wrong_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_attempt_at": { + "name": "first_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "solved_at": { + "name": "solved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "exercise_attempt_exercise_id_idx": { + "name": "exercise_attempt_exercise_id_idx", + "columns": [ + { + "expression": "exercise_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exercise_attempt_user_id_fk_user_id": { + "name": "exercise_attempt_user_id_fk_user_id", + "tableFrom": "exercise_attempt", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exercise_attempt_exercise_id_fk_exercise_id": { + "name": "exercise_attempt_exercise_id_fk_exercise_id", + "tableFrom": "exercise_attempt", + "tableTo": "exercise", + "columnsFrom": [ + "exercise_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "exercise_attempt_pkey": { + "name": "exercise_attempt_pkey", + "columns": [ + "user_id", + "exercise_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flowchart_submission": { + "name": "flowchart_submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ai_score": { + "name": "ai_score", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "ai_grade": { + "name": "ai_grade", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "ai_feedback": { + "name": "ai_feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_suggestions": { + "name": "ai_suggestions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_criteria_details": { + "name": "ai_criteria_details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "ai_provider": { + "name": "ai_provider", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "ai_model": { + "name": "ai_model", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "processing_time": { + "name": "processing_time", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "evaluation_time": { + "name": "evaluation_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "flowchart_problem_time_idx": { + "name": "flowchart_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_status_idx": { + "name": "flowchart_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_create_time_idx": { + "name": "flowchart_create_time_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flowchart_user_time_idx": { + "name": "flowchart_user_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flowchart_submission_problem_id_8551edbf_fk_problem_id": { + "name": "flowchart_submission_problem_id_8551edbf_fk_problem_id", + "tableFrom": "flowchart_submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "flowchart_submission_user_id_225c83e8_fk_user_id": { + "name": "flowchart_submission_user_id_225c83e8_fk_user_id", + "tableFrom": "flowchart_submission", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.judge_server": { + "name": "judge_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judger_version": { + "name": "judger_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cpu_core": { + "name": "cpu_core", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_usage": { + "name": "memory_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "cpu_usage": { + "name": "cpu_usage", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "task_number": { + "name": "task_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "service_url": { + "name": "service_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.options_sysoptions": { + "name": "options_sysoptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "options_sysoptions_key_key": { + "name": "options_sysoptions_key_key", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem": { + "name": "problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_description": { + "name": "input_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "output_description": { + "name": "output_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "samples": { + "name": "samples", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "test_case_id": { + "name": "test_case_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "test_case_score": { + "name": "test_case_score", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "memory_limit": { + "name": "memory_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "submission_number": { + "name": "submission_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "accepted_number": { + "name": "accepted_number", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_id": { + "name": "_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_submission": { + "name": "share_submission", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "answers": { + "name": "answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allow_flowchart": { + "name": "allow_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "flowchart_data": { + "name": "flowchart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "flowchart_hint": { + "name": "flowchart_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mermaid_code": { + "name": "mermaid_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_flowchart": { + "name": "show_flowchart", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ast_rules": { + "name": "ast_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_config": { + "name": "sql_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sql_display": { + "name": "sql_display", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problem_contest_visible_idx": { + "name": "problem_contest_visible_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_created_by_id_cb362143": { + "name": "problem_created_by_id_cb362143", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_visible_idx": { + "name": "problem_visible_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_contest_id_328e013a_fk_contest_id": { + "name": "problem_contest_id_328e013a_fk_contest_id", + "tableFrom": "problem", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "problem_created_by_id_cb362143_fk_user_id": { + "name": "problem_created_by_id_cb362143_fk_user_id", + "tableFrom": "problem", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problem_id_contest": { + "name": "unique_problem_id_contest", + "nullsNotDistinct": false, + "columns": [ + "_id", + "contest_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tag": { + "name": "problem_tag", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'knowledge'" + }, + "category_confirmed": { + "name": "category_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "problem_tag_name_ci_unique": { + "name": "problem_tag_name_ci_unique", + "columns": [ + { + "expression": "lower(name)", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problem_tags": { + "name": "problem_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemtag_id": { + "name": "problemtag_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problem_tags_problemtag_id_72d20571": { + "name": "problem_tags_problemtag_id_72d20571", + "columns": [ + { + "expression": "problemtag_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problem_tags_problem_id_866ecb8d_fk_problem_id": { + "name": "problem_tags_problem_id_866ecb8d_fk_problem_id", + "tableFrom": "problem_tags", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problem_tags_problemtag_id_72d20571_fk_problem_tag_id": { + "name": "problem_tags_problemtag_id_72d20571_fk_problem_tag_id", + "tableFrom": "problem_tags", + "tableTo": "problem_tag", + "columnsFrom": [ + "problemtag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "problem_tags_problem_id_problemtag_id_318459d1_uniq": { + "name": "problem_tags_problem_id_problemtag_id_318459d1_uniq", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemtag_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset": { + "name": "problemset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_update_time": { + "name": "last_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "problemset_created_by_id_01b5197f": { + "name": "problemset_created_by_id_01b5197f", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_created_by_id_01b5197f_fk_user_id": { + "name": "problemset_created_by_id_01b5197f_fk_user_id", + "tableFrom": "problemset", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_badge": { + "name": "problemset_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_type": { + "name": "condition_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "condition_value": { + "name": "condition_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_badge_problemset_id_6cb6c74f": { + "name": "problemset_badge_problemset_id_6cb6c74f", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id": { + "name": "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id", + "tableFrom": "problemset_badge", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_problem": { + "name": "problemset_problem", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_problem_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_problem_problem_id_fff2d686": { + "name": "problemset_problem_problem_id_fff2d686", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_problem_problem_id_fff2d686_fk_problem_id": { + "name": "problemset_problem_problem_id_fff2d686_fk_problem_id", + "tableFrom": "problemset_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_problem_problemset_id_350d17fb_fk_problemset_id": { + "name": "problemset_problem_problemset_id_350d17fb_fk_problemset_id", + "tableFrom": "problemset_problem", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_problem": { + "name": "unique_problemset_problem", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "problemset_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.problemset_progress": { + "name": "problemset_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "problemset_progress_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "join_time": { + "name": "join_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "complete_time": { + "name": "complete_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "completed_problems_count": { + "name": "completed_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_problems_count": { + "name": "total_problems_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress_detail": { + "name": "progress_detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "problemset_progress_user_id_c8041a80": { + "name": "problemset_progress_user_id_c8041a80", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "problemset_progress_problemset_id_20a9632e_fk_problemset_id": { + "name": "problemset_progress_problemset_id_20a9632e_fk_problemset_id", + "tableFrom": "problemset_progress", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "problemset_progress_user_id_c8041a80_fk_user_id": { + "name": "problemset_progress_user_id_c8041a80_fk_user_id", + "tableFrom": "problemset_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_problemset_progress_user": { + "name": "unique_problemset_progress_user", + "nullsNotDistinct": false, + "columns": [ + "problemset_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reaction": { + "name": "reaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "reaction_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reaction_problem_type_idx": { + "name": "reaction_problem_type_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reaction_user_id_cfa7f469": { + "name": "reaction_user_id_cfa7f469", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reaction_problem_id_a7f3b9f3_fk_problem_id": { + "name": "reaction_problem_id_a7f3b9f3_fk_problem_id", + "tableFrom": "reaction", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reaction_user_id_cfa7f469_fk_user_id": { + "name": "reaction_user_id_cfa7f469_fk_user_id", + "tableFrom": "reaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reaction_problem_user_unique": { + "name": "reaction_problem_user_unique", + "nullsNotDistinct": false, + "columns": [ + "problem_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission": { + "name": "submission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "contest_id": { + "name": "contest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 6 + }, + "info": { + "name": "info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared": { + "name": "shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "statistic_info": { + "name": "statistic_info", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "problemset_id": { + "name": "problemset_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contest_create_time_idx": { + "name": "contest_create_time_idx", + "columns": [ + { + "expression": "contest_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_create_time_id_idx": { + "name": "submission_public_create_time_id_idx", + "columns": [ + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "problem_user_idx": { + "name": "problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_37e2f67a": { + "name": "submission_result_37e2f67a", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_create_time_idx": { + "name": "user_create_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_language_time_idx": { + "name": "submission_language_time_idx", + "columns": [ + { + "expression": "language", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_result_time_idx": { + "name": "submission_result_time_idx", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_time_idx": { + "name": "submission_public_problem_time_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_username_trgm_idx": { + "name": "submission_public_username_trgm_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "gin", + "with": {} + }, + "submission_public_metrics_idx": { + "name": "submission_public_metrics_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "create_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_public_problem_user_idx": { + "name": "submission_public_problem_user_idx", + "columns": [ + { + "expression": "problem_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"contest_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_problemset_id_idx": { + "name": "submission_problemset_id_idx", + "columns": [ + { + "expression": "problemset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"submission\".\"problemset_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_contest_id_775716d5_fk_contest_id": { + "name": "submission_contest_id_775716d5_fk_contest_id", + "tableFrom": "submission", + "tableTo": "contest", + "columnsFrom": [ + "contest_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problem_id_76847b55_fk_problem_id": { + "name": "submission_problem_id_76847b55_fk_problem_id", + "tableFrom": "submission", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "submission_problemset_id_fk_problemset_id": { + "name": "submission_problemset_id_fk_problemset_id", + "tableFrom": "submission", + "tableTo": "problemset", + "columnsFrom": [ + "problemset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_trace": { + "name": "submission_trace", + "schema": "", + "columns": { + "submission_id": { + "name": "submission_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active_ms": { + "name": "active_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "since_open_ms": { + "name": "since_open_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "typed_chars": { + "name": "typed_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pasted_chars": { + "name": "pasted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paste_count": { + "name": "paste_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_paste": { + "name": "max_paste", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_chars": { + "name": "deleted_chars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "blur_count": { + "name": "blur_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "initial_len": { + "name": "initial_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "collab": { + "name": "collab", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "since_prev_ms": { + "name": "since_prev_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "submission_trace_submission_id_fk_submission_id": { + "name": "submission_trace_submission_id_fk_submission_id", + "tableFrom": "submission_trace", + "tableTo": "submission", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial": { + "name": "tutorial", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "tutorial_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "project": { + "name": "project", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "''" + } + }, + "indexes": { + "tutorial_created_by_id_07973cab": { + "name": "tutorial_created_by_id_07973cab", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_created_by_id_07973cab_fk_user_id": { + "name": "tutorial_created_by_id_07973cab_fk_user_id", + "tableFrom": "tutorial", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_problem": { + "name": "tutorial_problem", + "schema": "", + "columns": { + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "problem_id": { + "name": "problem_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tutorial_problem_tutorial_id_fk_tutorial_id": { + "name": "tutorial_problem_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_problem", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_problem_problem_id_fk_problem_id": { + "name": "tutorial_problem_problem_id_fk_problem_id", + "tableFrom": "tutorial_problem", + "tableTo": "problem", + "columnsFrom": [ + "problem_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_problem_pkey": { + "name": "tutorial_problem_pkey", + "columns": [ + "tutorial_id", + "problem_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tutorial_progress": { + "name": "tutorial_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tutorial_id": { + "name": "tutorial_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "view_count": { + "name": "view_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_seconds": { + "name": "total_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_viewed_at": { + "name": "first_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tutorial_progress_tutorial_id_idx": { + "name": "tutorial_progress_tutorial_id_idx", + "columns": [ + { + "expression": "tutorial_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tutorial_progress_user_id_fk_user_id": { + "name": "tutorial_progress_user_id_fk_user_id", + "tableFrom": "tutorial_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tutorial_progress_tutorial_id_fk_tutorial_id": { + "name": "tutorial_progress_tutorial_id_fk_tutorial_id", + "tableFrom": "tutorial_progress", + "tableTo": "tutorial", + "columnsFrom": [ + "tutorial_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tutorial_progress_pkey": { + "name": "tutorial_progress_pkey", + "columns": [ + "user_id", + "tutorial_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_login": { + "name": "last_login", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "create_time": { + "name": "create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_type": { + "name": "admin_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_disabled": { + "name": "is_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "problem_permission": { + "name": "problem_permission", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "raw_password": { + "name": "raw_password", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "class_name": { + "name": "class_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rank_hidden_at": { + "name": "rank_hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_active_idx": { + "name": "user_active_idx", + "columns": [ + { + "expression": "is_disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_login", + "isExpression": false, + "asc": false, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_class_name_idx": { + "name": "user_class_name_idx", + "columns": [ + { + "expression": "class_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_key": { + "name": "user_username_key", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievement": { + "name": "user_achievement", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_achievement_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "unlock_time": { + "name": "unlock_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "backfilled": { + "name": "backfilled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "achievement_id": { + "name": "achievement_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_achievement_achievement_id_29db600d": { + "name": "user_achievement_achievement_id_29db600d", + "columns": [ + { + "expression": "achievement_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_notified_idx": { + "name": "user_achv_notified_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "notified", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_achv_time_idx": { + "name": "user_achv_time_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "unlock_time", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievement_achievement_id_29db600d_fk_achievement_id": { + "name": "user_achievement_achievement_id_29db600d_fk_achievement_id", + "tableFrom": "user_achievement", + "tableTo": "achievement", + "columnsFrom": [ + "achievement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_achievement_user_id_b8ec7d6a_fk_user_id": { + "name": "user_achievement_user_id_b8ec7d6a_fk_user_id", + "tableFrom": "user_achievement", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_achievement": { + "name": "unique_user_achievement", + "nullsNotDistinct": false, + "columns": [ + "achievement_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_badge": { + "name": "user_badge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_badge_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "earned_time": { + "name": "earned_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "badge_id": { + "name": "badge_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_badge_badge_id_92a983e9": { + "name": "user_badge_badge_id_92a983e9", + "columns": [ + { + "expression": "badge_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int8_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_badge_badge_id_92a983e9_fk_problemset_badge_id": { + "name": "user_badge_badge_id_92a983e9_fk_problemset_badge_id", + "tableFrom": "user_badge", + "tableTo": "problemset_badge", + "columnsFrom": [ + "badge_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_badge_user_id_a286d718_fk_user_id": { + "name": "user_badge_user_id_a286d718_fk_user_id", + "tableFrom": "user_badge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_badge": { + "name": "unique_user_badge", + "nullsNotDistinct": false, + "columns": [ + "badge_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profile": { + "name": "user_profile", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "acm_problems_status": { + "name": "acm_problems_status", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mood": { + "name": "mood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_number": { + "name": "accepted_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "submission_number": { + "name": "submission_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "real_name": { + "name": "real_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_profile_user_id_8fdce8e2_fk_user_id": { + "name": "user_profile_user_id_8fdce8e2_fk_user_id", + "tableFrom": "user_profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profile_user_id_key": { + "name": "user_profile_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stat": { + "name": "user_stat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_stat_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "update_time": { + "name": "update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_stat_user_id_73337fc0_fk_user_id": { + "name": "user_stat_user_id_73337fc0_fk_user_id", + "tableFrom": "user_stat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stat_user_id_key": { + "name": "user_stat_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/meta/_journal.json b/apps/api/src/db/meta/_journal.json index 47f1d8e..2b8d85a 100644 --- a/apps/api/src/db/meta/_journal.json +++ b/apps/api/src/db/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index f875c7f..f5d65e9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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), diff --git a/apps/api/src/events.ts b/apps/api/src/events.ts index c17e2b3..b1ad2c2 100644 --- a/apps/api/src/events.ts +++ b/apps/api/src/events.ts @@ -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 { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b21a718..bb09655 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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({ port: config.port, // Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默 @@ -126,8 +130,9 @@ const server = Bun.serve({ 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({ : "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, }, diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index c5a9b8d..b1dfc46 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -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 }) diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 8cca902..5eeaeb9 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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) }) diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index c569725..74d6018 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -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[], 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() - const dead: Bun.ServerWebSocket[] = [] - for (const ws of liveSockets) { - const token = ws.data.token - let alive = checked.get(token) - if (alive === undefined) { - try { - alive = await touchSession(token, ws.data.userId) - } catch (error) { - // Redis 抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说 - console.error("Failed to verify websocket sessions", error) - return - } - checked.set(token, alive) + const sockets = [...liveSockets] + let users: { id: number; tokenVersion: number; isDisabled: boolean }[] + try { + 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)) } - if (!alive) dead.push(ws) + 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 或库抖一下不该把全班踢下线:这一轮直接放弃,下一轮再说 + console.error("Failed to verify websocket sessions", error) + return } - // 会话没了有两种可能:在别的标签页登出了,或者会话自己到期。对用户都是 + const byId = new Map(users.map((user) => [user.id, user])) + const ended: Bun.ServerWebSocket[] = [] + const disabled: Bun.ServerWebSocket[] = [] + 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) + } + } + // 会话没了有几种可能:在别的标签页登出、会话自己到期、改了密码。对用户都是 // 「要重新登录」,走 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, 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 - revoked.token !== undefined - ? ws.data.token === revoked.token + revoked.sid !== undefined + ? ws.data.sid === revoked.sid : ws.data.userId === revoked.userId, ), revoked.reason, diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index e8b4cbb..7c48740 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -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`) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index e117cc5..66ccea0 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -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), diff --git a/apps/web/src/shared/api.ts b/apps/web/src/shared/api.ts index 5e6f0f3..d734125 100644 --- a/apps/web/src/shared/api.ts +++ b/apps/web/src/shared/api.ts @@ -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("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 { diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 0600e1c..775ffe8 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -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 { */ protected closedByUser = false protected reviveBound = false + /** 正在等 access token、连接还没建出来,见 connect() */ + protected opening = false public status: Ref = ref("disconnected") @@ -117,11 +121,27 @@ export class BaseWebSocket { 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" diff --git a/apps/web/src/shared/store/user.ts b/apps/web/src/shared/store/user.ts index 47f8a91..8e872c3 100644 --- a/apps/web/src/shared/store/user.ts +++ b/apps/web/src/shared/store/user.ts @@ -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() diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts index 5684739..9abceed 100644 --- a/apps/web/src/utils/api.ts +++ b/apps/web/src/utils/api.ts @@ -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)) }, diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts new file mode 100644 index 0000000..9c13ae2 --- /dev/null +++ b/apps/web/src/utils/auth.ts @@ -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 | 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(task: () => Promise): Promise { + const locks = (navigator as Navigator & { locks?: LockManager }).locks + return locks ? (locks.request("oj2-token-refresh", task) as Promise) : task() +} + +async function runRefresh(stale?: string): Promise { + 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 | 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) +} diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index d951a86..0e68975 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -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", diff --git a/apps/web/src/utils/download.ts b/apps/web/src/utils/download.ts index 7e26b90..ec2451b 100644 --- a/apps/web/src/utils/download.ts +++ b/apps/web/src/utils/download.ts @@ -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 diff --git a/apps/web/src/utils/stream.ts b/apps/web/src/utils/stream.ts index d76d49c..c150194 100644 --- a/apps/web/src/utils/stream.ts +++ b/apps/web/src/utils/stream.ts @@ -1,4 +1,5 @@ import { handleGlobalApiError } from "./api" +import { authFetch } from "./auth" export interface JSONEventStreamHandlers { onMessage: (data: T, event?: string) => void @@ -152,7 +153,7 @@ export async function streamAI>( body: unknown, handlers: AIStreamHandlers, ) { - const response = await fetch(`/api/${path}`, { + const response = await authFetch(`/api/${path}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/docker/.env.example b/docker/.env.example index 2a9b1f7..d421480 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -67,9 +67,6 @@ WEB_PORT= JUDGE_STATE_DIR= # --- 只有机房那套需要 --- -# 机房走 http 直连 IP,没有 TLS,必须 false,否则 Cookie 发不回来。 -COOKIE_SECURE=false - # 机房没有本地库,连服务器的 5445。不填时默认指向服务器(默认值在 compose.school.yml 里), # 换服务器才要填。服务器那套不读这两个。 DB_HOST= diff --git a/docker/clear-sessions.sh b/docker/clear-sessions.sh index b905824..664e64e 100755 --- a/docker/clear-sessions.sh +++ b/docker/clear-sessions.sh @@ -1,17 +1,13 @@ #!/usr/bin/env bash # -# 一次性运维动作:清掉所有会话,强制全员重新登录。 +# 运维动作:清掉所有会话,强制全员重新登录。 # -# ## 为什么要跑 +# 会话是 `auth-session:`(见 apps/api/src/auth/session.ts),删掉之后 access token +# 下一个请求就不认、refresh token 也换不出新的,不用等 token 自然过期。 +# 顺带清掉 cookie 时代留下的 `session:*` / `user-sessions:*`(换成 token 之后没人读了, +# 不清的话也会在 7 天内自己过期)。 # -# 会话的反向索引 `user-sessions:` 是 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" ] \ diff --git a/docker/compose.debian.yml b/docker/compose.debian.yml index b318596..5c877a1 100644 --- a/docker/compose.debian.yml +++ b/docker/compose.debian.yml @@ -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 diff --git a/docker/compose.school.yml b/docker/compose.school.yml index e6ba433..548e268 100644 --- a/docker/compose.school.yml +++ b/docker/compose.school.yml @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 22287a8..dbf5a36 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 会撞车(有宽限期兜着,但别依赖它) | ## 备份与灾难恢复 diff --git a/docs/specs/2026-08-06-bun-backend-rewrite-design.md b/docs/specs/2026-08-06-bun-backend-rewrite-design.md index 3cab9f8..0f59a76 100644 --- a/docs/specs/2026-08-06-bun-backend-rewrite-design.md +++ b/docs/specs/2026-08-06-bun-backend-rewrite-design.md @@ -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`。 diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index 537878e..b9cd55e 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -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 +export type RefreshTokenRequest = z.input export type SessionUser = z.infer export type UserProfile = z.infer