From e9fd51d119befad69eb04282a850593b0108b3d7 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Thu, 16 Jul 2026 09:40:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E5=8F=91=E7=A0=81=E9=98=B2?= =?UTF-8?q?=E5=88=B7=E6=94=B9=E4=B8=BA=E6=8C=89=E6=88=90=E5=8A=9F=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=20+=20=E6=96=B0=E5=A2=9E=E6=AF=8F=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E5=8F=91=E7=A0=81=E4=B8=8A=E9=99=90=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复:发码限流原为原子「判+记」,被单号 60s 冷却挡下的重发也占设备额度 → 正常用户连点重发可能被误锁 1 小时。改为「先判后记、只对成功发码计数」: check 判在真发之前(超限直接 429、不真发),record 只在 send_code 成功后调; 被单号冷却 / 供应商失败抛 429 时直接返回、不计数。 - 新增:同一设备(device_id)+ IP 每天最多 20 次发码上限,与原每小时 5 次两道闸并存, 均按成功计数,叠一层日封顶挡低频长时间轰炸。 - 基建:ratelimit.py 新增 RateLimitRule + check_rate_limits / record_rate_limits (peek/commit 拆分);原子的 enforce_rate_limit 仍保留给登录爆破(失败也计)不变。 - 测试:补 2 个用例(冷却挡下不占额度 / 每日上限)。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/136 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/api/v1/auth.py | 39 ++++++++++------ app/core/ratelimit.py | 101 ++++++++++++++++++++++++++++++++++++---- app/integrations/sms.py | 3 +- tests/test_auth.py | 61 ++++++++++++++++++++++++ tests/test_ratelimit.py | 57 +++++++++++++++++++++++ 5 files changed, 237 insertions(+), 24 deletions(-) create mode 100644 tests/test_ratelimit.py diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 6cee047..b2b0382 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -12,11 +12,16 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, HTTPException, Request from app.api.deps import CurrentUser, DbSession from app.core import test_account -from app.core.ratelimit import enforce_rate_limit +from app.core.ratelimit import ( + RateLimitRule, + check_rate_limits, + enforce_rate_limit, + record_rate_limits, +) from app.core.security import TokenError, decode_token, issue_token_pair from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code @@ -40,9 +45,10 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 -# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。 +# 发码防刷(同一设备 device_id + 同一 IP,**只按成功发码计数**;被单号 60s 冷却挡下的重发不占额度): # 堵「换手机号绕开单号 60s 冷却」的洞 —— 冷却是单号维度,一机换号能绕开。 -SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 +SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限 +SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸) def _login_response( @@ -99,23 +105,26 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: logger.info("test_account sms_send short-circuit (不真发)") return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) - # 防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码。 - # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶, - # 挡短信轰炸/烧钱。放在真发(send_code)之前 → 超限直接拦下、不真发短信。 - enforce_rate_limit( - request, - scope="sms-send-device", - subject=req.device_id, - limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE, - window_sec=3600, - detail="操作过于频繁,请稍后再试", - ) + # 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。 + # 补「换手机号绕开单号 60s 冷却」的洞(冷却是单号维度,一机换号能绕);设备维度按机器封顶,挡短信轰炸/烧钱。 + # 关键:被单号 60s 冷却挡下的重发是「没真发、没烧钱」→ 不该占额度。故 check(先判)放在真发之前 + # (超限直接 429、不真发),record(计数)只在 send_code 成功后调 —— 冷却/供应商失败抛 429 时直接返回、不计数。 + send_rules = [ + RateLimitRule("sms-send-device", SMS_SEND_MAX_PER_HOUR_PER_DEVICE, 3600, + "操作过于频繁,请稍后再试"), + RateLimitRule("sms-send-device-daily", SMS_SEND_MAX_PER_DAY_PER_DEVICE, 86400, + "今日验证码发送次数过多,请明天再试"), + ] + check_rate_limits(request, subject=req.device_id, rules=send_rules) try: cooldown = send_code(req.phone) except SmsError as e: raise HTTPException(status_code=e.status_code, detail=str(e)) from e + # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度) + record_rate_limits(request, subject=req.device_id, rules=send_rules) + from app.core.config import settings # 局部 import 避免循环 return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown) diff --git a/app/core/ratelimit.py b/app/core/ratelimit.py index cff6545..f516ae2 100644 --- a/app/core/ratelimit.py +++ b/app/core/ratelimit.py @@ -9,29 +9,41 @@ from __future__ import annotations import threading import time +from typing import NamedTuple from fastapi import HTTPException, Request, status from app.core.config import settings -# key -> (window_start_ts, count) -_buckets: dict[str, tuple[float, int]] = {} +# key -> (window_start_ts, count, window_sec) +# 存每个 key 自己的 window_sec:_buckets 混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key, +# GC 必须按各 key 自己的窗口判过期(见 [_purge_expired]),否则短窗口调用触发的 GC 会误删长窗口 key。 +_buckets: dict[str, tuple[float, int, float]] = {} _lock = threading.Lock() +_GC_THRESHOLD = 10000 # _buckets 超此阈值才顺手清过期 key(仿 sms.py;测试可 monkeypatch 调小强制每次扫) + + +def _purge_expired(now: float) -> None: + """清过期 key(**仅在持有 _lock 时调用**)。按每个 key 自己存的 window_sec 判过期,而非调用方的窗口 + —— _buckets 是全局共享、混着 60s(广告)/3600s(登录)/86400s(日闸)不同窗口的 key;若用调用方窗口, + 高频的 60s 广告端点触发 GC 时会把本该活 3600s/86400s 的登录/日闸计数一并删掉,使其在规模上(超阈值才 + 触发本清理)被反复清零而失效。仅在超阈值时扫,低频、开销可忽略。""" + if len(_buckets) <= _GC_THRESHOLD: + return + for k in [k for k, (s, _, w) in _buckets.items() if now - s >= w]: + _buckets.pop(k, None) def _hit(key: str, limit: int, window_sec: float) -> bool: """记一次访问。返回 True=放行,False=超限。""" now = time.monotonic() with _lock: - start, count = _buckets.get(key, (now, 0)) + start, count, _ = _buckets.get(key, (now, 0, window_sec)) if now - start >= window_sec: # 窗口过期,重置 start, count = now, 0 count += 1 - _buckets[key] = (start, count) - # 顺手清理过期 key,防内存无限涨(低频访问足够) - if len(_buckets) > 10000: - for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]: - _buckets.pop(k, None) + _buckets[key] = (start, count, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口),防内存无限涨 return count <= limit @@ -83,3 +95,76 @@ def enforce_rate_limit( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail, ) + + +# ===================== 先判 / 后记(只按「成功」计数)===================== +# _hit 是原子「判+记」:一调用就 +1,适合登录爆破(失败尝试也要计)。但对「短信发码」这类 +# **只想给成功动作计数**的场景不合适 —— 被单号冷却挡下的重发没真发、没烧钱,不该占额度。 +# 故拆成 _peek(只判不记)+ _commit(只记):check_rate_limits 先判 → 动作 → 成功后 record。 + + +class RateLimitRule(NamedTuple): + """一条限流规则。scope 区分不同闸(不同 key 前缀);同一 (subject, IP) 在 window_sec + 内最多 limit 次,超限抛 429 用 detail 文案。 + + (scope, window_sec) 成对绑在一条规则里 —— check(先判)与 record(计数)复用同一条, + 避免两处把窗口/scope 写歪导致 key 对不上。 + """ + + scope: str + limit: int + window_sec: float + detail: str = "操作过于频繁,请稍后再试" + + +def _peek(key: str, limit: int, window_sec: float) -> bool: + """只读:当前窗口内是否还没到上限(count < limit)。**不改计数**。 + 与 [_commit] 配对实现「先判后记」——只在动作成功后才 _commit。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口已过期 → 视作已重置(count 归零) + count = 0 + return count < limit + + +def _commit(key: str, window_sec: float) -> None: + """记一次访问(+1)。窗口过期则以本次为起点重置。仅在动作成功后调用。""" + now = time.monotonic() + with _lock: + start, count, _ = _buckets.get(key, (now, 0, window_sec)) + if now - start >= window_sec: # 窗口过期,重置 + start, count = now, 0 + _buckets[key] = (start, count + 1, window_sec) + _purge_expired(now) # 顺手清过期 key(按各自窗口,同 [_hit]) + + +def check_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【先判】一组限流:任一规则已达上限即抛 429,且**不改计数**。 + + 配合 [record_rate_limits] 实现「只按成功计数」:先 check 所有闸(全未超才继续)→ 执行动作 + → 动作**成功后**再 record。动作被下游挡下(如短信单号冷却)、没真正发生时不 record → 不占额度。 + key = `scope:subject:client_ip`(与 [enforce_rate_limit] 同款)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + if not _peek(f"{rule.scope}:{subject}:{ip}", rule.limit, rule.window_sec): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=rule.detail, + ) + + +def record_rate_limits(request: Request, subject: str, rules: list[RateLimitRule]) -> None: + """【记一次】一组限流(每条规则 +1)。仅在动作成功后调用,与 [check_rate_limits] 配对。 + + ⚠️ check→动作→record 非原子:并发突发下计数可能略超 limit(每个在途请求各 +1)。对 + 「防脚本/防轰炸」的安全网定位可接受;要精确配额需迁 Redis(见模块 docstring)。 + """ + if not settings.RATE_LIMIT_ENABLED: + return + ip = _client_ip(request) + for rule in rules: + _commit(f"{rule.scope}:{subject}:{ip}", rule.window_sec) diff --git a/app/integrations/sms.py b/app/integrations/sms.py index 3841f96..eca2a71 100644 --- a/app/integrations/sms.py +++ b/app/integrations/sms.py @@ -13,7 +13,8 @@ worker / 多机时内存不共享 → 冷却、校验都会失效,届时迁移 防刷两层(短信花钱 + `/sms/send` 在登录前无法 JWT 鉴权): 1. 单号 `SMS_SEND_INTERVAL_SEC` 冷却(本文件) - 2. 单设备(device_id)每小时频控(api 层 auth.sms_send 内 enforce_rate_limit)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 + 2. 单设备(device_id)+ IP 每小时 / 每天频控(api 层 auth.sms_send 的 check/record_rate_limits, + **只按成功发码计数** —— 被本文件单号冷却挡下的重发不占额度)+ 极光控制台 IP 白名单/防轰炸(运维侧)。 ⚠️ 原「单 IP 频控(rate_limit 依赖)」2026-06-26 按产品要求删除、改设备维度;但 device_id 客户端可伪造/轮换, 脚本轮换 id 能绕过本层 → 挡脚本狂发主要靠极光控制台侧(+ 可选 nginx 限流)。 ⚠️ 原「单号每日上限」2026-07-03 按精简要求删除(mentor 定:登录风控只留单号冷却 + 单设备频控); diff --git a/tests/test_auth.py b/tests/test_auth.py index 810c25a..fe6b79b 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -107,6 +107,67 @@ def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: assert r.status_code == 200, r.text +def test_sms_send_cooldown_reject_not_counted(client, monkeypatch) -> None: + """发码额度只算「成功发码」:被单号 60s 冷却挡下的重发(429)不占设备额度。 + 做法:同号狂发只成功 1 次、其余被冷却挡下;把小时额度设 2,证明换号后仍能再成功发 1 次 + —— 若冷却重发也计数,额度早被那几次耗尽。""" + from app.api.v1 import auth + from app.core import ratelimit + + monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True) + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2) + ratelimit._buckets.clear() + + device = "dev-cooldown" + phone_a = "13710137000" + # 首发成功(小时闸计 1/2) + assert client.post( + "/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device} + ).status_code == 200 + # 同号连发 3 次:都被单号 60s 冷却挡下 → 429,且**不占**设备额度 + for _ in range(3): + r = client.post( + "/api/v1/auth/sms/send", json={"phone": phone_a, "device_id": device} + ) + assert r.status_code == 429, r.text + # 换号再发:设备额度只用了 1/2(冷却那几次没算)→ 仍放行(计到 2/2) + assert client.post( + "/api/v1/auth/sms/send", json={"phone": "13710137001", "device_id": device} + ).status_code == 200 + # 又换号:此时小时闸已 2/2 → 429(反证成功发码确实各计了 1) + r = client.post( + "/api/v1/auth/sms/send", json={"phone": "13710137002", "device_id": device} + ) + assert r.status_code == 429, r.text + + +def test_sms_send_daily_cap(client, monkeypatch) -> None: + """每天发码上限(设备 + IP):成功发码累计到日上限即 429(用不同手机号绕开单号冷却)。 + 抬高小时闸单独测日闸;超限文案含「今日」以便前端提示明天再来。""" + from app.api.v1 import auth + from app.core import ratelimit + + monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True) + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 100) # 抬高小时闸,不干扰 + monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_DAY_PER_DEVICE", 3) + ratelimit._buckets.clear() + + device = "dev-daily" + for i in range(3): + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": f"13720137{i:03d}", "device_id": device}, + ) + assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}" + # 第 4 次:同设备同 IP 当日超限 → 429 + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": "13720137999", "device_id": device}, + ) + assert r.status_code == 429, r.text + assert "今日" in r.json()["detail"] + + def test_sms_login_device_ip_rate_limit(client, monkeypatch) -> None: """防刷:同一设备(device_id) + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。 conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。""" diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py new file mode 100644 index 0000000..0e5603e --- /dev/null +++ b/tests/test_ratelimit.py @@ -0,0 +1,57 @@ +"""ratelimit 内存桶过期清理(GC)测试。 + +回归重点:_buckets 是**全局共享**、混着不同窗口(60s 广告 / 3600s 登录 / 86400s 日闸)的 key。 +GC 必须按【每个 key 自己存的 window_sec】判过期,而不是当前调用方的窗口 —— 否则高频的 60s 端点 +触发 GC 时会把本该存活更久的 3600s/86400s 计数(如短信日闸)一并删掉,使其被反复清零、限流失效。 +用 monkeypatch 把 _GC_THRESHOLD 调 0 强制每次都扫,免造上万条(仿 test_auth 里对 sms._GC_THRESHOLD 的做法)。 +""" +from __future__ import annotations + +from app.core import ratelimit + + +def test_purge_expired_respects_each_key_own_window(monkeypatch) -> None: + """短窗口(60s)触发的 GC 只删真正过期的 key,不得删掉仍在自身窗口内的长窗口 key。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) # 强制每次都扫 + ratelimit._buckets.clear() + + now = 1_000_000.0 + # 日闸:100s 前开窗、window=86400 → 远未过期,必须保留 + ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 100, 7, 86400.0) + # 登录:1800s、window=3600 → 未过期,保留 + ratelimit._buckets["sms-login-device:D:IP"] = (now - 1800, 2, 3600.0) + # 广告:120s、window=60 → 已过期,应删 + ratelimit._buckets["ad-watch-report:IP2"] = (now - 120, 3, 60.0) + + ratelimit._purge_expired(now) + + assert "sms-send-device-daily:D:IP" in ratelimit._buckets + assert "sms-login-device:D:IP" in ratelimit._buckets + assert "ad-watch-report:IP2" not in ratelimit._buckets + + +def test_purge_expired_keeps_long_window_key_older_than_short_window(monkeypatch) -> None: + """反证旧 bug:日闸 key 已老于 3600s,旧代码在 60s/3600s 端点触发 GC 时会误删它; + 现在按自身 86400s 窗口判 → 未过期 → 必须保留。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 0) + ratelimit._buckets.clear() + + now = 2_000_000.0 + # 3700s 前开窗(> 1 小时),但 window=86400 → 未过期 + ratelimit._buckets["sms-send-device-daily:D:IP"] = (now - 3700, 20, 86400.0) + + ratelimit._purge_expired(now) + + assert "sms-send-device-daily:D:IP" in ratelimit._buckets + + +def test_purge_expired_noop_below_threshold(monkeypatch) -> None: + """未超阈值时不扫(即便有过期 key 也不动),避免每次请求都 O(n) 扫全表。""" + monkeypatch.setattr(ratelimit, "_GC_THRESHOLD", 10) + ratelimit._buckets.clear() + + now = 3_000_000.0 + ratelimit._buckets["stale:IP"] = (now - 999, 1, 60.0) # 早过期,但没超阈值 + ratelimit._purge_expired(now) + + assert "stale:IP" in ratelimit._buckets # 桶数没超阈值 → 不清理