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:
@@ -1,12 +1,104 @@
|
|||||||
"""手机号验证码登录路由(骨架,T11 填充实现)。
|
"""手机号验证码登录路由(T11,FR-ACC-04 / FR-ACC-14)。
|
||||||
|
|
||||||
POST /auth/sms/send、POST /auth/sms/verify;
|
POST /auth/sms/send、POST /auth/sms/verify;与 auth.py(华为/dev-token/
|
||||||
实现契约见 docs/开发任务包/T11-短信登录代码先行.md。
|
refresh,T10)分文件以避免并行冲突,且**不 import 华为相关任何内容**——
|
||||||
与 auth.py(华为/dev-token/refresh)分文件以避免并行冲突。
|
登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity +
|
||||||
|
issue_session + TokenPairResponse)。
|
||||||
|
|
||||||
|
``_access_token``/``_token_pair`` 与 auth.py 各持一份小拷贝:私有助手不跨
|
||||||
|
路由复用,避免 T10 并行改动 auth.py 时把冲突引进本文件。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
|
||||||
|
from apps.api.deps import IdentityServiceDep, SettingsDep
|
||||||
|
from packages.identity.schemas import TokenPairResponse
|
||||||
|
from packages.identity.service import SessionIssued
|
||||||
|
from packages.identity.sms_login import (
|
||||||
|
SmsAuthService,
|
||||||
|
SmsSendRequest,
|
||||||
|
SmsVerifyRequest,
|
||||||
|
build_sms_auth_service,
|
||||||
|
)
|
||||||
|
from packages.shared.config import Settings, get_settings
|
||||||
|
from packages.shared.redis_client import get_redis
|
||||||
|
from packages.shared.response import ok
|
||||||
|
from packages.shared.security import create_access_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth-sms"])
|
router = APIRouter(prefix="/auth", tags=["auth-sms"])
|
||||||
|
|
||||||
|
|
||||||
|
def sms_auth_dep(request: Request) -> SmsAuthService:
|
||||||
|
"""惰性装配并缓存到 ``app.state``(与 deps.gateway_dep 同一模式)。
|
||||||
|
|
||||||
|
缓存到 app.state 同时是集成测试的钩子:测试在发请求前把捕获型 fake
|
||||||
|
sender 的 service 放进 ``app.state.sms_auth`` 即可拿到验证码——生产代码
|
||||||
|
不为测试留任何后门。
|
||||||
|
"""
|
||||||
|
service: SmsAuthService | None = getattr(request.app.state, "sms_auth", None)
|
||||||
|
if service is None:
|
||||||
|
service = build_sms_auth_service(get_settings(), get_redis())
|
||||||
|
request.app.state.sms_auth = service
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
SmsAuthDep = Annotated[SmsAuthService, Depends(sms_auth_dep)]
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request: Request) -> str:
|
||||||
|
"""直连对端 IP(ASGI scope)。反向代理场景的字段解析属网关层职责,V1 不做。"""
|
||||||
|
return request.client.host if request.client is not None else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _access_token(settings: Settings, user_id: int) -> str:
|
||||||
|
return create_access_token(
|
||||||
|
user_id=user_id,
|
||||||
|
secret=settings.jwt_secret,
|
||||||
|
algorithm=settings.jwt_algorithm,
|
||||||
|
ttl_seconds=settings.jwt_access_ttl_seconds,
|
||||||
|
issuer=settings.jwt_issuer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _token_pair(
|
||||||
|
settings: Settings, access_token: str, issued: SessionIssued
|
||||||
|
) -> dict[str, str | int]:
|
||||||
|
"""与 auth.py 的 dev-token/refresh 完全一致的双令牌响应形态。"""
|
||||||
|
return TokenPairResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=issued.refresh_token,
|
||||||
|
expires_in=settings.jwt_access_ttl_seconds,
|
||||||
|
refresh_expires_in=settings.refresh_token_ttl_seconds,
|
||||||
|
user_id=str(issued.user_id),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sms/send", summary="发送短信验证码")
|
||||||
|
async def send_sms_code(
|
||||||
|
payload: SmsSendRequest, request: Request, sms: SmsAuthDep
|
||||||
|
) -> dict[str, object]:
|
||||||
|
# 限流/格式错误(42910/42911/40000/61000)由统一异常处理器透传给客户端
|
||||||
|
await sms.request_code(payload.phone, ip=_client_ip(request), device_id=payload.device_id)
|
||||||
|
return ok({"sent": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sms/verify", summary="校验验证码并登录(双令牌)")
|
||||||
|
async def verify_sms_code(
|
||||||
|
payload: SmsVerifyRequest,
|
||||||
|
request: Request,
|
||||||
|
sms: SmsAuthDep,
|
||||||
|
settings: SettingsDep,
|
||||||
|
identity: IdentityServiceDep,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
union_id = await sms.verify(
|
||||||
|
payload.phone, payload.code, ip=_client_ip(request), device_id=payload.device_id
|
||||||
|
)
|
||||||
|
user = await identity.get_or_create_by_external_identity(
|
||||||
|
provider="phone", union_id=union_id, register_source="phone"
|
||||||
|
)
|
||||||
|
issued = await identity.issue_session(user.user_id)
|
||||||
|
return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""手机号验证码登录(FR-ACC-04 / FR-ACC-14,TD-32)。
|
||||||
|
|
||||||
|
与华为通道(FR-ACC-01,T10)**零耦合**:本模块不 import 华为相关任何内容,
|
||||||
|
登录成功路径只依赖 identity 域已有设施(``get_or_create_by_external_identity``
|
||||||
|
+ ``issue_session`` 由路由层调用)。
|
||||||
|
|
||||||
|
关键纪律与设计决策:
|
||||||
|
|
||||||
|
- **明文号码不出现在 Redis 与日志**:Redis 键用 ``sha256(规范化号码)`` 前 16
|
||||||
|
位,日志一律掩码(``138****1234``);验证码在 Redis 里只存 sha256 哈希,
|
||||||
|
比对用 ``hmac.compare_digest``(恒定时间,防时序侧信道逐位猜码)。
|
||||||
|
- **号码存储决策(V1 简化)**:``id_user_identity`` 现无 ``phone_enc`` 列,
|
||||||
|
``union_id = "phone:" + sha256(号码)[:16]``(E.164 规范化:去 +86/0086/
|
||||||
|
前导零/空白后哈希);``phone_enc`` 补齐属后续迁移任务。
|
||||||
|
- **频控计数按「请求」计而非「成功发送」计**:脚本刷量时被拒的请求也要烧
|
||||||
|
额度,否则攻击者可以用被拒请求无限试探而不触发任何上限;这也是
|
||||||
|
FR-ACC-14「连发 11 次、第 11 次 42911」脚本化验收在真实时钟下成立的
|
||||||
|
前提。代价是用户连点两次会多烧一次额度——防刷优先。
|
||||||
|
- 时段桶对齐 billing 的时区纪律:统一 UTC+8 自然小时/日边界。
|
||||||
|
|
||||||
|
与任务文档的两处实现偏差(见提交信息):
|
||||||
|
|
||||||
|
1. ``SmsSender.send_code`` 收 ``(phone, masked, code)`` 三参而非单一
|
||||||
|
「掩码键」——腾讯通道下发必须有真实号码(掩码/哈希无法投递);明文
|
||||||
|
防泄漏由三层兜底:协议约定实现层不得记录 ``phone``(日志只用 ``masked``)、
|
||||||
|
structlog 全局脱敏处理器会掩码任何 11 位手机号样式的字符串、
|
||||||
|
FakeSmsSender 只打 ``masked``。
|
||||||
|
2. 频控计数自增发生在限额检查**之前**(按请求计,见上)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from tencentcloud.common.credential import Credential
|
||||||
|
from tencentcloud.sms.v20210111 import models as tencent_sms_models
|
||||||
|
from tencentcloud.sms.v20210111.sms_client_async import SmsClient as AsyncTencentSmsClient
|
||||||
|
|
||||||
|
from packages.shared.config import Settings
|
||||||
|
from packages.shared.errors import AppError, ErrorCode
|
||||||
|
from packages.shared.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
#: V1 只做大陆手机号(写死并注释):境外号码格式与通道资质都不在本批范围。
|
||||||
|
_MAINLAND_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
|
||||||
|
|
||||||
|
#: 自然窗口的时区:与 billing 的日预算桶一致(FR-BILL-01 纪律),UTC+8。
|
||||||
|
_CN_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
_HOUR_BUCKET_TTL = 2 * 60 * 60 # 小时桶键活 2h,覆盖桶期 + 少量时钟偏移
|
||||||
|
_DAY_BUCKET_TTL = 26 * 60 * 60 # 日桶键活 26h,过期由桶名(日期)保证语义正确
|
||||||
|
|
||||||
|
#: union_id 的 provider 前缀(id_user_identity.provider 固定 "phone")。
|
||||||
|
_PHONE_PROVIDER = "phone"
|
||||||
|
|
||||||
|
|
||||||
|
# --- 号码与验证码的纯函数 ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(raw: str) -> str:
|
||||||
|
"""规范化大陆手机号为 11 位裸号码。
|
||||||
|
|
||||||
|
容忍 ``+86`` / ``0086`` / ``86`` 国家码、空白、连字符、括号与前导零;
|
||||||
|
规范化后不匹配 ``^1[3-9]\\d{9}$`` 即 40000(400 类错误,不消耗频控额度
|
||||||
|
——非法格式的请求连 Redis 都不碰,避免垃圾键)。
|
||||||
|
"""
|
||||||
|
digits = re.sub(r"\D", "", raw)
|
||||||
|
if digits.startswith("0086"):
|
||||||
|
digits = digits[4:]
|
||||||
|
if len(digits) == 13 and digits.startswith("86"):
|
||||||
|
digits = digits[2:]
|
||||||
|
digits = digits.lstrip("0")
|
||||||
|
if not _MAINLAND_PHONE_RE.fullmatch(digits):
|
||||||
|
raise AppError(ErrorCode.BAD_REQUEST, message="手机号格式不正确", hint="请输入大陆手机号")
|
||||||
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
def masked_phone(phone: str) -> str:
|
||||||
|
"""日志掩码:``138****1234``(输入应为规范化后的 11 位号码)。"""
|
||||||
|
return f"{phone[:3]}****{phone[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key(value: str) -> str:
|
||||||
|
"""任意维度标识(IP/设备号)进 Redis 键前的哈希:键会进监控/RDB,不落明文。"""
|
||||||
|
return hashlib.sha256(value.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def phone_hash_key(phone: str) -> str:
|
||||||
|
"""号码在 Redis 键中的形态:sha256 前 16 位(不存明文,FR-ACC-14 掩码纪律)。"""
|
||||||
|
return hashlib.sha256(phone.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def phone_union_id(phone: str) -> str:
|
||||||
|
"""号码映射到 ``id_user_identity.union_id``(内部先规范化,同号恒同 ID)。"""
|
||||||
|
return f"{_PHONE_PROVIDER}:{phone_hash_key(normalize_phone(phone))}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code(length: int) -> str:
|
||||||
|
"""6 位数字验证码。``secrets`` 而非 ``random``:验证码是安全敏感值,
|
||||||
|
Mersenne Twister 可预测;均匀取 ``[0, 10^length)`` 后补前导零。"""
|
||||||
|
return f"{secrets.randbelow(10**length):0{length}d}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_code(code: str) -> str:
|
||||||
|
"""验证码只以 sha256 形态落 Redis:泄漏 RDB 换不回可用验证码。"""
|
||||||
|
return hashlib.sha256(code.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# --- wire 模型(任务文档允许放本模块;共享 schemas.py 不碰)-------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SmsSendRequest(BaseModel):
|
||||||
|
"""POST /auth/sms/send 请求体。
|
||||||
|
|
||||||
|
号码格式校验放服务层:规范化(+86/前导零/空白)不是一条正则能表达的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
phone: str = Field(
|
||||||
|
min_length=5, max_length=20, description="大陆手机号,容忍 +86/空格/连字符写法"
|
||||||
|
)
|
||||||
|
device_id: str | None = Field(
|
||||||
|
default=None, max_length=128, description="设备指纹;缺省则该维度不参与频控"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SmsVerifyRequest(BaseModel):
|
||||||
|
"""POST /auth/sms/verify 请求体。"""
|
||||||
|
|
||||||
|
phone: str = Field(min_length=5, max_length=20)
|
||||||
|
code: str = Field(min_length=4, max_length=8, description="6 位数字验证码")
|
||||||
|
device_id: str | None = Field(default=None, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 存储接口(Redis 生产 / dict 单测)----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SmsStore(Protocol):
|
||||||
|
"""验证码与频控计数的最小存储接口。
|
||||||
|
|
||||||
|
生产用 :class:`RedisSmsStore`(跨进程原子);单测自写内存 dict 实现
|
||||||
|
(参考 ``InMemoryCostCounter`` 的做法,不引 fakeredis)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get(self, key: str) -> str | None: ...
|
||||||
|
|
||||||
|
async def set(self, key: str, value: str, ttl_seconds: int) -> None: ...
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
async def incr(self, key: str, ttl_seconds: int) -> int: ...
|
||||||
|
|
||||||
|
|
||||||
|
class RedisSmsStore:
|
||||||
|
"""生产实现:包一层 ``redis.asyncio.Redis`` 适配到 :class:`SmsStore`。
|
||||||
|
|
||||||
|
持有 ``object`` 而非 ``Redis`` 具体类型并局部 ignore——与
|
||||||
|
``RedisCostCounter`` 相同的做法:identity 包只认接口,不 import redis 包。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, redis: object) -> None:
|
||||||
|
self._redis = redis
|
||||||
|
|
||||||
|
async def get(self, key: str) -> str | None:
|
||||||
|
value = await self._redis.get(key) # type: ignore[attr-defined]
|
||||||
|
return str(value) if value is not None else None
|
||||||
|
|
||||||
|
async def set(self, key: str, value: str, ttl_seconds: int) -> None:
|
||||||
|
await self._redis.set(key, value, ex=ttl_seconds) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
await self._redis.delete(key) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
async def incr(self, key: str, ttl_seconds: int) -> int:
|
||||||
|
# INCR + EXPIRE 走 pipeline:计数与过期原子下发,滚动续期(桶名含
|
||||||
|
# 日期/小时,键多活一会儿不影响窗口语义)。
|
||||||
|
pipe = self._redis.pipeline() # type: ignore[attr-defined]
|
||||||
|
pipe.incr(key)
|
||||||
|
pipe.expire(key, ttl_seconds)
|
||||||
|
results = await pipe.execute()
|
||||||
|
return int(results[0])
|
||||||
|
|
||||||
|
|
||||||
|
# --- 发送通道(Protocol 隔离,联调后置)--------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SmsSender(Protocol):
|
||||||
|
"""验证码发送通道。fake 打日志(dev),tencent 真实下发(待凭证)。
|
||||||
|
|
||||||
|
实现层约定:**不得记录 ``phone`` 明文**,日志/异常只允许 ``masked``;
|
||||||
|
``code`` 明文仅 dev 专用实现可为联调打印。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def send_code(self, phone: str, masked: str, code: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSmsSender:
|
||||||
|
"""dev 专用(``SMS_PROVIDER=fake``,默认值):验证码打日志。
|
||||||
|
|
||||||
|
生产必须切 tencent——这里把 code 明文打进日志只是本地联调需要,
|
||||||
|
任何非 local 环境都不该用它。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def send_code(self, phone: str, masked: str, code: str) -> None:
|
||||||
|
logger.info("sms_code_issued", masked=masked, code=code)
|
||||||
|
|
||||||
|
|
||||||
|
class TencentSmsSender:
|
||||||
|
"""腾讯云短信(真实通道,签名/模板审核通过后启用,本批不联调)。
|
||||||
|
|
||||||
|
构造失败(凭证缺失)与响应非 Ok 一律映射 61000(SMS_PROVIDER_FAILED):
|
||||||
|
通道故障是外部依赖问题,不能被当成 5xxxx 内部错误稀释告警。
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: 短信是全局服务,SDK 签名要 region 参数,按官方示例固定广州即可。
|
||||||
|
_REGION = "ap-guangzhou"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("TENCENT_SMS_SECRET_ID", settings.tencent_sms_secret_id),
|
||||||
|
("TENCENT_SMS_SECRET_KEY", settings.tencent_sms_secret_key),
|
||||||
|
("TENCENT_SMS_SDK_APP_ID", settings.tencent_sms_sdk_app_id),
|
||||||
|
("TENCENT_SMS_SIGN_NAME", settings.tencent_sms_sign_name),
|
||||||
|
("TENCENT_SMS_TEMPLATE_ID", settings.tencent_sms_template_id),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
# 只回显配置项名字,绝不回显已配置的值(secret 可能就在其中)
|
||||||
|
raise AppError(
|
||||||
|
ErrorCode.SMS_PROVIDER_FAILED,
|
||||||
|
message="短信通道凭证未配置",
|
||||||
|
data={"missing": missing},
|
||||||
|
)
|
||||||
|
self._settings = settings
|
||||||
|
self._client: object | None = None
|
||||||
|
|
||||||
|
def _ensure_client(self) -> object:
|
||||||
|
"""惰性构造 SDK 客户端:凭证明文只活在客户端对象里。"""
|
||||||
|
if self._client is None:
|
||||||
|
self._client = AsyncTencentSmsClient(
|
||||||
|
Credential(
|
||||||
|
self._settings.tencent_sms_secret_id,
|
||||||
|
self._settings.tencent_sms_secret_key,
|
||||||
|
),
|
||||||
|
self._REGION,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def send_code(self, phone: str, masked: str, code: str) -> None:
|
||||||
|
request = tencent_sms_models.SendSmsRequest()
|
||||||
|
request.PhoneNumberSet = [f"+86{phone}"] # SDK 要求 E.164(含国家码)
|
||||||
|
request.SmsSdkAppId = self._settings.tencent_sms_sdk_app_id
|
||||||
|
request.SignName = self._settings.tencent_sms_sign_name
|
||||||
|
request.TemplateId = self._settings.tencent_sms_template_id
|
||||||
|
request.TemplateParamSet = [code] # 模板变量即验证码本身
|
||||||
|
try:
|
||||||
|
resp = await self._ensure_client().SendSms(request) # type: ignore[attr-defined]
|
||||||
|
except Exception as exc:
|
||||||
|
# 网络超时/签名错误/限流都视为通道故障;异常细节只留类型名
|
||||||
|
logger.warning("sms_provider_error", masked=masked, error=type(exc).__name__)
|
||||||
|
raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc
|
||||||
|
statuses = list(getattr(resp, "SendStatusSet", None) or [])
|
||||||
|
status = statuses[0] if statuses else None
|
||||||
|
if status is None or getattr(status, "Code", None) != "Ok":
|
||||||
|
# 只打掩码与腾讯侧错误码;响应里的 PhoneNumber 是明文号码,绝不入日志
|
||||||
|
logger.warning(
|
||||||
|
"sms_send_rejected",
|
||||||
|
masked=masked,
|
||||||
|
provider_code=getattr(status, "Code", None),
|
||||||
|
)
|
||||||
|
raise AppError(ErrorCode.SMS_PROVIDER_FAILED)
|
||||||
|
|
||||||
|
|
||||||
|
# --- 服务:验证码生命周期 + 三重频控 ------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class SmsAuthService:
|
||||||
|
"""验证码生命周期(6 位 / 300s / 一次性 / 失败 5 次作废)+ 三重频控。
|
||||||
|
|
||||||
|
FR-ACC-14 的三重频控维度:同号(60s 冷却 / 1h≤5 / 24h≤10)、同 IP
|
||||||
|
(24h≤20)、同设备(24h≤10)。时段桶用 UTC+8 自然窗口,对齐 billing。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, store: SmsStore, sender: SmsSender, *, settings: Settings) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._sender = sender
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
async def request_code(self, phone: str, *, ip: str, device_id: str | None = None) -> None:
|
||||||
|
"""发送验证码:先频控后发送(FR-ACC-14),全过才生成并存储随机码。"""
|
||||||
|
normalized = normalize_phone(phone)
|
||||||
|
pkey = phone_hash_key(normalized)
|
||||||
|
masked = masked_phone(normalized)
|
||||||
|
cfg = self._settings
|
||||||
|
now = datetime.now(_CN_TZ)
|
||||||
|
day = f"{now:%Y%m%d}"
|
||||||
|
hour = f"{now:%Y%m%d%H}"
|
||||||
|
cooldown_key = f"sms:cooldown:{pkey}"
|
||||||
|
|
||||||
|
# 计数按请求计(含被拒请求):模块 docstring 论证过为什么。
|
||||||
|
hourly = await self._store.incr(f"sms:cnt:h:{pkey}:{hour}", ttl_seconds=_HOUR_BUCKET_TTL)
|
||||||
|
daily = await self._store.incr(f"sms:cnt:d:{pkey}:{day}", ttl_seconds=_DAY_BUCKET_TTL)
|
||||||
|
ip_count = await self._store.incr(
|
||||||
|
f"sms:cnt:ip:{_hash_key(ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
|
||||||
|
)
|
||||||
|
device_count = 0
|
||||||
|
if device_id:
|
||||||
|
# device_id 是客户端自报的任意串:不传则该维度不参与(宁可少拦不可误伤)
|
||||||
|
device_count = await self._store.incr(
|
||||||
|
f"sms:cnt:dev:{_hash_key(device_id)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查顺序:先报「今日已达上限」(信息量最大),再小时/冷却,最后维度限额。
|
||||||
|
if daily > cfg.sms_daily_limit:
|
||||||
|
logger.info("sms_rate_limited", masked=masked, dimension="phone_daily", count=daily)
|
||||||
|
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
|
||||||
|
if hourly > cfg.sms_hourly_limit:
|
||||||
|
logger.info("sms_rate_limited", masked=masked, dimension="phone_hourly", count=hourly)
|
||||||
|
raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT)
|
||||||
|
if cfg.sms_resend_cooldown_seconds > 0 and await self._store.get(cooldown_key) is not None:
|
||||||
|
logger.info("sms_rate_limited", masked=masked, dimension="cooldown")
|
||||||
|
raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT)
|
||||||
|
if ip_count > cfg.sms_ip_daily_limit:
|
||||||
|
logger.info("sms_rate_limited", masked=masked, dimension="ip_daily", count=ip_count)
|
||||||
|
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
|
||||||
|
if device_count > cfg.sms_device_daily_limit:
|
||||||
|
logger.info(
|
||||||
|
"sms_rate_limited", masked=masked, dimension="device_daily", count=device_count
|
||||||
|
)
|
||||||
|
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
|
||||||
|
|
||||||
|
code = generate_code(cfg.sms_code_length)
|
||||||
|
await self._store.set(
|
||||||
|
f"sms:code:{pkey}", hash_code(code), ttl_seconds=cfg.sms_code_ttl_seconds
|
||||||
|
)
|
||||||
|
# 新码作废旧码的全部失败计数:「失败 5 次」按码计,不按号计
|
||||||
|
await self._store.delete(f"sms:attempts:{pkey}")
|
||||||
|
if cfg.sms_resend_cooldown_seconds > 0:
|
||||||
|
await self._store.set(cooldown_key, "1", ttl_seconds=cfg.sms_resend_cooldown_seconds)
|
||||||
|
logger.info("sms_code_dispatched", masked=masked, ttl=cfg.sms_code_ttl_seconds)
|
||||||
|
try:
|
||||||
|
await self._sender.send_code(normalized, masked, code)
|
||||||
|
except AppError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
# 兜底:sender 实现漏映射异常时,通道故障也不许变成 5xxxx 内部错误
|
||||||
|
# 稀释告警(TencentSmsSender 自己会先映射一次,这里只接住漏网的)。
|
||||||
|
logger.warning("sms_sender_unexpected_error", masked=masked, error=type(exc).__name__)
|
||||||
|
raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc
|
||||||
|
|
||||||
|
async def verify(
|
||||||
|
self, phone: str, code: str, *, ip: str = "", device_id: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""校验验证码;成功返回该号码的 ``union_id``,由调用方建户/查户。
|
||||||
|
|
||||||
|
``ip``/``device_id`` 为风控审计预留(V1 不参与校验判定——校验侧的
|
||||||
|
防护是「失败 5 次作废」+ 一次性删除,不需要再叠频控)。
|
||||||
|
"""
|
||||||
|
normalized = normalize_phone(phone)
|
||||||
|
pkey = phone_hash_key(normalized)
|
||||||
|
masked = masked_phone(normalized)
|
||||||
|
stored = await self._store.get(f"sms:code:{pkey}")
|
||||||
|
if stored is None:
|
||||||
|
# 无码 = 过期 / 从未发送 / 已用尽作废 / 已消费——统一按过期回应,
|
||||||
|
# 不区分细节,避免给攻击者探测状态的机会
|
||||||
|
raise AppError(ErrorCode.SMS_CODE_EXPIRED)
|
||||||
|
if not hmac.compare_digest(stored, hash_code(code)):
|
||||||
|
attempts = await self._store.incr(
|
||||||
|
f"sms:attempts:{pkey}", ttl_seconds=self._settings.sms_code_ttl_seconds
|
||||||
|
)
|
||||||
|
if attempts >= self._settings.sms_verify_max_attempts:
|
||||||
|
await self._store.delete(f"sms:code:{pkey}")
|
||||||
|
await self._store.delete(f"sms:attempts:{pkey}")
|
||||||
|
logger.warning("sms_code_invalidated", masked=masked, attempts=attempts)
|
||||||
|
else:
|
||||||
|
logger.info("sms_code_rejected", masked=masked, attempts=attempts)
|
||||||
|
raise AppError(ErrorCode.SMS_CODE_INVALID)
|
||||||
|
# 一次性:成功即删(连同失败计数),二次使用按「已过期」回应
|
||||||
|
await self._store.delete(f"sms:code:{pkey}")
|
||||||
|
await self._store.delete(f"sms:attempts:{pkey}")
|
||||||
|
logger.info("sms_code_verified", masked=masked)
|
||||||
|
return f"{_PHONE_PROVIDER}:{pkey}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_sms_auth_service(settings: Settings, redis: object) -> SmsAuthService:
|
||||||
|
"""按 ``SMS_PROVIDER`` 装配(apps/api 层调用;单测直接构造 service)。"""
|
||||||
|
sender: SmsSender
|
||||||
|
if settings.sms_provider == "fake":
|
||||||
|
sender = FakeSmsSender()
|
||||||
|
elif settings.sms_provider == "tencent":
|
||||||
|
sender = TencentSmsSender(settings) # 凭证缺失在这里就抛 61000(构造失败)
|
||||||
|
else:
|
||||||
|
raise AppError(
|
||||||
|
ErrorCode.CONFIG_ERROR, message=f"未知 SMS_PROVIDER: {settings.sms_provider}"
|
||||||
|
)
|
||||||
|
return SmsAuthService(RedisSmsStore(redis), sender, settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FakeSmsSender",
|
||||||
|
"RedisSmsStore",
|
||||||
|
"SmsAuthService",
|
||||||
|
"SmsSendRequest",
|
||||||
|
"SmsSender",
|
||||||
|
"SmsStore",
|
||||||
|
"SmsVerifyRequest",
|
||||||
|
"TencentSmsSender",
|
||||||
|
"build_sms_auth_service",
|
||||||
|
"generate_code",
|
||||||
|
"hash_code",
|
||||||
|
"masked_phone",
|
||||||
|
"normalize_phone",
|
||||||
|
"phone_hash_key",
|
||||||
|
"phone_union_id",
|
||||||
|
]
|
||||||
@@ -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