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 || "权限不足") }