Files
xsl cf9d500bfa 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)。
2026-08-14 20:04:46 +08:00

105 lines
3.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""手机号验证码登录路由(T11,FR-ACC-04 / FR-ACC-14)。
POST /auth/sms/send、POST /auth/sms/verify;与 auth.py(华为/dev-token/
refresh,T10)分文件以避免并行冲突,且**不 import 华为相关任何内容**——
登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity +
issue_session + TokenPairResponse)。
``_access_token``/``_token_pair`` 与 auth.py 各持一份小拷贝:私有助手不跨
路由复用,避免 T10 并行改动 auth.py 时把冲突引进本文件。
"""
from __future__ import annotations
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"])
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))