fix(auth): 登录限流改按设备(device_id)而非手机号 — 防一机狂登多号

sms-login 防刷限流键从 (手机号+IP) 改为 (device_id+IP):同一台机器换不同手机号刷登录
也受同一桶约束(5次/小时,成功/失败都计);device_id 空(老客户端)时退化为该 IP 聚一桶,仍受限。
测试号仍豁免。回归测试更新为设备维度(同设备换号仍被拦、换设备独立放行)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zzhyyyyy
2026-06-26 10:55:36 +08:00
parent 41a000304e
commit a91a04c45d
3 changed files with 35 additions and 21 deletions
+18 -9
View File
@@ -74,8 +74,8 @@ 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。
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(内存计数跨用例累加),本用例临时打开并清空计数隔离。"""
from app.api.v1 import auth
from app.core import ratelimit
@@ -83,18 +83,27 @@ def test_sms_login_phone_ip_rate_limit(client, monkeypatch) -> None:
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
phone = "13533153300"
# 前 N 次:mock 校验放行(任意 6 位数字)→ 200 登录成功
device = "dev-rl-A"
# 前 N 次故意每次换手机号、固定设备:mock 校验放行 → 200。证明限流按设备而非手机号,同设备共用一个桶
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR):
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": f"135331533{i:02d}", "code": "123456", "device_id": device},
)
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"})
# 第 N+1 次:同设备同 IP 超限 → 429(即便又换了个手机号)
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": "13533153399", "code": "123456", "device_id": device},
)
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"})
# 同 IP 换个设备 → 独立计数(限流键含 device_id、非纯 IP/手机号),仍放行
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": "13533153300", "code": "123456", "device_id": "dev-rl-B"},
)
assert r.status_code == 200, r.text
+9 -6
View File
@@ -144,11 +144,11 @@ def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
# ============================ 登录限流豁免 ============================
def test_exempt_from_phone_ip_login_rate_limit(client, enabled, monkeypatch) -> None:
"""测试号豁免 (手机号+IP) 每小时登录限流。
def test_exempt_from_device_ip_login_rate_limit(client, enabled, monkeypatch) -> None:
"""测试号豁免 (设备+IP) 每小时登录限流。
普通号同号同 IP 到 SMS_LOGIN_MAX_PER_HOUR 次即被拦(见 test_auth.test_sms_login_phone_ip_rate_limit);
测试号走自己的每日额度、不受这道限流——这里临时打开 RATE_LIMIT_ENABLED,连登远超该上限仍全 200。
普通设备同设备同 IP 到 SMS_LOGIN_MAX_PER_HOUR 次即被拦(见 test_auth.test_sms_login_device_ip_rate_limit);
测试号走自己的每日额度、不受这道限流——这里临时打开 RATE_LIMIT_ENABLED,同一设备连登远超该上限仍全 200。
回归用:防有人把 enforce_rate_limit 挪到测试账号 early-return 之前而破坏豁免。"""
from app.api.v1 import auth
from app.core import ratelimit
@@ -156,9 +156,12 @@ def test_exempt_from_phone_ip_login_rate_limit(client, enabled, monkeypatch) ->
monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
ratelimit._buckets.clear() # 隔离:清掉跨用例累加的内存计数
# 连登 (每小时上限 + 3) 次,远超普通会被拦的阈值;测试号豁免 → 全部放行
# 同一设备连登 (每小时上限 + 3) 次,远超普通设备会被拦的阈值;测试号豁免 → 全部放行
for i in range(auth.SMS_LOGIN_MAX_PER_HOUR + 3):
r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "000000"})
r = client.post(
"/api/v1/auth/sms/login",
json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-test-exempt"},
)
assert r.status_code == 200, (
f"测试号第 {i + 1} 次登录应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
)