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:
1 parent
5813981bb1
commit
fb240d4254
8 files changed
+156
-19
No files matched your search
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user