merge: T11 手机号验证码登录与三重防刷(FR-ACC-04/14,TD-32)

This commit is contained in:
xsl
2026-08-14 20:05:43 +08:00
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;
实现契约见 docs/开发任务包/T11-短信登录代码先行.md。
与 auth.py(华为/dev-token/refresh)分文件以避免并行冲突。
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 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"])
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))