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)。
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""手机号验证码登录路由(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))
|