From cafa92a10285756044e593b2d9132a72d61f54a9 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sat, 8 Aug 2026 02:23:23 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=98=B6=E6=AE=B54=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E6=94=B6=E5=B0=BE):=20=E6=B8=85=E6=8E=89=E4=B8=89=E6=9D=A1=20M?= =?UTF-8?q?inor=EF=BC=8C=E9=A1=BA=E5=B8=A6=E4=B8=80=E4=B8=AA=E7=9C=9F=20bu?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- apps/api/src/auth/middleware.ts | 41 +++++++++++++++++---------- apps/api/src/auth/session.ts | 42 +++++++++++++++++++++------- apps/api/src/routes/admin/problem.ts | 15 ++++++---- apps/api/src/routes/flowchart.ts | 8 ++---- apps/api/src/routes/submission.ts | 19 ++++++------- apps/web/src/utils/api2.ts | 6 ++++ 6 files changed, 84 insertions(+), 47 deletions(-) diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts index 8f3d16e..83cbd38 100644 --- a/apps/api/src/auth/middleware.ts +++ b/apps/api/src/auth/middleware.ts @@ -1,7 +1,7 @@ -import type { MiddlewareHandler } from "hono" +import type { Context, MiddlewareHandler } from "hono" import { failure } from "../http" -import { getSessionUser, type AuthUser } from "./session" +import { getSessionUser, resolveSession, type AuthUser } from "./session" export interface AppEnv { Variables: { @@ -14,12 +14,26 @@ export const optionalAuth: MiddlewareHandler = async (c, next) => { 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 = async (c, next) => { - const user = await getSessionUser(c) - if (!user) { - return failure(c, 401, "login-required", "Authentication required") - } - c.set("user", user) + const session = await resolveSession(c) + if (!session.user) return denied(c, session.reason) + c.set("user", session.user) await next() } @@ -28,19 +42,16 @@ export const requireAuth: MiddlewareHandler = async (c, next) => { * * 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`, * 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts` - * 的拦截器就是按这两个 code 分别弹登录框和弹提示的。 - * - * 「账号已禁用」这一支不需要单独处理:`getSessionUser` 对禁用用户直接返回 null, - * 于是落到 401,比旧后端先认证再报 403 更早拦一步。 + * 的拦截器就是按这两个 code 分别弹登录框和弹提示的。禁用账号走第三个码,见 denied()。 */ function requireRole( allowed: (user: AuthUser) => boolean, ): MiddlewareHandler { return async (c, next) => { - const user = await getSessionUser(c) - if (!user) return failure(c, 401, "login-required", "请先登录") - if (!allowed(user)) return failure(c, 403, "permission-denied", "权限不足") - c.set("user", user) + 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() } } diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 78f719c..3d047dc 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -74,18 +74,26 @@ function readCookie(request: Request, name: string) { return undefined } -async function getUserByToken(token: string | undefined): Promise { - if (!token) return null +/** + * 会话解析结果。之所以不只返回 `AuthUser | null`:拿不到用户有两种原因, + * 而它们对应完全不同的前端行为 —— 未登录该弹登录框,已禁用该说「账号已禁用」。 + */ +export type SessionResult = + | { user: AuthUser; reason?: undefined } + | { user: null; reason: "anonymous" | "disabled" } + +async function getUserByToken(token: string | undefined): Promise { + if (!token) return { user: null, reason: "anonymous" } const raw = await redis.get(sessionKey(token)) - if (!raw) return null + if (!raw) return { user: null, reason: "anonymous" } let session: StoredSession try { session = JSON.parse(raw) as StoredSession } catch { await redis.del(sessionKey(token)) - return null + return { user: null, reason: "anonymous" } } const [user] = await db @@ -102,21 +110,35 @@ async function getUserByToken(token: string | undefined): Promise { const FLOWCHART_COMPLETED = 2 -flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => { - if (!isTeacherOrAbove(c.get("user"))) { - return failure(c, 403, "permission-denied", "Teacher permission required") - } +flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { const end = c.req.query("end")?.trim() if (!end) return failure(c, 400, "invalid-request", "end is required") const start = c.req.query("start")?.trim() diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 5242297..0aad5fc 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -14,7 +14,12 @@ import { import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm" import { Hono } from "hono" -import { optionalAuth, requireAuth } from "../auth/middleware" +import { + optionalAuth, + requireAuth, + requireSuperAdmin, + requireTeacher, +} from "../auth/middleware" import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, success } from "../http" @@ -34,8 +39,6 @@ import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" import { isAdminRole, - isSuperAdmin, - isTeacherOrAbove, queryInteger, rounded, stripClassPrefix, @@ -218,10 +221,7 @@ async function matchedStudents(username: string) { ) } -submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => { - if (!isTeacherOrAbove(c.get("user"))) { - return failure(c, 403, "permission-denied", "Teacher permission required") - } +submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { const range = statisticsRange(c) if (!range) return failure(c, 400, "invalid-request", "end is required") @@ -334,10 +334,7 @@ submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => { ) }) -submissionRoutes.post("/submissions/:id/rejudge", requireAuth, async (c) => { - if (!isSuperAdmin(c.get("user"))) { - return failure(c, 403, "permission-denied", "Super admin permission required") - } +submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => { const [row] = await db .select({ id: schema.submission.id, problemId: schema.submission.problemId }) .from(schema.submission) diff --git a/apps/web/src/utils/api2.ts b/apps/web/src/utils/api2.ts index 53e86f4..761b026 100644 --- a/apps/web/src/utils/api2.ts +++ b/apps/web/src/utils/api2.ts @@ -63,6 +63,12 @@ instance.interceptors.response.use( if (code === "login-required") { storage.remove(STORAGE_KEY.AUTHED) useAuthModalStore().openLoginModal() + } else if (code === "account-disabled") { + // 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒, + // 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。 + // 清掉登录态并明确告知,会话在中途被禁用时也走这一支。 + storage.remove(STORAGE_KEY.AUTHED) + toast.error(legacyMessage || "账号已被禁用,请联系老师") } else if (code === "permission-denied") { toast.error(legacyMessage || "权限不足") }