diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 2ae74e1..e3ba696 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -47,6 +47,12 @@ import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" import { getBooleanOption } from "../services/options" import { getUserProfileById } from "../services/profile" +import { + clientIp, + countAttempt, + lockoutRemaining, + type AttemptRule, +} from "../services/throttling" import { localTime, weekStart } from "../time" import { isTeacherOrAbove, @@ -57,6 +63,12 @@ import { export const accountRoutes = new Hono() +/** + * 注册限流:按 IP 每小时 100 个号,只数成功的。拦的是脚本批量造号; + * 机房一个班共用一个出口 IP,一节课全班现场注册也够用。 + */ +const REGISTER_PER_IP: AttemptRule = { limit: 100, windowSeconds: 60 * 60 } + accountRoutes.post("/users", async (c) => { const parsed = registerRequestSchema.safeParse( await c.req.json().catch(() => null), @@ -72,6 +84,17 @@ accountRoutes.post("/users", async (c) => { ) } + const registerKey = `register:ip:${clientIp(c)}` + const wait = await lockoutRemaining(registerKey, REGISTER_PER_IP) + if (wait !== null) { + return failure( + c, + 429, + "too-many-registrations", + `Too many registrations, please wait ${wait} seconds`, + ) + } + const username = parsed.data.username.toLowerCase() const email = parsed.data.email.toLowerCase() const [duplicate] = await db @@ -120,6 +143,7 @@ accountRoutes.post("/users", async (c) => { realName: null, }) }) + await countAttempt(registerKey, REGISTER_PER_IP) return success(c, { ok: true }, 201) }) diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index afd3f51..a92f0ce 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -9,9 +9,28 @@ import { hashPassword, verifyPassword } from "../auth/password" import { db, schema } from "../db" import { failure, success } from "../http" import { getUserProfileById } from "../services/profile" +import { + clientIp, + countAttempt, + lockoutRemaining, + resetAttempts, + type AttemptRule, +} from "../services/throttling" export const authRoutes = new Hono() +/** + * 登录防爆破,两道都只数失败: + * + * - **按用户名**:盯着一个账号猜。学生的初始密码弱、`/classes/:name/usernames` 又能 + * 公开列出整班用户名,这是最现实的攻击。15 分钟 10 次,登录成功清零。 + * 代价是别人能故意输错把某个学生锁 15 分钟 —— 比被猜中密码好得多。 + * - **按 IP**:换着用户名猜(每个号只试几次常见密码,绕开上一道)。机房一个班共用一个 + * 出口 IP,所以放得很宽,而且成功**不清零**,否则攻击者拿自己的号登一次就能重置。 + */ +const LOGIN_PER_USERNAME: AttemptRule = { limit: 10, windowSeconds: 15 * 60 } +const LOGIN_PER_IP: AttemptRule = { limit: 100, windowSeconds: 15 * 60 } + authRoutes.post("/auth/login", async (c) => { const parsed = loginRequestSchema.safeParse( await c.req.json().catch(() => null), @@ -25,33 +44,46 @@ authRoutes.post("/auth/login", async (c) => { ) } + const usernameKey = `login:user:${parsed.data.username.toLowerCase()}` + const ipKey = `login:ip:${clientIp(c)}` + const wait = + (await lockoutRemaining(usernameKey, LOGIN_PER_USERNAME)) ?? + (await lockoutRemaining(ipKey, LOGIN_PER_IP)) + if (wait !== null) { + return failure( + c, + 429, + "too-many-login-attempts", + `Too many failed attempts, please wait ${wait} seconds`, + ) + } + const loginFailed = async () => { + await Promise.all([ + countAttempt(usernameKey, LOGIN_PER_USERNAME), + countAttempt(ipKey, LOGIN_PER_IP), + ]) + return failure( + c, + 401, + "invalid-credentials", + "Invalid username or password", + ) + } + const [user] = await db .select() .from(schema.user) .where(sql`lower(${schema.user.username}) = lower(${parsed.data.username})`) .limit(1) - if (!user) { - return failure( - c, - 401, - "invalid-credentials", - "Invalid username or password", - ) - } + if (!user) return loginFailed() if (user.isDisabled) { return failure(c, 403, "account-disabled", "Your account has been disabled") } const password = await verifyPassword(parsed.data.password, user.password) - if (!password.valid) { - return failure( - c, - 401, - "invalid-credentials", - "Invalid username or password", - ) - } + if (!password.valid) return loginFailed() + await resetAttempts(usernameKey) const now = new Date().toISOString() const update: { lastLogin: string; password?: string } = { lastLogin: now } diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 75bcd4a..f7f8048 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -38,6 +38,11 @@ import { requireContestAccess, type ContestEnv, } from "../services/contest" +import { + countAttempt, + lockoutRemaining, + type AttemptRule, +} from "../services/throttling" import { objectValue, publicTemplates, @@ -149,6 +154,8 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => { ) }) +const CONTEST_PASSWORD_RULE: AttemptRule = { limit: 10, windowSeconds: 10 * 60 } + contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { const contest = await findAccessibleContest( c.get("user"), @@ -161,7 +168,20 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => { ) if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required") + // 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。 + // 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。 + const attemptKey = `contest-password:${contest.id}:${c.get("user")!.id}` + const wait = await lockoutRemaining(attemptKey, CONTEST_PASSWORD_RULE) + if (wait !== null) { + return failure( + c, + 429, + "too-many-password-attempts", + `Too many wrong passwords, please wait ${wait} seconds`, + ) + } if (!checkContestPassword(parsed.data.password, contest.password)) { + await countAttempt(attemptKey, CONTEST_PASSWORD_RULE) return failure( c, 403, diff --git a/apps/api/src/services/throttling.ts b/apps/api/src/services/throttling.ts index 8b4eb62..d59ac7d 100644 --- a/apps/api/src/services/throttling.ts +++ b/apps/api/src/services/throttling.ts @@ -1,3 +1,5 @@ +import type { Context } from "hono" + import { redis } from "../redis" import { getOptions } from "./options" @@ -166,3 +168,50 @@ export async function consumeToken( if (Number(result[0]) === 1) return { allowed: true } return { allowed: false, wait: Number(result[1]) || 0 } } + +/** + * 计数锁定:固定窗口内计满 `limit` 次就锁到窗口结束。**什么时候计数由调用方决定**: + * + * - 登录、比赛密码只在**失败**时计数 —— 正常用户几乎不会连续输错,暴力猜解全是失败, + * 只数失败的话机房一个班同时登录也不会误伤; + * - 注册在**成功**时计数 —— 限的是批量造号。 + * + * 和上面的令牌桶是两回事:令牌桶限的是资源消耗(判题沙箱、AI),带匀速回填; + * 这里是「窗口内满多少次就停」。用固定窗口而不是滑动窗口:一个 INCR + EXPIRE NX 就够, + * 锁定时长就是 key 的剩余 TTL,精度对防爆破足够了。 + */ +export type AttemptRule = { limit: number; windowSeconds: number } + +/** 已锁定时返回还要等几秒,否则 null。只读,不计数。 */ +export async function lockoutRemaining(key: string, rule: AttemptRule) { + const [count, ttl] = await Promise.all([ + redis.get(`attempts:${key}`), + redis.ttl(`attempts:${key}`), + ]) + if (Number(count) < rule.limit) return null + // ttl 为 -1(没有过期时间)理论上不会出现,EXPIRE NX 总会补上;兜底按整窗算 + return ttl > 0 ? ttl : rule.windowSeconds +} + +export async function countAttempt(key: string, rule: AttemptRule) { + await redis + .multi() + .incr(`attempts:${key}`) + // NX:只在窗口里第一次计数时设过期,之后不续期 —— 否则攻击者持续猜就永远锁着, + // 真正的主人过了窗口也登不进来 + .expire(`attempts:${key}`, rule.windowSeconds, "NX") + .exec() +} + +export async function resetAttempts(key: string) { + await redis.del(`attempts:${key}`) +} + +/** + * 客户端 IP。线上 api 端口不对外发布,只有 Caddy 连得到它,Caddy 用 `{client_ip}` + * **覆盖**写入 X-Real-IP(它只信任内网来源的 X-Forwarded-For,见 docker/Caddyfile), + * 所以这个头是可信的。本机 dev 走 Vite 代理没有这个头,统一落到一个桶里,无所谓。 + */ +export function clientIp(c: Context) { + return c.req.header("x-real-ip") || "unknown" +} diff --git a/apps/web/src/oj/contest/detail.vue b/apps/web/src/oj/contest/detail.vue index 15ae6de..bbfbc38 100644 --- a/apps/web/src/oj/contest/detail.vue +++ b/apps/web/src/oj/contest/detail.vue @@ -17,8 +17,13 @@ const { isDesktop } = useBreakpoints() const password = ref("") async function check() { - await contestStore.checkPassword(props.contestID, password.value) - if (!contestStore.access) { + const error = await contestStore.checkPassword( + props.contestID, + password.value, + ) + if (error === "too-many-password-attempts") { + message.error("密码错误次数太多,请 10 分钟后再试") + } else if (!contestStore.access) { message.error("密码错误") } } diff --git a/apps/web/src/oj/store/contest.ts b/apps/web/src/oj/store/contest.ts index 058a184..e01fa29 100644 --- a/apps/web/src/oj/store/contest.ts +++ b/apps/web/src/oj/store/contest.ts @@ -87,6 +87,7 @@ export const useContestStore = defineStore("contest", () => { timer = 0 } + /** 返回失败时的错误码(成功为 null),调用方据此给提示 */ async function checkPassword(contestID: string, password: string) { try { const res = await checkContestPassword(contestID, password) @@ -94,8 +95,10 @@ export const useContestStore = defineStore("contest", () => { if (res) { _getProblems(contestID) } - } catch (err) { + return null + } catch (err: any) { toggleAccess(false) + return (err?.error as string | undefined) ?? "unknown" } } diff --git a/apps/web/src/shared/components/Login.vue b/apps/web/src/shared/components/Login.vue index 7d42ad7..6d5223c 100644 --- a/apps/web/src/shared/components/Login.vue +++ b/apps/web/src/shared/components/Login.vue @@ -130,6 +130,8 @@ function submit() { authStore.setLoginError("此账号已被封禁") } else if (err.error === "invalid-credentials") { authStore.setLoginError("用户名或密码不正确") + } else if (err.error === "too-many-login-attempts") { + authStore.setLoginError("密码错误次数太多,请 15 分钟后再试") } else { authStore.setLoginError("无法登录") } diff --git a/apps/web/src/shared/components/Signup.vue b/apps/web/src/shared/components/Signup.vue index ab76b2b..4ef25b0 100644 --- a/apps/web/src/shared/components/Signup.vue +++ b/apps/web/src/shared/components/Signup.vue @@ -52,6 +52,8 @@ function submit() { authStore.setSignupError("用户名已存在") } else if (err.error === "email-exists") { authStore.setSignupError("邮箱已存在") + } else if (err.error === "too-many-registrations") { + authStore.setSignupError("注册太频繁,请稍后再试") } else { authStore.setSignupError("无法注册") }