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:
@@ -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
|
||||
@@ -0,0 +1,430 @@
|
||||
"""SmsAuthService 单测(packages/identity/sms_login.py)——无 IO、无网络。
|
||||
|
||||
存储用自写内存 dict 实现 Protocol 化的 SmsStore 接口(参考
|
||||
InMemoryCostCounter 的做法,不引 fakeredis);TTL 用可注入时钟模拟过期。
|
||||
频控计数语义按「请求」计(见被测模块 docstring),错误码矩阵对应
|
||||
FR-ACC-14 的三重频控。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.identity.sms_login import (
|
||||
FakeSmsSender,
|
||||
SmsAuthService,
|
||||
TencentSmsSender,
|
||||
build_sms_auth_service,
|
||||
generate_code,
|
||||
hash_code,
|
||||
masked_phone,
|
||||
normalize_phone,
|
||||
phone_hash_key,
|
||||
phone_union_id,
|
||||
)
|
||||
from packages.shared.config import Settings
|
||||
from packages.shared.errors import AppError, ErrorCode
|
||||
|
||||
PHONE = "13812341234"
|
||||
MASKED = "138****1234"
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""单调时钟桩:advance 推进 TTL 判定,单测无需真实等待。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self._now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self._now += seconds
|
||||
|
||||
|
||||
class InMemorySmsStore:
|
||||
"""dict 实现的 SmsStore:键 → (值, 过期时刻);过期惰性清除。"""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] | None = None) -> None:
|
||||
self._data: dict[str, tuple[str, float]] = {}
|
||||
self._clock = clock or time.monotonic
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
value, expires_at = entry
|
||||
if self._clock() >= expires_at:
|
||||
self._data.pop(key, None)
|
||||
return None
|
||||
return value
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||
self._data[key] = (value, self._clock() + ttl_seconds)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self._data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||
current = await self.get(key) # 复用过期语义,与 Redis 行为对齐
|
||||
nxt = int(current) + 1 if current is not None else 1
|
||||
self._data[key] = (str(nxt), self._clock() + ttl_seconds)
|
||||
return nxt
|
||||
|
||||
|
||||
class CapturingSender:
|
||||
"""捕获型 fake:记下发出的 (号码, 掩码, 验证码),不打日志。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, str]] = []
|
||||
|
||||
async def send_code(self, phone: str, masked: str, code: str) -> None:
|
||||
self.sent.append((phone, masked, code))
|
||||
|
||||
|
||||
class FailingSender:
|
||||
async def send_code(self, phone: str, masked: str, code: str) -> None:
|
||||
raise RuntimeError("provider down")
|
||||
|
||||
|
||||
def _settings(**overrides: Any) -> Settings:
|
||||
"""单测配置:不吃 worktree 的 .env(_env_file=None),保持确定性。
|
||||
|
||||
默认 cooldown=0:让频控矩阵只受被测的那一个维度约束(冷却与限额分开测)。
|
||||
"""
|
||||
values: dict[str, Any] = {"_env_file": None, "sms_resend_cooldown_seconds": 0}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def _service(
|
||||
settings: Settings, sender: Any | None = None
|
||||
) -> tuple[SmsAuthService, InMemorySmsStore, CapturingSender, FakeClock]:
|
||||
clock = FakeClock()
|
||||
store = InMemorySmsStore(clock)
|
||||
capturing = CapturingSender()
|
||||
service = SmsAuthService(store, sender or capturing, settings=settings)
|
||||
return service, store, capturing, clock
|
||||
|
||||
|
||||
def _wrong_code(code: str) -> str:
|
||||
"""保证与真码不同的 6 位错码(尾位翻转)。"""
|
||||
return code[:-1] + ("0" if code[-1] != "0" else "1")
|
||||
|
||||
|
||||
def _last_code(sender: CapturingSender) -> str:
|
||||
return sender.sent[-1][2]
|
||||
|
||||
|
||||
# --- 号码规范化与掩码 ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("13812341234", "13812341234"),
|
||||
("+8613812341234", "13812341234"),
|
||||
("+86 138-1234 1234", "13812341234"),
|
||||
("008613812341234", "13812341234"),
|
||||
("8613812341234", "13812341234"),
|
||||
(" 13812341234 ", "13812341234"),
|
||||
],
|
||||
)
|
||||
def test_normalize_phone_accepts_common_writings(raw: str, expected: str) -> None:
|
||||
assert normalize_phone(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["12345", "12345678901", "23812341234", "10812341234", "abc-def", "1381234123456"]
|
||||
)
|
||||
def test_normalize_phone_rejects_invalid_formats(bad: str) -> None:
|
||||
# 非法格式是 400 类错误,且不消耗频控额度(不碰存储)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
normalize_phone(bad)
|
||||
assert excinfo.value.code == ErrorCode.BAD_REQUEST
|
||||
|
||||
|
||||
def test_masked_phone_hides_middle_digits() -> None:
|
||||
assert masked_phone(PHONE) == MASKED
|
||||
assert masked_phone(PHONE)[3:7] == "****" # 中间 4 位不出现
|
||||
|
||||
|
||||
def test_phone_hash_key_is_16_hex_chars() -> None:
|
||||
digest = phone_hash_key(PHONE)
|
||||
assert len(digest) == 16
|
||||
assert re.fullmatch(r"[0-9a-f]{16}", digest)
|
||||
|
||||
|
||||
def test_phone_union_id_is_stable_and_hashed() -> None:
|
||||
uid = phone_union_id("+8613812341234")
|
||||
assert uid.startswith("phone:")
|
||||
# 规范化后同号同 ID:+86 写法与裸号映射到同一用户
|
||||
assert uid == phone_union_id(PHONE)
|
||||
assert len(uid) == len("phone:") + 16
|
||||
assert PHONE not in uid
|
||||
|
||||
|
||||
# --- 发送:码形态与存储纪律 ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_request_code_sends_six_digit_code() -> None:
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(PHONE, ip="1.2.3.4", device_id="dev-1")
|
||||
assert len(sender.sent) == 1
|
||||
phone, masked, code = sender.sent[0]
|
||||
assert phone == PHONE
|
||||
assert masked == MASKED
|
||||
assert re.fullmatch(r"\d{6}", code)
|
||||
|
||||
|
||||
async def test_code_stored_as_sha256_never_plaintext() -> None:
|
||||
# FR-ACC-14 掩码纪律:Redis(此处为 store)里不得出现明文号码/验证码/IP
|
||||
service, store, sender, _ = _service(_settings())
|
||||
await service.request_code(PHONE, ip="203.0.113.7", device_id="dev-1")
|
||||
code = _last_code(sender)
|
||||
entry = store._data.get(f"sms:code:{phone_hash_key(PHONE)}")
|
||||
assert entry is not None and entry[0] == hash_code(code)
|
||||
for key, (value, _) in store._data.items():
|
||||
assert PHONE not in key and PHONE not in value
|
||||
assert code not in key and code not in value
|
||||
assert "203.0.113.7" not in key and "203.0.113.7" not in value
|
||||
assert "dev-1" not in key
|
||||
|
||||
|
||||
def test_generate_code_is_zero_padded_digits() -> None:
|
||||
assert re.fullmatch(r"\d{6}", generate_code(6))
|
||||
assert len(generate_code(6)) == 6
|
||||
|
||||
|
||||
def test_hash_code_is_sha256_hex() -> None:
|
||||
assert hash_code("123456") == hash_code("123456")
|
||||
assert len(hash_code("123456")) == 64
|
||||
assert "123456" not in hash_code("123456")
|
||||
|
||||
|
||||
# --- 频控矩阵(FR-ACC-14)-----------------------------------------------------
|
||||
|
||||
|
||||
async def test_hourly_limit_sixth_request_rejected() -> None:
|
||||
# 同号 1h ≤ 5:第 6 次 42910
|
||||
service, _, sender, _ = _service(_settings(sms_hourly_limit=5))
|
||||
for _ in range(5):
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.SMS_SEND_TOO_FREQUENT
|
||||
assert len(sender.sent) == 5
|
||||
|
||||
|
||||
async def test_daily_limit_eleventh_request_rejected() -> None:
|
||||
# 同号 24h ≤ 10:第 11 次 42911(小时上限抬高以单独验证日窗)
|
||||
service, _, sender, _ = _service(_settings(sms_hourly_limit=99, sms_daily_limit=10))
|
||||
for _ in range(10):
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT
|
||||
assert len(sender.sent) == 10
|
||||
|
||||
|
||||
async def test_ip_dimension_twenty_first_rejected() -> None:
|
||||
# 同 IP 24h ≤ 20:每次换新号码(同号额度不干扰),第 21 次 42911
|
||||
service, _, _, _ = _service(_settings(sms_ip_daily_limit=20))
|
||||
for i in range(20):
|
||||
await service.request_code(f"138{i:08d}", ip="9.9.9.9")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("13899999999", ip="9.9.9.9")
|
||||
assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_device_dimension_eleventh_rejected() -> None:
|
||||
# 同设备 24h ≤ 10:第 11 次 42911(号码与 IP 都逐次更换以隔离维度)
|
||||
service, _, _, _ = _service(_settings(sms_device_daily_limit=10))
|
||||
for i in range(10):
|
||||
await service.request_code(f"138{i:08d}", ip=f"10.0.0.{i}", device_id="device-a")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code("13899999999", ip="10.0.0.99", device_id="device-a")
|
||||
assert excinfo.value.code == ErrorCode.SMS_DAILY_LIMIT
|
||||
|
||||
|
||||
async def test_cooldown_blocks_resend_within_60s_and_allows_after() -> None:
|
||||
# 同号 60s 冷却:期内二次 42910,过窗放行(小时上限抬高排除干扰)
|
||||
service, _, sender, clock = _service(
|
||||
_settings(sms_resend_cooldown_seconds=60, sms_hourly_limit=99)
|
||||
)
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.SMS_SEND_TOO_FREQUENT
|
||||
clock.advance(61)
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
assert len(sender.sent) == 2
|
||||
|
||||
|
||||
async def test_sender_failure_maps_to_provider_failed() -> None:
|
||||
# 通道故障 → 61000(外部依赖错误,不与内部 5xxxx 混淆)
|
||||
service = SmsAuthService(InMemorySmsStore(), FailingSender(), settings=_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED
|
||||
|
||||
|
||||
# --- 校验:FR-ACC-04 四要素 ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_verify_success_is_one_time() -> None:
|
||||
# 一次性:成功即删,同一码二次使用按「已过期」回应
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
code = _last_code(sender)
|
||||
assert await service.verify(PHONE, code) == phone_union_id(PHONE)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, code)
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_verify_expired_after_ttl() -> None:
|
||||
# 5 分钟 TTL:过窗后校验 40111
|
||||
service, _, sender, clock = _service(_settings(sms_code_ttl_seconds=300))
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
clock.advance(301)
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, _last_code(sender))
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_four_wrong_attempts_still_allowed_then_correct_code_passes() -> None:
|
||||
# 失败 5 次作废:错 4 次仍可再试,第 5 次尝试用对码可以通过
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
wrong = _wrong_code(_last_code(sender))
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, wrong)
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_INVALID
|
||||
assert await service.verify(PHONE, _last_code(sender)) == phone_union_id(PHONE)
|
||||
|
||||
|
||||
async def test_fifth_wrong_attempt_invalidates_code() -> None:
|
||||
# 第 5 次错误:返回 40110 且码已删——之后连正确码都只剩 40111
|
||||
service, _, sender, _ = _service(_settings())
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
correct = _last_code(sender)
|
||||
for _ in range(5):
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, _wrong_code(correct))
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_INVALID
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, correct)
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED
|
||||
|
||||
|
||||
async def test_new_code_resets_attempt_counter() -> None:
|
||||
# 「失败 5 次」按码计不按号计:换新码后失败计数清零
|
||||
service, _, sender, _ = _service(_settings(sms_hourly_limit=99))
|
||||
await service.request_code(PHONE, ip="1.1.1.1")
|
||||
for _ in range(4):
|
||||
with pytest.raises(AppError):
|
||||
await service.verify(PHONE, _wrong_code(_last_code(sender)))
|
||||
await service.request_code(PHONE, ip="1.1.1.1") # cooldown=0 可立即重发
|
||||
assert await service.verify(PHONE, _last_code(sender)) == phone_union_id(PHONE)
|
||||
|
||||
|
||||
async def test_verify_unknown_phone_is_expired() -> None:
|
||||
# 从未发过码的号:40111(不区分「无码」细节,防状态探测)
|
||||
service, _, _, _ = _service(_settings())
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await service.verify(PHONE, "123456")
|
||||
assert excinfo.value.code == ErrorCode.SMS_CODE_EXPIRED
|
||||
|
||||
|
||||
# --- 通道装配与腾讯实现(不连网:桩替 SDK 客户端)-----------------------------
|
||||
|
||||
|
||||
def test_build_service_fake_provider() -> None:
|
||||
service = build_sms_auth_service(_settings(sms_provider="fake"), object())
|
||||
assert isinstance(service._sender, FakeSmsSender)
|
||||
|
||||
|
||||
def test_build_service_unknown_provider_is_config_error() -> None:
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_sms_auth_service(_settings(sms_provider="pigeon"), object())
|
||||
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
|
||||
|
||||
|
||||
def test_build_service_tencent_without_credentials_maps_61000() -> None:
|
||||
# 构造失败(凭证缺失)→ 61000:装配期就炸,不带病上岗
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
build_sms_auth_service(_settings(sms_provider="tencent"), object())
|
||||
assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED
|
||||
|
||||
|
||||
_TENCENT_SETTINGS: dict[str, Any] = {
|
||||
"sms_provider": "tencent",
|
||||
"tencent_sms_secret_id": "secret-id",
|
||||
"tencent_sms_secret_key": "secret-key",
|
||||
"tencent_sms_sdk_app_id": "1400006666",
|
||||
"tencent_sms_sign_name": "美搭",
|
||||
"tencent_sms_template_id": "1234560",
|
||||
}
|
||||
|
||||
|
||||
class _StubClient:
|
||||
"""替身 SDK 客户端:记录请求并返回固定 SendStatusSet。"""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
self._code = code
|
||||
self.requests: list[Any] = []
|
||||
|
||||
async def SendSms(self, request: Any) -> Any: # noqa: N802 - 对齐 SDK 的 API 名
|
||||
self.requests.append(request)
|
||||
status = SimpleNamespace(Code=self._code, Message="stub")
|
||||
return SimpleNamespace(SendStatusSet=[status], RequestId="stub-rid")
|
||||
|
||||
|
||||
class _StubbedTencentSender(TencentSmsSender):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(_settings(**_TENCENT_SETTINGS))
|
||||
self.stub = _StubClient(code)
|
||||
|
||||
def _ensure_client(self) -> Any:
|
||||
return self.stub
|
||||
|
||||
|
||||
class _ExplodingTencentSender(TencentSmsSender):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_settings(**_TENCENT_SETTINGS))
|
||||
|
||||
def _ensure_client(self) -> Any:
|
||||
async def send(_: Any) -> Any:
|
||||
raise TimeoutError("connect timeout")
|
||||
|
||||
return SimpleNamespace(SendSms=send)
|
||||
|
||||
|
||||
async def test_tencent_sender_builds_e164_request_and_accepts_ok() -> None:
|
||||
sender = _StubbedTencentSender("Ok")
|
||||
await sender.send_code(PHONE, MASKED, "123456")
|
||||
request = sender.stub.requests[0]
|
||||
assert request.PhoneNumberSet == ["+8613812341234"]
|
||||
assert request.TemplateParamSet == ["123456"]
|
||||
assert request.SmsSdkAppId == "1400006666"
|
||||
|
||||
|
||||
async def test_tencent_sender_rejected_status_maps_61000() -> None:
|
||||
sender = _StubbedTencentSender("LimitExceeded.PhoneNumberDailyLimit")
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await sender.send_code(PHONE, MASKED, "123456")
|
||||
assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED
|
||||
|
||||
|
||||
async def test_tencent_sender_network_error_maps_61000() -> None:
|
||||
with pytest.raises(AppError) as excinfo:
|
||||
await _ExplodingTencentSender().send_code(PHONE, MASKED, "123456")
|
||||
assert excinfo.value.code == ErrorCode.SMS_PROVIDER_FAILED
|
||||
Reference in New Issue
Block a user