feat(auth): 短信登录防刷 — 同手机号同IP 每小时限 5 次

/sms/login 增加 (手机号+IP) 每小时最多 5 次限流,挡撞库爆破/高频重登;
与路由上原有 IP 维度限流(20次/分钟,跨号)互补。
- 计数放在验证码校验之前 → 输错码的失败尝试也计数
- 测试账号(.env TEST_ACCOUNT_PHONE)豁免,走自己的每日额度
- 复用 ratelimit 内存固定窗口,受 RATE_LIMIT_ENABLED 总开关控制(默认开)
- 新增 enforce_rate_limit(供路由内按请求体字段如手机号限流)
- 补 test_auth/test_test_account 回归测试(普通号到5次被拦、测试号豁免)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zzhyyyyy
2026-06-25 17:28:59 +08:00
parent acdb10b516
commit 41a000304e
4 changed files with 90 additions and 3 deletions
+24
View File
@@ -74,6 +74,30 @@ 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_login_phone_ip_rate_limit(client, monkeypatch) -> None:
"""防刷:同一手机号 + 同一 IP 每小时最多 SMS_LOGIN_MAX_PER_HOUR 次登录尝试,超出 429。
conftest 默认 RATE_LIMIT_ENABLED=false(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
from app.api.v1 import auth
from app.core import ratelimit
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
phone = "13533153300"
# 前 N 次:mock 校验放行(任意 6 位数字)→ 200 登录成功
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR):
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, f"{i + 1} 次应放行: {r.text}"
# 第 N+1 次:同号同 IP 超限 → 429
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 429, r.text
assert "频繁" in r.json()["detail"]
# 同 IP 换个手机号 → 独立计数(限流键含手机号、非纯 IP),仍放行
r = client.post("/api/v1/auth/sms/login", json={"phone": "13533153301", "code": "123456"})
assert r.status_code == 200, r.text
def test_sms_login_bad_code(client) -> None:
"""mock 下校验位数:5 位过 schema 但业务要 6 位 → 400。"""
phone = "13700137000"