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
+97 -5
View File
@@ -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))
+426
View File
@@ -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",
]
+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
+430
View File
@@ -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