From be8154c7a548601cce4148342d0f53e57fc0db98 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Wed, 15 Jul 2026 17:55:17 +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?= 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 个用例(冷却挡下不占额度 / 每日上限);顺手删 auth.py 一个既有的未使用 import。 Co-Authored-By: Claude Opus 4.8 --- app/api/v1/auth.py | 39 +++++++++++++-------- app/core/ratelimit.py | 77 +++++++++++++++++++++++++++++++++++++++++ app/integrations/sms.py | 3 +- tests/test_auth.py | 61 ++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 16 deletions(-) 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..7301286 100644 --- a/app/core/ratelimit.py +++ b/app/core/ratelimit.py @@ -9,6 +9,7 @@ from __future__ import annotations import threading import time +from typing import NamedTuple from fastapi import HTTPException, Request, status @@ -83,3 +84,79 @@ 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)) + 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)) + if now - start >= window_sec: # 窗口过期,重置 + start, count = now, 0 + _buckets[key] = (start, count + 1) + # 顺手清理过期 key,防内存无限涨(同 [_hit]) + if len(_buckets) > 10000: + for k in [k for k, (s, _) in _buckets.items() if now - s >= window_sec]: + _buckets.pop(k, None) + + +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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""