Files
OJ2/apps/api/src/auth/middleware.ts
yuetsh cafa92a102 fix(阶段4评审收尾): 清掉三条 Minor,顺带一个真 bug
## 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>
2026-08-08 02:23:23 -06:00

79 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
)