fix(auth): 发码防刷改为按成功计数 + 新增每设备每日发码上限
- 修复:发码限流原为原子「判+记」,被单号 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 <noreply@anthropic.com>
This commit is contained in:
+24
-15
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 定:登录风控只留单号冷却 + 单设备频控);
|
||||
|
||||
@@ -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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
|
||||
|
||||
Reference in New Issue
Block a user