feat(identity): phone SMS login with triple rate limiting and anti-abuse

SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/
5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32;
真实发送待签名审核)。

实现:packages/identity/sms_login.py(SmsStore/SmsSender 双 Protocol、
FakeSmsSender 打日志、TencentSmsSender 包官方 SDK 失败映射 61000、
SmsAuthService 验证码生命周期 + UTC+8 时段桶频控);路由 POST
/auth/sms/send、POST /auth/sms/verify(provider="phone" 映射 +
issue_session + TokenPairResponse,与华为通道零耦合)。测试:单测 37 个
(内存 dict 实现 store、注入时钟模拟 TTL;号码规范化/掩码/频控矩阵/
验证码四要素/腾讯通道桩测),集成 7 个(send→verify 双令牌闭环、同号
同 user、脚本化刷量 11 发第 11 次 42911、IP/设备维度、错 5 次作废)。
门禁:ruff/mypy(identity+apps/api)/lint-imports 全绿,单测 275 通过、
集成 23 通过。

偏差说明:
1. SmsSender.send_code 收 (phone, masked, code) 三参而非单一「掩码键」:
   腾讯通道下发必须有真实号码(掩码/哈希无法投递);防泄漏由「实现层
   不得记录 phone」约定 + structlog 全局手机号脱敏兜底。
2. 频控计数按「请求」计(含被拒请求)且在限额检查前自增:否则被拒
   请求不烧额度,攻击者可无限试探;同时这是「连发 11 次第 11 次
   42911」脚本化验收在真实时钟下成立的前提。
3. request_code 对 sender 逃逸的非 AppError 异常兜底包成 61000:
   通道故障不得以 5xxxx 内部错误形式稀释告警(TencentSmsSender 自身
   映射为主,此处仅接漏网)。
4. wire 请求模型(SmsSendRequest/SmsVerifyRequest)定义在 sms_login.py
   内(任务文档允许的两选一,未新建 sms_schemas.py)。
This commit is contained in:
xsl
2026-08-14 20:04:46 +08:00
parent d5f366fa4c
commit cf9d500bfa
4 changed files with 1160 additions and 5 deletions
+207
View File
@@ -0,0 +1,207 @@
"""短信登录集成(T11,FR-ACC-04/14)—— 真实 PostgreSQL + Redis,SMS_PROVIDER=fake。
测试钩子:sender 经 ``app.state.sms_auth`` 注入为捕获型 fake(路由的惰性装配
优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为
测试留任何后门。每用例用随机号码/IP/设备:频控计数按 UTC+8 自然日分桶,
固定值会跨用例、跨次运行、跨天污染 Redis 计数。
``sms_resend_cooldown_seconds`` 覆写为 0:同号二次登录(重复发送)与刷量
循环不必等 60s 真实冷却;冷却语义在单测里用注入时钟验证。
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
import pytest
from packages.identity.sms_login import RedisSmsStore, SmsAuthService
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
class CapturingSender:
"""捕获型 fake sender:把发出去的码留在内存里供 verify 使用。"""
def __init__(self) -> None:
self.codes: dict[str, str] = {}
async def send_code(self, phone: str, masked: str, code: str) -> None:
self.codes[phone] = code
def _rand_phone() -> str:
# "139" + 8 位随机数字:合法大陆号形;随机空间 1e8,跨运行撞桶概率可忽略
return f"139{uuid.uuid4().int % 10**8:08d}"
def _rand_ip() -> str:
# 随机源 IP(文档保留网段 198.51.100.0/24):让 IP 维度计数互不污染
return f"198.51.100.{uuid.uuid4().int % 254 + 1}"
@pytest.fixture
async def env() -> AsyncIterator[tuple[Any, CapturingSender]]:
from apps.api.main import create_app
from packages.shared.config import get_settings
from packages.shared.redis_client import get_redis
settings = get_settings().model_copy(update={"sms_resend_cooldown_seconds": 0})
app = create_app(settings)
sender = CapturingSender()
app.state.sms_auth = SmsAuthService(RedisSmsStore(get_redis()), sender, settings=settings)
async with app.router.lifespan_context(app):
yield app, sender
@asynccontextmanager
async def _client(app: Any, ip: str | None = None) -> AsyncIterator[httpx.AsyncClient]:
"""ASGI 客户端;``client=(ip, port)`` 决定 Request.client.host(IP 维度输入)。"""
transport = httpx.ASGITransport(app=app, client=(ip or _rand_ip(), 54321))
async with httpx.AsyncClient(transport=transport, base_url="http://test") as http:
yield http
async def _send(
http: httpx.AsyncClient, phone: str, device_id: str | None = None
) -> httpx.Response:
return await http.post("/api/v1/auth/sms/send", json={"phone": phone, "device_id": device_id})
async def test_send_then_verify_returns_working_token_pair(
env: tuple[Any, CapturingSender],
) -> None:
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
sent = await _send(http, phone, device_id="dev-a")
assert sent.status_code == 200
body = sent.json()
assert body["code"] == 0
assert body["data"] == {"sent": True}
verify = await http.post(
"/api/v1/auth/sms/verify",
json={"phone": phone, "code": sender.codes[phone], "device_id": "dev-a"},
)
assert verify.status_code == 200
data = verify.json()["data"]
assert data["access_token"]
assert data["refresh_token"]
assert data["expires_in"] > 0
assert data["refresh_expires_in"] > 0
assert data["user_id"]
# 新签发的 access 立即可用(双令牌链路闭环)
quota = await http.get(
"/api/v1/quota", headers={"Authorization": f"Bearer {data['access_token']}"}
)
assert quota.status_code == 200
assert quota.json()["code"] == 0
async def test_same_phone_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None:
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
user_ids = []
for _ in range(2):
await _send(http, phone) # cooldown=0,可立即重发取新码
verify = await http.post(
"/api/v1/auth/sms/verify", json={"phone": phone, "code": sender.codes[phone]}
)
assert verify.status_code == 200
user_ids.append(verify.json()["data"]["user_id"])
# provider="phone" 映射唯一:同号两次登录拿到同一 user_id
assert user_ids[0] == user_ids[1]
async def test_scripted_flood_eleventh_send_hits_daily_limit(
env: tuple[Any, CapturingSender],
) -> None:
"""FR-ACC-14 验收原文:脚本化刷量证明拦截有效——同号连发 11 次。
前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42910);第 11 次撞 24h≤10
的日窗(42911)。设备逐次更换以隔离设备维度。
"""
app, _ = env
phone = _rand_phone()
async with _client(app) as http:
for i in range(11):
resp = await _send(http, phone, device_id=f"flood-dev-{i}")
body = resp.json()
if i < 5:
assert resp.status_code == 200, body
elif i < 10:
assert resp.status_code == 429 and body["code"] == 42910, body
else:
assert resp.status_code == 429 and body["code"] == 42911, body
async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None:
"""IP 维度刷量:同一 IP 打 21 个不同号码,第 21 次 42911。"""
app, _ = env
ip = _rand_ip()
async with _client(app, ip=ip) as http:
for _ in range(20):
resp = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}")
assert resp.status_code == 200, resp.text
blocked = await _send(http, _rand_phone(), device_id=f"ipdev-{uuid.uuid4().hex[:8]}")
assert blocked.status_code == 429
assert blocked.json()["code"] == 42911
async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None:
"""设备维度刷量:同一设备换号换 IP 打 11 次,第 11 次 42911。"""
app, _ = env
device = f"dev-{uuid.uuid4().hex[:12]}"
async with _client(app) as http: # 本用例客户端 IP 固定但仅 11 次发送 < IP 上限 20
for _ in range(10):
resp = await _send(http, _rand_phone(), device_id=device)
assert resp.status_code == 200, resp.text
blocked = await _send(http, _rand_phone(), device_id=device)
assert blocked.status_code == 429
assert blocked.json()["code"] == 42911
async def test_five_wrong_verifies_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None:
"""FR-ACC-04:验证码错 5 次作废——第 5 次仍报 40110,之后正确码也只剩 40111。"""
app, sender = env
phone = _rand_phone()
async with _client(app) as http:
await _send(http, phone)
code = sender.codes[phone]
wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码
for _ in range(5):
resp = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": wrong})
assert resp.status_code == 401
assert resp.json()["code"] == 40110
# 码已作废:即使拿到正确码也只能 40111(一次性 + 作废都不留活口)
after = await http.post("/api/v1/auth/sms/verify", json={"phone": phone, "code": code})
assert after.status_code == 401
assert after.json()["code"] == 40111
async def test_send_rejects_malformed_phone(env: tuple[Any, CapturingSender]) -> None:
"""服务层规范化兜底:能过 Pydantic 长度但非大陆号形 → 40000。"""
app, _ = env
async with _client(app) as http:
resp = await _send(http, "00000")
assert resp.status_code == 400
assert resp.json()["code"] == 40000