后台代码审查后的一批修复。
后端:
- 改密码 / 重置密码 / 禁用账号现在真的把该用户所有设备的会话删掉。原来只
publishSessionRevoked 广播断 WebSocket,HTTP 拿着旧 cookie 照样能用到会话
自然过期 —— 给被盗用的账号改密码等于没改。为此在 Redis 里补了反向索引
user-sessions:<id>(createSession 写入、登出和失效路径清理、跟着会话续期)。
- 导入用户补齐校验:邮箱走 z.email()、批内查重、库内查重,用户名和邮箱各报各的;
用户名和邮箱都归一成小写,和登录的 lower(username) 比较口径对齐。以前导入这条
路什么都不查,而前端占位邮箱按「班级+批内序号」拼,同一个班导第二批必然重号,
那两个账号从此在后台保存一次就撞 409、再也改不动。前端生成的占位邮箱同步加了
每批随机后缀。
- PUT /users/:id 的邮箱查重改比 lower(email),存量大小写混着的数据也能拦住。
- 删用户的裸 catch 收窄成只认外键冲突 23503(顺 cause 链找,drizzle 0.45 把驱动
错误包了一层),别的错照常抛 500,不再把连接故障说成「该用户还有历史数据」。
- 练习题 data 补语义校验(services/exercise.ts):没有 {{空位}} 的填空题、空选项的
选择题、越界的下标等一律拒收。以前后端零校验,坏数据只有学生端会撞到。
- PUT /judge-servers/:id 改用 queryInteger,非数字 id 回 404 而不是 500。
- 比赛克隆不加归属校验是**有意的**(快速再开一场以前的比赛;保密边界在师生之间不在
教师之间),把这条政策和它的副作用写进注释,免得反复被当成漏洞。
前端:
- 编辑用户弹窗的「班级」输入框改成只读 —— 它一直是个改了没用的控件,班级由后端从
用户名推导。
- 新建用户预填唯一占位邮箱、角色默认改成实际会建出来的 Regular User、密码留空直接拦。
- 比赛题目列表的列过滤写的是 top_reaction,实际 key 是 topReaction,空列一直没被滤掉。
- AI 生成流程图加 try/finally,接口失败不再把按钮卡在 loading。
- 单个判题机删除后刷新表格;后台首页显示在线判题机数量(后端一直在下发)。
- 下载测试点失败时读 Blob 里的错误信封弹提示,不再毫无反应。
- 练习题编辑器补上和后端一致的前置校验。
验证:tsc / vue-tsc / vite build / check:routes 全过;后端每条改动都在本机起服务
实跑确认(会话吊销、导入各种重复、练习题七种题型、外键 409、非数字 id)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC5uL72UY9aZFuTvUKe2jv
This commit is contained in:
@@ -13,9 +13,11 @@ import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
||||
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
|
||||
import { redis } from "../redis"
|
||||
|
||||
const SESSION_PREFIX = "session:"
|
||||
const USER_SESSIONS_PREFIX = "user-sessions:"
|
||||
|
||||
interface StoredSession {
|
||||
userId: number
|
||||
@@ -43,6 +45,21 @@ function sessionKey(token: string) {
|
||||
return `${SESSION_PREFIX}${token}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个用户名下所有还活着的会话 token。
|
||||
*
|
||||
* 会话本体是 `session:<token>`,里面记着 userId —— 从 token 找人很快,从人找 token
|
||||
* 却只能 SCAN 整个 Redis。改密码、重置密码、禁用账号这三件事都要求「把这个人所有
|
||||
* 设备上的会话立刻作废」,所以额外维护这张反向索引。
|
||||
*
|
||||
* **它是索引,不是真相**:成员可能指向已经过期的 token(集合成员没有各自的 TTL),
|
||||
* 吊销时按成员逐个 DEL 即可,删到不存在的 key 没有代价。集合自己跟着会话续期,
|
||||
* 整个人不活动满一个 TTL 之后自然消失。
|
||||
*/
|
||||
function userSessionsKey(userId: number) {
|
||||
return `${USER_SESSIONS_PREFIX}${userId}`
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
c: Context,
|
||||
userId: number,
|
||||
@@ -61,6 +78,8 @@ export async function createSession(
|
||||
"EX",
|
||||
config.sessionTtlSeconds,
|
||||
)
|
||||
await redis.sadd(userSessionsKey(userId), token)
|
||||
await redis.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||
setCookie(c, config.sessionCookie, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
@@ -73,11 +92,46 @@ export async function createSession(
|
||||
/** 返回被删掉的 token:调用方要拿它去广播会话吊销,好断掉同一浏览器里其他标签页的连接 */
|
||||
export async function destroySession(c: Context) {
|
||||
const token = getCookie(c, config.sessionCookie)
|
||||
if (token) await redis.del(sessionKey(token))
|
||||
if (token) {
|
||||
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
|
||||
const userId = await sessionUserId(token)
|
||||
await redis.del(sessionKey(token))
|
||||
if (userId !== null) await redis.srem(userSessionsKey(userId), token)
|
||||
}
|
||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||
return token ?? null
|
||||
}
|
||||
|
||||
async function sessionUserId(token: string) {
|
||||
const raw = await redis.get(sessionKey(token))
|
||||
if (!raw) return null
|
||||
try {
|
||||
const value = JSON.parse(raw) as StoredSession
|
||||
return Number.isInteger(value.userId) ? value.userId : null
|
||||
} 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 publishSessionRevoked({ userId }, reason)
|
||||
return tokens.length
|
||||
}
|
||||
|
||||
function readCookie(request: Request, name: string) {
|
||||
const header = request.headers.get("cookie")
|
||||
if (!header) return undefined
|
||||
@@ -126,6 +180,7 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
|
||||
|
||||
if (!user) {
|
||||
await redis.del(sessionKey(token))
|
||||
await redis.srem(userSessionsKey(session.userId), token)
|
||||
return { user: null, reason: "anonymous" }
|
||||
}
|
||||
|
||||
@@ -134,10 +189,14 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
|
||||
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
|
||||
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
|
||||
await redis.del(sessionKey(token))
|
||||
await redis.srem(userSessionsKey(session.userId), token)
|
||||
return { user: null, reason: "disabled" }
|
||||
}
|
||||
|
||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
|
||||
// 之后再吊销就找不到这张会话了
|
||||
await redis.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
|
||||
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
|
||||
return {
|
||||
user: {
|
||||
|
||||
Reference in New Issue
Block a user