## M4 禁用账号会把学生卡在登录死循环里(唯一学生会撞上的)
`getSessionUser` 对禁用用户返回 null,于是落到 401 `login-required`,
而前端拦截器见到这个码就弹登录框 —— 一个上课上到一半被禁用的学生会陷入
「弹登录框 → 登进去 → 又被弹」,完全看不出发生了什么。
会话解析改成返回 `{ user } | { user: null, reason: "anonymous" | "disabled" }`,
禁用报 403 `account-disabled`(凭证有效、是账号不让用了,和 login 接口对禁用
账号的回法一致)。会话照删,禁用立即生效。前端补一支:清登录态 + 明确提示,
**不弹登录框**。
实测:会话中途 UPDATE is_disabled=true → 同一会话下一个请求
403 `account-disabled`「账号已被禁用,请联系老师」。
## M3 三个端点的守卫写在 handler 体内
submissions/statistics、submissions/:id/rejudge、flowcharts/statistics 的档位
本来就是对的,但写成 handler 里的 if,违背了「守卫要从注册行上看得出来」的约定,
下一个人加同类端点容易漏掉那个 if。改用 requireTeacher / requireSuperAdmin。
实测档位没变:普通学生三个都 403;教师统计接口 200、重判仍 403。
## M2 from-public 的错误码构成比赛存在性预言机
比赛不存在回 `not-found`、存在但不属于你回 `contest-not-found`,带一个已知
有效的 problemId 就能靠错误码枚举出哪些 contestId 真实存在。统一成
`contest-not-found`,和全仓其余跨租户路径一致。
实测:两种情况现在都是 404 contest-not-found。
## 顺带:比赛里的 SQL 题看不到示例数据
改 M2 时 tsc 报 `sqlDisplay` 声明了没用到 —— 查下去是真 bug:
`POST /contests/:id/problems` 把展示数据算出来了,却往库里写死 null
(公开题那两条路径都是对的)。于是比赛里的 SQL 题打开后没有示例数据表和期望结果。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
import type { Context, MiddlewareHandler } from "hono"
|
||
|
||
import { failure } from "../http"
|
||
import { getSessionUser, resolveSession, type AuthUser } from "./session"
|
||
|
||
export interface AppEnv {
|
||
Variables: {
|
||
user: AuthUser | null
|
||
}
|
||
}
|
||
|
||
export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||
c.set("user", await getSessionUser(c))
|
||
await next()
|
||
}
|
||
|
||
/**
|
||
* 拿不到用户时该报哪个错。
|
||
*
|
||
* 「账号被禁用」必须和「没登录」分开报:前端拦截器见到 `login-required` 会弹登录框,
|
||
* 于是一个上课上到一半被禁用的学生会陷入「弹登录框 → 登进去 → 又被弹」的死循环,
|
||
* 而且完全看不出发生了什么。旧后端报的是「账号已禁用」,这里对齐。
|
||
*
|
||
* 用 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", "请先登录")
|
||
}
|
||
|
||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||
const session = await resolveSession(c)
|
||
if (!session.user) return denied(c, session.reason)
|
||
c.set("user", session.user)
|
||
await next()
|
||
}
|
||
|
||
/**
|
||
* 后台接口的角色守卫,对应旧后端 `account/decorators.py` 的四个装饰器。
|
||
*
|
||
* 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`,
|
||
* 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts`
|
||
* 的拦截器就是按这两个 code 分别弹登录框和弹提示的。禁用账号走第三个码,见 denied()。
|
||
*/
|
||
function requireRole(
|
||
allowed: (user: AuthUser) => boolean,
|
||
): MiddlewareHandler<AppEnv> {
|
||
return async (c, next) => {
|
||
const session = await resolveSession(c)
|
||
if (!session.user) return denied(c, session.reason)
|
||
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足")
|
||
c.set("user", session.user)
|
||
await next()
|
||
}
|
||
}
|
||
|
||
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
||
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
|
||
|
||
/** 旧 `@admin_role_required` */
|
||
export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType))
|
||
|
||
/** 旧 `@teacher_admin_required` */
|
||
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType))
|
||
|
||
/** 旧 `@super_admin_required` */
|
||
export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin")
|
||
|
||
/**
|
||
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
|
||
* 注意它只管「能不能进这个接口」,「能改哪些题」(Own vs All)由各 handler 自己按
|
||
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
|
||
*/
|
||
export const requireProblemPermission = requireRole(
|
||
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
||
)
|