feat(限流): 登录、注册、比赛密码防爆破

三处原来都不限次数。学生初始密码弱,/classes/:name/usernames 又能公开列出整班
用户名,等于现成的撞库名单。

services/throttling.ts 加固定窗口计数(INCR + EXPIRE NX,锁定时长就是剩余 TTL),
何时计数由调用方决定:

- 登录按用户名 15 分钟 10 次失败,成功清零;按 IP 15 分钟 100 次失败,成功不清零
  (否则拿自己的号登一次就能重置)。机房一个班共用出口 IP,所以 IP 那道放得宽
- 注册按 IP 每小时 100 个成功注册
- 比赛密码按「人 × 比赛」10 分钟 10 次失败
- IP 取 X-Real-IP:api 端口不对外,Caddy 用 {client_ip} 覆盖写入,可信

前端登录、注册、比赛密码三处按新错误码给中文提示。

本机实跑核过:第 11 次错误登录被拦、成功清零(用户名大小写不敏感)、同 IP 换
100 个用户名被拦且别的 IP 不受影响、比赛密码第 11 次被拦。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 14:51:52 +08:00
1 parent 5813981bb1
commit fb240d4254
8 files changed
+156 -19

No files matched your search

+24
View File
@@ -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<AppEnv>()
/**
* 注册限流:按 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)
})
+48 -16
View File
@@ -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<AppEnv>()
/**
* 登录防爆破,两道都只数失败:
*
* - **按用户名**:盯着一个账号猜。学生的初始密码弱、`/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 }
+20
View File
@@ -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,