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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import type { MiddlewareHandler } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import { failure } from "../http"
|
import { failure } from "../http"
|
||||||
import { getSessionUser, type AuthUser } from "./session"
|
import { getSessionUser, resolveSession, type AuthUser } from "./session"
|
||||||
|
|
||||||
export interface AppEnv {
|
export interface AppEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
@@ -14,12 +14,26 @@ export const optionalAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
/**
|
||||||
const user = await getSessionUser(c)
|
* 拿不到用户时该报哪个错。
|
||||||
if (!user) {
|
*
|
||||||
return failure(c, 401, "login-required", "Authentication required")
|
* 「账号被禁用」必须和「没登录」分开报:前端拦截器见到 `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", "请先登录")
|
||||||
}
|
}
|
||||||
c.set("user", user)
|
|
||||||
|
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()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,19 +42,16 @@ export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|||||||
*
|
*
|
||||||
* 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`,
|
* 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`,
|
||||||
* 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts`
|
* 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts`
|
||||||
* 的拦截器就是按这两个 code 分别弹登录框和弹提示的。
|
* 的拦截器就是按这两个 code 分别弹登录框和弹提示的。禁用账号走第三个码,见 denied()。
|
||||||
*
|
|
||||||
* 「账号已禁用」这一支不需要单独处理:`getSessionUser` 对禁用用户直接返回 null,
|
|
||||||
* 于是落到 401,比旧后端先认证再报 403 更早拦一步。
|
|
||||||
*/
|
*/
|
||||||
function requireRole(
|
function requireRole(
|
||||||
allowed: (user: AuthUser) => boolean,
|
allowed: (user: AuthUser) => boolean,
|
||||||
): MiddlewareHandler<AppEnv> {
|
): MiddlewareHandler<AppEnv> {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const user = await getSessionUser(c)
|
const session = await resolveSession(c)
|
||||||
if (!user) return failure(c, 401, "login-required", "请先登录")
|
if (!session.user) return denied(c, session.reason)
|
||||||
if (!allowed(user)) return failure(c, 403, "permission-denied", "权限不足")
|
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足")
|
||||||
c.set("user", user)
|
c.set("user", session.user)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,18 +74,26 @@ function readCookie(request: Request, name: string) {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserByToken(token: string | undefined): Promise<AuthUser | null> {
|
/**
|
||||||
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<SessionResult> {
|
||||||
|
if (!token) return { user: null, reason: "anonymous" }
|
||||||
|
|
||||||
const raw = await redis.get(sessionKey(token))
|
const raw = await redis.get(sessionKey(token))
|
||||||
if (!raw) return null
|
if (!raw) return { user: null, reason: "anonymous" }
|
||||||
|
|
||||||
let session: StoredSession
|
let session: StoredSession
|
||||||
try {
|
try {
|
||||||
session = JSON.parse(raw) as StoredSession
|
session = JSON.parse(raw) as StoredSession
|
||||||
} catch {
|
} catch {
|
||||||
await redis.del(sessionKey(token))
|
await redis.del(sessionKey(token))
|
||||||
return null
|
return { user: null, reason: "anonymous" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
@@ -102,21 +110,35 @@ async function getUserByToken(token: string | undefined): Promise<AuthUser | nul
|
|||||||
.where(eq(schema.user.id, session.userId))
|
.where(eq(schema.user.id, session.userId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!user || user.isDisabled) {
|
if (!user) {
|
||||||
await redis.del(sessionKey(token))
|
await redis.del(sessionKey(token))
|
||||||
return null
|
return { user: null, reason: "anonymous" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.isDisabled) {
|
||||||
|
// 会话照删(禁用要立即生效),但要把「是被禁用」这件事告诉调用方。
|
||||||
|
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
|
||||||
|
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
|
||||||
|
await redis.del(sessionKey(token))
|
||||||
|
return { user: null, reason: "disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||||
return user
|
return { user }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionUser(c: Context) {
|
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
|
||||||
|
export function resolveSession(c: Context) {
|
||||||
return getUserByToken(getCookie(c, config.sessionCookie))
|
return getUserByToken(getCookie(c, config.sessionCookie))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequestSessionUser(request: Request) {
|
/** 只关心「是谁」的调用方用这个 */
|
||||||
return getUserByToken(readCookie(request, config.sessionCookie))
|
export async function getSessionUser(c: Context) {
|
||||||
|
return (await resolveSession(c)).user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRequestSessionUser(request: Request) {
|
||||||
|
return (await getUserByToken(readCookie(request, config.sessionCookie))).user
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoredSession(c: Context) {
|
async function getStoredSession(c: Context) {
|
||||||
|
|||||||
@@ -478,7 +478,9 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
|
|||||||
acceptedNumber: 0,
|
acceptedNumber: 0,
|
||||||
statisticInfo: {},
|
statisticInfo: {},
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
sqlDisplay: null,
|
// 上面 generateSqlDisplay 已经把展示数据算好了,之前这里写死 null,
|
||||||
|
// 结果比赛里的 SQL 题打开后看不到示例数据表和期望结果(公开题那两条路径都是对的)
|
||||||
|
sqlDisplay,
|
||||||
}).returning()
|
}).returning()
|
||||||
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
await setTags(tx as unknown as typeof db, row!.id, parsed.data.tags)
|
||||||
return row!
|
return row!
|
||||||
@@ -547,11 +549,14 @@ adminProblemRoutes.post("/contests/:contestId/problems/from-public", requireProb
|
|||||||
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
|
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
|
||||||
const [problem] = await db.select().from(schema.problem)
|
const [problem] = await db.select().from(schema.problem)
|
||||||
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
||||||
if (!contest || !problem) return failure(c, 404, "not-found", "Contest or Problem does not exist")
|
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) {
|
// 「比赛不存在」和「比赛存在但不是你的」必须回同一个码。分开报的话,带一个已知有效的
|
||||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
// problemId 就能靠错误码差异枚举出哪些 contestId 真实存在。全仓其余跨租户路径都是
|
||||||
}
|
// 统一码(contest 系列一律 contest-not-found),这里对齐。
|
||||||
|
const denyContest =
|
||||||
|
!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)
|
||||||
|
if (denyContest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
|
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
|
// 源题必须是**公开题**,且要么已可见、要么是自己的。旧后端只按 id 取,不校验任何东西 ——
|
||||||
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
|
// 于是能把别人比赛里的题(或别人尚未公开的草稿)拖进自己比赛,进而读到 answers。
|
||||||
if (problem.contestId !== null) {
|
if (problem.contestId !== null) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
@@ -21,7 +21,6 @@ import { flowchartQueue } from "../queue"
|
|||||||
import { buildWordFrequencies } from "../services/word-frequency"
|
import { buildWordFrequencies } from "../services/word-frequency"
|
||||||
import {
|
import {
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
isTeacherOrAbove,
|
|
||||||
objectValue,
|
objectValue,
|
||||||
queryInteger,
|
queryInteger,
|
||||||
rounded,
|
rounded,
|
||||||
@@ -139,10 +138,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
|
|
||||||
const FLOWCHART_COMPLETED = 2
|
const FLOWCHART_COMPLETED = 2
|
||||||
|
|
||||||
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
|
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
||||||
if (!isTeacherOrAbove(c.get("user"))) {
|
|
||||||
return failure(c, 403, "permission-denied", "Teacher permission required")
|
|
||||||
}
|
|
||||||
const end = c.req.query("end")?.trim()
|
const end = c.req.query("end")?.trim()
|
||||||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||||||
const start = c.req.query("start")?.trim()
|
const start = c.req.query("start")?.trim()
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
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 type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
@@ -34,8 +39,6 @@ import { getBooleanOption } from "../services/options"
|
|||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import {
|
import {
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
isSuperAdmin,
|
|
||||||
isTeacherOrAbove,
|
|
||||||
queryInteger,
|
queryInteger,
|
||||||
rounded,
|
rounded,
|
||||||
stripClassPrefix,
|
stripClassPrefix,
|
||||||
@@ -218,10 +221,7 @@ async function matchedStudents(username: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
|
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||||
if (!isTeacherOrAbove(c.get("user"))) {
|
|
||||||
return failure(c, 403, "permission-denied", "Teacher permission required")
|
|
||||||
}
|
|
||||||
const range = statisticsRange(c)
|
const range = statisticsRange(c)
|
||||||
if (!range) return failure(c, 400, "invalid-request", "end is required")
|
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) => {
|
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
|
||||||
if (!isSuperAdmin(c.get("user"))) {
|
|
||||||
return failure(c, 403, "permission-denied", "Super admin permission required")
|
|
||||||
}
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
|
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ instance.interceptors.response.use(
|
|||||||
if (code === "login-required") {
|
if (code === "login-required") {
|
||||||
storage.remove(STORAGE_KEY.AUTHED)
|
storage.remove(STORAGE_KEY.AUTHED)
|
||||||
useAuthModalStore().openLoginModal()
|
useAuthModalStore().openLoginModal()
|
||||||
|
} else if (code === "account-disabled") {
|
||||||
|
// 这里**不能**弹登录框:账号已经被禁用,登进去还是会被拒,
|
||||||
|
// 学生会陷入「弹框 → 登录 → 又弹框」的死循环,且看不出发生了什么。
|
||||||
|
// 清掉登录态并明确告知,会话在中途被禁用时也走这一支。
|
||||||
|
storage.remove(STORAGE_KEY.AUTHED)
|
||||||
|
toast.error(legacyMessage || "账号已被禁用,请联系老师")
|
||||||
} else if (code === "permission-denied") {
|
} else if (code === "permission-denied") {
|
||||||
toast.error(legacyMessage || "权限不足")
|
toast.error(legacyMessage || "权限不足")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user