From 9ff91b81ee73fbdafd6da7cb57155d2551bea3c4 Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Fri, 26 Jun 2026 12:01:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=8F=91=E7=A0=81=E4=B9=9F?= =?UTF-8?q?=E5=8A=A0=E5=90=8C=E8=AE=BE=E5=A4=87=E5=90=8CIP=E6=AF=8F?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E9=99=90=E6=B5=81=20=E2=80=94=20=E5=A0=B5?= =?UTF-8?q?=E6=8D=A2=E5=8F=B7=E7=BB=95=E5=BC=80=E5=8D=95=E5=8F=B7=E5=86=B7?= =?UTF-8?q?=E5=8D=B4=E7=9A=84=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sms/send 增加 (device_id + IP) 每小时最多 10 次发码限流,放在真发之前(超限不烧短信)。 - 原单号 60s 冷却 / 单号每日上限都是单号维度,一机换手机号即可绕开 → 设备维度按机器封顶,挡短信轰炸/烧钱。 - 与路由上 IP 维度(10次/分钟)互补;测试号豁免。 - SmsSendRequest 加 device_id(空=按 IP 聚一桶);补回归测试。 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/v1/auth.py | 18 +++++++++++++++++- app/schemas/auth.py | 4 ++++ tests/test_auth.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index bcd4b2b..1ad66ab 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -40,6 +40,9 @@ router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) # 手机号登录防刷:同一设备(device_id) + 同一 IP 每小时最多的登录尝试次数(成功/失败都计)。 SMS_LOGIN_MAX_PER_HOUR = 5 +# 发码防刷:同一设备(device_id) + 同一 IP 每小时最多的发码次数。比登录略宽(发码含正常重发); +# 堵「换手机号绕开单号 60s 冷却 / 单号每日上限」的洞 —— 那两道是单号维度,一机换号能绕开。 +SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 10 def _login_response( @@ -89,13 +92,26 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: summary="发送短信验证码", dependencies=[Depends(rate_limit(10, 60, "sms-send"))], # 同 IP 每分钟≤10 次(防一 IP 刷不同号) ) -def sms_send(req: SmsSendRequest) -> SmsSendResponse: +def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。 # 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。 + # (也不受下面设备发码限流约束:QA 联调要反复发码。) if test_account.is_test_account(req.phone): 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)之前 → 超限直接拦下、不真发短信。与路由上 IP 维度(10次/分钟)互补。 + enforce_rate_limit( + request, + scope="sms-send-device", + subject=req.device_id, + limit=SMS_SEND_MAX_PER_HOUR_PER_DEVICE, + window_sec=3600, + detail="操作过于频繁,请稍后再试", + ) + try: cooldown = send_code(req.phone) except SmsError as e: diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3133de0..3870a52 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -71,6 +71,10 @@ class JverifyLoginRequest(BaseModel): class SmsSendRequest(BaseModel): phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶", + ) class SmsSendResponse(BaseModel): diff --git a/tests/test_auth.py b/tests/test_auth.py index 27eaf43..6288b11 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -74,6 +74,40 @@ def test_sms_send_too_frequent(client) -> None: assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 429 +def test_sms_send_device_ip_rate_limit(client, monkeypatch) -> None: + """发码防刷:同一设备(device_id) + 同一 IP 每小时最多 N 次发码,超出 429。 + 用不同手机号(绕开单号 60s 冷却)证明限流按设备封顶 —— 堵「换号绕开单号冷却/每日上限」的洞。 + conftest 默认关限流;本用例临时打开 + 调小阈值(避开同 IP 10/分钟那道)+ 清计数隔离。""" + 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", 3) + ratelimit._buckets.clear() + + device = "dev-send-A" + # 同设备、每次换不同手机号(绕开单号冷却)发码,前 3 次 200 + for i in range(3): + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": f"137001370{i:02d}", "device_id": device}, + ) + assert r.status_code == 200, f"第 {i + 1} 次应放行: {r.text}" + # 第 4 次:同设备超限 → 429(即便又换了手机号) + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": "13700137003", "device_id": device}, + ) + assert r.status_code == 429, r.text + + # 换个设备 → 独立计数(限流键含 device_id),放行 + r = client.post( + "/api/v1/auth/sms/send", + json={"phone": "13700137004", "device_id": "dev-send-B"}, + ) + assert r.status_code == 200, r.text + + 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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""