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)
|
||||
})
|
||||
|
||||
|
||||
+48
-16
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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("密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("无法登录")
|
||||
}
|
||||
|
||||
@@ -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("无法注册")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user