feat(auth): 邮箱验证码登录通道(正式兜底,替代不可用的短信)——/auth/email/code + /auth/email/login,SmtpEmailSender(stdlib smtplib,无新依赖)+ FakeEmailSender(仅本地);频控对齐短信口径(60s 冷却/邮箱 1h≤5·24h≤10/IP≤20/设备≤10);新增 EMAIL_*/SMTP_* 配置与 40112/40113/42912/42913/61001 错误码

This commit is contained in:
xsl
2026-09-15 00:02:55 +08:00
parent 5c4f1fc75a
commit df460eeea8
8 changed files with 1212 additions and 3 deletions
+2
View File
@@ -15,6 +15,7 @@ from apps.api.deps import build_llm_gateway
from apps.api.routers import (
agent,
auth,
auth_email,
auth_sms,
events,
health,
@@ -84,6 +85,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
routers = (
auth.router,
auth_sms.router,
auth_email.router,
agent.router,
quota.router,
tasks.router,
+6 -2
View File
@@ -8,8 +8,12 @@
华为账号登录(T-P1-12)已实现(``/auth/huawei``):Account Kit id_token 的
OIDC 验签与 UnionID 映射在 :mod:`packages.identity.huawei`,代码先行——真实
联调待 AGC 配置(见该模块 docstring)。明确不做(防止后人误判「漏了」):
手机验证码(T-P1-12b,等短信通道)、游客/合并/注销。dev-token 端点不是正式
鉴权设计,仅为纵向切片与联调服务:``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
游客/合并/注销。dev-token 端点不是正式鉴权设计,仅为纵向切片与联调服务:
``DEV_AUTH_ENABLED`` + 本地环境双开关才放行。
短信验证码通道已由邮箱验证码通道替代(FR-ACC-14 v2 变更,TD-32 短信实现
仅作参照保留):正式兜底登录走 ``auth_email`` 路由的
``/auth/email/code`` + ``/auth/email/login``。
"""
from __future__ import annotations
+114
View File
@@ -0,0 +1,114 @@
"""邮箱验证码登录路由(FR-ACC-14 v2 变更:短信通道由邮箱通道替代)。
POST /auth/email/code、POST /auth/email/login;与 auth_sms.py 同构:
登录成功只依赖 identity 域已有设施(get_or_create_by_external_identity +
issue_session + TokenPairResponse)。设备维度输入取请求头 ``X-Device-Key``,
缺省以客户端 IP 兜底(见 packages/identity/email_login.py docstring)。
``_access_token``/``_token_pair`` 各持一份小拷贝:私有助手不跨路由复用,
与 auth_sms.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.email_login import (
EmailAuthService,
EmailLoginRequest,
EmailSendRequest,
build_email_auth_service,
)
from packages.identity.schemas import TokenPairResponse
from packages.identity.service import SessionIssued
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-email"])
#: 设备维度请求头:缺省时该维度以客户端 IP 兜底参与频控(见模块 docstring)。
DEVICE_KEY_HEADER = "X-Device-Key"
def email_auth_dep(request: Request) -> EmailAuthService:
"""惰性装配并缓存到 ``app.state``(与 auth_sms.sms_auth_dep 同一模式)。
缓存到 app.state 同时是集成测试的钩子:测试在发请求前把捕获型 fake
sender 的 service 放进 ``app.state.email_auth`` 即可拿到验证码——生产
代码不为测试留任何后门。
"""
service: EmailAuthService | None = getattr(request.app.state, "email_auth", None)
if service is None:
service = build_email_auth_service(get_settings(), get_redis())
request.app.state.email_auth = service
return service
EmailAuthDep = Annotated[EmailAuthService, Depends(email_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 _device_key(request: Request) -> str:
"""设备维度:X-Device-Key 缺省用客户端 IP(邮箱通道不做「不传不拦」)。"""
header = request.headers.get(DEVICE_KEY_HEADER)
return header.strip() if header and header.strip() else _client_ip(request)
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("/email/code", summary="发送邮箱登录验证码")
async def send_email_code(
payload: EmailSendRequest,
request: Request,
email: EmailAuthDep,
settings: SettingsDep,
) -> dict[str, object]:
# 限流/格式错误(42912/42913/40000/61001)由统一异常处理器透传给客户端
await email.request_code(payload.email, ip=_client_ip(request), device_id=_device_key(request))
return ok({"sent": True, "cooldown_seconds": settings.email_resend_cooldown_seconds})
@router.post("/email/login", summary="校验邮箱验证码并登录(双令牌)")
async def email_login(
payload: EmailLoginRequest,
request: Request,
email: EmailAuthDep,
settings: SettingsDep,
identity: IdentityServiceDep,
) -> dict[str, object]:
union_id = await email.verify(payload.email, payload.code)
user = await identity.get_or_create_by_external_identity(
provider="email", union_id=union_id, register_source="email"
)
issued = await identity.issue_session(user.user_id)
return ok(_token_pair(settings, _access_token(settings, user.user_id), issued))
+384
View File
@@ -0,0 +1,384 @@
"""邮箱验证码登录(FR-ACC-14 v2 变更:正式登录的兜底通道,替代短信)。
短信通道(``sms_login.py``)因签名/资质上不了线,产品决策改用邮箱验证码
承接「无第三方账号用户的正式登录」。本模块与 sms_login **同构**:验证码
生命周期与三重频控口径逐条对齐(6 位数字 / TTL 300s / 校验最多 5 次 /
重发冷却 60s / 单邮箱 1h≤5 / 24h≤10 / 单 IP 24h≤20 / 单设备 24h≤10),
Redis 键前缀换成 ``email:``,差异只在与通道相关的地方:
- **union_id 是明文邮箱**(``email:<小写邮箱>``,产品契约,非哈希):
邮箱本身是登录账号而非手机号那样的敏感标识,掩码纪律落在「日志与
Redis 键」两处——日志只打掩码(``ab***@example.com``),Redis 键用
``sha256(规范化邮箱)`` 前 16 位,不存明文。
- **设备维度缺省用 IP**:请求头 ``X-Device-Key`` 缺省时路由层以客户端
IP 兜底传入,保证「单设备 24h≤10」对所有请求生效(sms 通道允许不传
设备号跳过该维度,邮箱通道不做这个让步——攻击面更大,宁可误伤不可漏拦)。
- **发送通道**:``SmtpEmailSender`` 用 stdlib ``smtplib`` 同步发送、
``anyio.to_thread`` 包成异步(不引 aiosmtplib 依赖);``FakeEmailSender``
验证码明文进日志,**仅限本地开发**(``EMAIL_PROVIDER=fake`` 默认值)。
- 频控计数同样按「请求」计而非「成功发送」计(理由见 sms_login docstring)。
"""
from __future__ import annotations
import hashlib
import hmac
import re
import smtplib
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from typing import Protocol, runtime_checkable
import anyio.to_thread
from pydantic import BaseModel, Field
from packages.identity.sms_login import generate_code, hash_code
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
logger = get_logger(__name__)
#: 宽松邮箱正则(前端也校验):非空白 @ 非空白 . 非空白,长度上限交给 Pydantic。
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
#: 自然窗口的时区:与 sms/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 固定 "email")。
_EMAIL_PROVIDER = "email"
# --- 邮箱规范化与掩码 ----------------------------------------------------------
def normalize_email(raw: str) -> str:
"""规范化邮箱:去首尾空白 + 小写;宽松正则不过即 40000(不消耗频控额度)。"""
email = raw.strip().lower()
if not _EMAIL_RE.fullmatch(email) or len(email) > 254:
raise AppError(ErrorCode.BAD_REQUEST, message="邮箱格式不正确", hint="请输入正确的邮箱地址")
return email
def masked_email(email: str) -> str:
"""日志掩码:``ab***@example.com``(输入应为规范化后的邮箱)。
本地部分只留前 2 位;不足 2 位按 1 位留。域名不打码(域名不含 PII,
且运营排查需要它区分邮箱服务商)。
"""
local, _, domain = email.partition("@")
head = local[:2] if len(local) >= 2 else local[:1]
return f"{head}***@{domain}"
def _hash_key(value: str) -> str:
"""任意维度标识(IP/设备号/邮箱)进 Redis 键前的哈希:键不落明文。"""
return hashlib.sha256(value.encode()).hexdigest()[:16]
def email_hash_key(email: str) -> str:
"""邮箱在 Redis 键中的形态:sha256 前 16 位(内部先规范化,同箱恒同键)。"""
return _hash_key(normalize_email(email))
def email_union_id(email: str) -> str:
"""邮箱映射到 ``id_user_identity.union_id``(产品契约:明文小写邮箱)。"""
return f"{_EMAIL_PROVIDER}:{normalize_email(email)}"
# --- wire 模型 -----------------------------------------------------------------
class EmailSendRequest(BaseModel):
"""POST /auth/email/code 请求体。"""
email: str = Field(min_length=5, max_length=254, description="邮箱地址")
class EmailLoginRequest(BaseModel):
"""POST /auth/email/login 请求体。"""
email: str = Field(min_length=5, max_length=254)
code: str = Field(min_length=4, max_length=8, description="6 位数字验证码")
# --- 存储接口(Redis 生产 / dict 单测)------------------------------------------
@runtime_checkable
class EmailStore(Protocol):
"""验证码与频控计数的最小存储接口(与 SmsStore 同形)。"""
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 RedisEmailStore:
"""生产实现:包一层 ``redis.asyncio.Redis`` 适配到 :class:`EmailStore`。
持有 ``object`` 而非 ``Redis`` 具体类型并局部 ignore——与
``RedisSmsStore`` 相同的做法: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:计数与过期原子下发(同 RedisSmsStore)。
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 EmailSender(Protocol):
"""验证码发送通道。fake 打日志(dev),smtp 真实下发。
实现层约定:日志/异常只允许 ``masked``,不得记录邮箱明文;
``code`` 明文仅 dev 专用实现可为联调打印。
"""
async def send_code(self, email: str, masked: str, code: str) -> None: ...
class FakeEmailSender:
"""dev 专用(``EMAIL_PROVIDER=fake``,默认值):验证码打日志。
生产必须切 smtp——这里把 code 明文打进日志只是本地联调需要,
任何非 local 环境都不该用它。
"""
async def send_code(self, email: str, masked: str, code: str) -> None:
logger.info("email_code_issued", masked=masked, code=code)
class SmtpEmailSender:
"""SMTP 真实通道(stdlib smtplib 同步发送,anyio.to_thread 包成异步)。
构造时校验配置完整性(缺 SMTP_HOST/SMTP_USER/SMTP_PASS 即抛 61001
EMAIL_PROVIDER_FAILED,装配期炸,不带病上岗);发送失败/超时同样映射
61001——通道故障是外部依赖问题,不能被当成 5xxxx 内部错误稀释告警。
"""
def __init__(self, settings: Settings) -> None:
missing = [
name
for name, value in (
("SMTP_HOST", settings.smtp_host),
("SMTP_USER", settings.smtp_user),
("SMTP_PASS", settings.smtp_pass),
)
if not value
]
if missing:
# 只回显配置项名字,绝不回显已配置的值(secret 可能就在其中)
raise AppError(
ErrorCode.EMAIL_PROVIDER_FAILED,
message="邮件通道配置不完整",
data={"missing": missing},
)
self._settings = settings
def _build_message(self, email: str, code: str) -> EmailMessage:
settings = self._settings
sender = settings.mail_from or settings.smtp_user
message = EmailMessage()
message["From"] = sender
message["To"] = email
message["Subject"] = "【美搭】登录验证码"
message.set_content(
f"您的登录验证码是:{code}\n\n"
f"{settings.email_code_ttl_seconds // 60} 分钟内有效,请勿泄露给他人。\n"
"若非本人操作,请忽略本邮件。"
)
return message
def _send_sync(self, email: str, code: str) -> None:
"""同步发送本体(在 worker 线程里跑):smtplib 全程不触碰事件循环。"""
settings = self._settings
message = self._build_message(email, code)
if settings.smtp_use_ssl:
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, timeout=15) as client:
client.login(settings.smtp_user, settings.smtp_pass)
client.send_message(message)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as client:
client.starttls()
client.login(settings.smtp_user, settings.smtp_pass)
client.send_message(message)
async def send_code(self, email: str, masked: str, code: str) -> None:
try:
await anyio.to_thread.run_sync(self._send_sync, email, code)
except AppError:
raise
except Exception as exc:
# 网络超时/认证失败/被对方拒收都视为通道故障;细节只留类型名
logger.warning("email_provider_error", masked=masked, error=type(exc).__name__)
raise AppError(ErrorCode.EMAIL_PROVIDER_FAILED) from exc
# --- 服务:验证码生命周期 + 三重频控 ------------------------------------------
class EmailAuthService:
"""验证码生命周期(6 位 / 300s / 一次性 / 失败 5 次作废)+ 三重频控。
频控维度与 sms_login 对齐:同邮箱(60s 冷却 / 1h≤5 / 24h≤10)、同 IP
(24h≤20)、同设备(24h≤10,设备键由路由层以 ``X-Device-Key`` 或客户端
IP 兜底,永远参与计数——与 sms 通道「不传不拦」的差异见模块 docstring)。
"""
def __init__(self, store: EmailStore, sender: EmailSender, *, settings: Settings) -> None:
self._store = store
self._sender = sender
self._settings = settings
async def request_code(self, email: str, *, ip: str, device_id: str | None = None) -> None:
"""发送验证码:先频控后发送,全过才生成并存储随机码。"""
normalized = normalize_email(email)
ekey = email_hash_key(normalized)
masked = masked_email(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"email:cooldown:{ekey}"
# 计数按请求计(含被拒请求):与 sms_login 同一论证。
hourly = await self._store.incr(f"email:cnt:h:{ekey}:{hour}", ttl_seconds=_HOUR_BUCKET_TTL)
daily = await self._store.incr(f"email:cnt:d:{ekey}:{day}", ttl_seconds=_DAY_BUCKET_TTL)
ip_count = await self._store.incr(
f"email:cnt:ip:{_hash_key(ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
)
# 设备维度永远参与:路由层已用 X-Device-Key(缺省 IP)兜底传入。
device_count = await self._store.incr(
f"email:cnt:dev:{_hash_key(device_id or ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
)
# 检查顺序与 sms_login 一致:先报「今日已达上限」,再小时/冷却,最后维度限额。
if daily > cfg.email_daily_limit:
logger.info("email_rate_limited", masked=masked, dimension="email_daily", count=daily)
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
if hourly > cfg.email_hourly_limit:
logger.info("email_rate_limited", masked=masked, dimension="email_hourly", count=hourly)
raise AppError(ErrorCode.EMAIL_SEND_TOO_FREQUENT)
if (
cfg.email_resend_cooldown_seconds > 0
and await self._store.get(cooldown_key) is not None
):
logger.info("email_rate_limited", masked=masked, dimension="cooldown")
raise AppError(ErrorCode.EMAIL_SEND_TOO_FREQUENT)
if ip_count > cfg.email_ip_daily_limit:
logger.info("email_rate_limited", masked=masked, dimension="ip_daily", count=ip_count)
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
if device_count > cfg.email_device_daily_limit:
logger.info(
"email_rate_limited", masked=masked, dimension="device_daily", count=device_count
)
raise AppError(ErrorCode.EMAIL_DAILY_LIMIT)
code = generate_code(cfg.email_code_length)
await self._store.set(
f"email:code:{ekey}", hash_code(code), ttl_seconds=cfg.email_code_ttl_seconds
)
# 新码作废旧码的全部失败计数:「失败 5 次」按码计,不按邮箱计
await self._store.delete(f"email:attempts:{ekey}")
if cfg.email_resend_cooldown_seconds > 0:
await self._store.set(cooldown_key, "1", ttl_seconds=cfg.email_resend_cooldown_seconds)
logger.info("email_code_dispatched", masked=masked, ttl=cfg.email_code_ttl_seconds)
try:
await self._sender.send_code(normalized, masked, code)
except AppError:
raise
except Exception as exc:
# 兜底:sender 实现漏映射异常时,通道故障也不许变成 5xxxx 内部错误
logger.warning("email_sender_unexpected_error", masked=masked, error=type(exc).__name__)
raise AppError(ErrorCode.EMAIL_PROVIDER_FAILED) from exc
async def verify(
self, email: str, code: str, *, ip: str = "", device_id: str | None = None
) -> str:
"""校验验证码;成功返回该邮箱的 ``union_id``,由调用方建户/查户。
``ip``/``device_id`` 为风控审计预留(与 sms_login.verify 同口径,
校验侧的防护是「失败 5 次作废」+ 一次性删除)。
"""
normalized = normalize_email(email)
ekey = email_hash_key(normalized)
masked = masked_email(normalized)
stored = await self._store.get(f"email:code:{ekey}")
if stored is None:
# 无码 = 过期 / 从未发送 / 已用尽作废 / 已消费——统一按过期回应,
# 不区分细节,避免给攻击者探测状态的机会
raise AppError(ErrorCode.EMAIL_CODE_EXPIRED)
if not hmac.compare_digest(stored, hash_code(code)):
attempts = await self._store.incr(
f"email:attempts:{ekey}", ttl_seconds=self._settings.email_code_ttl_seconds
)
if attempts >= self._settings.email_verify_max_attempts:
await self._store.delete(f"email:code:{ekey}")
await self._store.delete(f"email:attempts:{ekey}")
logger.warning("email_code_invalidated", masked=masked, attempts=attempts)
else:
logger.info("email_code_rejected", masked=masked, attempts=attempts)
raise AppError(ErrorCode.EMAIL_CODE_INVALID)
# 一次性:成功即删(连同失败计数),二次使用按「已过期」回应
await self._store.delete(f"email:code:{ekey}")
await self._store.delete(f"email:attempts:{ekey}")
logger.info("email_code_verified", masked=masked)
return email_union_id(normalized)
def build_email_auth_service(settings: Settings, redis: object) -> EmailAuthService:
"""按 ``EMAIL_PROVIDER`` 装配(apps/api 层调用;单测直接构造 service)。"""
sender: EmailSender
if settings.email_provider == "fake":
sender = FakeEmailSender()
elif settings.email_provider == "smtp":
sender = SmtpEmailSender(settings) # 配置缺失在这里就抛 61001(构造失败)
else:
raise AppError(
ErrorCode.CONFIG_ERROR, message=f"未知 EMAIL_PROVIDER: {settings.email_provider}"
)
return EmailAuthService(RedisEmailStore(redis), sender, settings=settings)
__all__ = [
"EmailAuthService",
"EmailLoginRequest",
"EmailSendRequest",
"EmailSender",
"EmailStore",
"FakeEmailSender",
"RedisEmailStore",
"SmtpEmailSender",
"build_email_auth_service",
"email_hash_key",
"email_union_id",
"masked_email",
"normalize_email",
]
+30
View File
@@ -207,6 +207,25 @@ class Settings(BaseSettings):
sms_ip_daily_limit: int = 20
sms_device_daily_limit: int = 10
# --- 邮箱验证码登录(FR-ACC-14 v2 变更:短信通道由邮箱通道替代)------------
# fake = 验证码明文进日志(仅限本地开发,生产必须切 smtp);
# smtp = stdlib smtplib 经 anyio.to_thread 同步发送(不引 aiosmtplib 依赖)。
email_provider: str = "fake"
smtp_host: str = "" # 空 = smtp provider 不可装配(构造即 61001,不带病上岗)
smtp_port: int = 465
smtp_user: str = ""
smtp_pass: str = ""
smtp_use_ssl: bool = True
mail_from: str = "" # 空 = 发件人回退用 SMTP_USER
email_code_ttl_seconds: int = 300
email_code_length: int = 6
email_verify_max_attempts: int = 5
email_resend_cooldown_seconds: int = 60
email_hourly_limit: int = 5
email_daily_limit: int = 10
email_ip_daily_limit: int = 20
email_device_daily_limit: int = 10
# --- 极简运营后台(T-P2-09)----------------------------------------------
# 空字符串 = 后台全部端点 404(等价于未部署)。V1 极简鉴权:共享令牌,
# 后续接运营账号体系时替换。令牌只进 .env / SSM,绝不进代码。
@@ -230,6 +249,16 @@ class Settings(BaseSettings):
],
alias="REPORT_PIPELINE_STAGES_JSON",
)
# 深度诊断(tier=full)流水线:无视觉阶段(复用 basic 报告的 derived_features),
# 诊断装配复用,文案/校验换 full 专用阶段(四大块结构化产出)。
report_full_pipeline_stages: list[str] = Field(
default_factory=lambda: [
"packages.report.diagnosis_stage:DiagnosisStage",
"packages.report.full_copy_stage:FullCopyStage",
"packages.report.full_validation_stage:FullValidationStage",
],
alias="REPORT_FULL_PIPELINE_STAGES_JSON",
)
# 视觉提供者:deterministic = V1 骨架的确定性假实现(真实 RTMPose/BiRefNet
# 是 Sprint 1 结论落地后的替换项,接口位见 packages/report/vision.py)。
report_vision_provider: str = "deterministic"
@@ -254,6 +283,7 @@ class Settings(BaseSettings):
"upload_scenes",
"retrieval_rerank_weights",
"report_pipeline_stages",
"report_full_pipeline_stages",
mode="before",
)
@classmethod
+12 -1
View File
@@ -34,6 +34,8 @@ class ErrorCode(IntEnum):
SESSION_REVOKED = 40105
SMS_CODE_INVALID = 40110
SMS_CODE_EXPIRED = 40111
EMAIL_CODE_INVALID = 40112
EMAIL_CODE_EXPIRED = 40113
FORBIDDEN = 40300
QUOTA_EXHAUSTED = 40301
CONSENT_REQUIRED = 40302
@@ -46,10 +48,12 @@ class ErrorCode(IntEnum):
REPORT_EXPORT_NOT_READY = 40421
STATE_CONFLICT = 40900
TASK_BONUS_ALREADY_CLAIMED = 40910
REPORT_ALREADY_EXISTS = 40920
REPORT_ALREADY_EXISTS = 40920 # 已下线,仅历史兼容:重复生成改由 report_basic 配额兜底
RATE_LIMITED = 42900
SMS_SEND_TOO_FREQUENT = 42910
SMS_DAILY_LIMIT = 42911
EMAIL_SEND_TOO_FREQUENT = 42912
EMAIL_DAILY_LIMIT = 42913
# --- 5xxxx server -----------------------------------------------------
INTERNAL_ERROR = 50000
@@ -67,6 +71,7 @@ class ErrorCode(IntEnum):
MODERATION_UNAVAILABLE = 60005
LLM_SCENE_DEGRADED = 60006
SMS_PROVIDER_FAILED = 61000
EMAIL_PROVIDER_FAILED = 61001
@dataclass(frozen=True, slots=True)
@@ -95,6 +100,8 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.SESSION_REVOKED: ErrorMeta(401, "该会话已登出", "请重新登录"),
ErrorCode.SMS_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"),
ErrorCode.SMS_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"),
ErrorCode.EMAIL_CODE_INVALID: ErrorMeta(401, "验证码错误", "请检查后重试"),
ErrorCode.EMAIL_CODE_EXPIRED: ErrorMeta(401, "验证码已过期", "请重新获取"),
ErrorCode.FORBIDDEN: ErrorMeta(403, "没有访问权限"),
ErrorCode.QUOTA_EXHAUSTED: ErrorMeta(403, "今日额度已用完", "明天恢复,或完成任务多换几次"),
ErrorCode.CONSENT_REQUIRED: ErrorMeta(403, "需要先完成授权", "请阅读并同意后继续"),
@@ -111,10 +118,13 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
ErrorCode.TASK_BONUS_ALREADY_CLAIMED: ErrorMeta(
409, "这个周期的任务奖励已领取", "下个周期再来"
),
# 40920 已无任何 raise 方(service 终身资格硬校验已删),保留仅为兼容旧端缓存的错误文案。
ErrorCode.REPORT_ALREADY_EXISTS: ErrorMeta(409, "本期报告已生成过", "每 30 天可重新生成一次"),
ErrorCode.RATE_LIMITED: ErrorMeta(429, "操作太频繁了", "请稍后再试"),
ErrorCode.SMS_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
ErrorCode.SMS_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
ErrorCode.EMAIL_SEND_TOO_FREQUENT: ErrorMeta(429, "发送太频繁了", "请稍后再试"),
ErrorCode.EMAIL_DAILY_LIMIT: ErrorMeta(429, "今日发送次数已达上限", "明天再试"),
ErrorCode.INTERNAL_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.DB_ERROR: ErrorMeta(500, "服务开小差了", "请稍后重试"),
ErrorCode.TASK_STATE_INVALID: ErrorMeta(500, "任务状态异常", "请稍后在首页「报告」中查看"),
@@ -138,6 +148,7 @@ ERROR_META: dict[ErrorCode, ErrorMeta] = {
503, "该功能今日暂时关闭", "AI 服务今日已达服务上限,明天恢复"
),
ErrorCode.SMS_PROVIDER_FAILED: ErrorMeta(502, "短信服务暂时不可用", "请稍后重试"),
ErrorCode.EMAIL_PROVIDER_FAILED: ErrorMeta(502, "邮件服务暂时不可用", "请稍后重试"),
}
+273
View File
@@ -0,0 +1,273 @@
"""邮箱登录集成(FR-ACC-14 v2 变更)—— 真实 PostgreSQL + Redis,EMAIL_PROVIDER=fake。
测试钩子:sender 经 ``app.state.email_auth`` 注入为捕获型 fake(路由的惰性装配
优先读 app.state),验证码从 fixture 拿而不是去挖生产日志——生产代码不为
测试留任何后门。每用例用随机邮箱/IP/设备:频控计数按 UTC+8 自然日分桶,
固定值会跨用例、跨次运行、跨天污染 Redis 计数。
``email_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.email_login import EmailAuthService, RedisEmailStore
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:把发出去的码留在内存里供 login 使用。"""
def __init__(self) -> None:
self.codes: dict[str, str] = {}
async def send_code(self, email: str, masked: str, code: str) -> None:
self.codes[email] = code
def _rand_email() -> str:
# 随机 local 部分:跨运行撞桶概率可忽略
return f"it-{uuid.uuid4().hex[:12]}@example.com"
def _rand_ip() -> str:
# 随机源 IP(文档保留网段 198.51.100.0/24):让 IP 维度计数互不污染
return f"198.51.100.{uuid.uuid4().int % 254 + 1}"
@pytest.fixture(autouse=True)
async def _clean_email_counters() -> AsyncIterator[None]:
"""每用例前清空 email:* 计数键(理由同 test_auth_sms 的同名 fixture)。"""
if not _infra_available():
yield
return
from packages.shared.redis_client import get_redis
redis = await get_redis()
keys = [k async for k in redis.scan_iter(match="email:*")]
if keys:
await redis.delete(*keys)
yield
@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={"email_resend_cooldown_seconds": 0})
app = create_app(settings)
sender = CapturingSender()
app.state.email_auth = EmailAuthService(RedisEmailStore(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 _code(
http: httpx.AsyncClient, email: str, device_key: str | None = None
) -> httpx.Response:
headers = {"X-Device-Key": device_key} if device_key else {}
return await http.post("/api/v1/auth/email/code", json={"email": email}, headers=headers)
async def test_send_then_login_returns_working_token_pair(
env: tuple[Any, CapturingSender],
) -> None:
app, sender = env
email = _rand_email()
async with _client(app) as http:
sent = await _code(http, email, device_key="dev-a")
assert sent.status_code == 200
body = sent.json()
assert body["code"] == 0
assert body["data"]["sent"] is True
# cooldown_seconds 回报的是进程配置值(全局 Settings,不受 fixture 覆写影响)
assert body["data"]["cooldown_seconds"] == 60
login = await http.post(
"/api/v1/auth/email/login",
json={"email": email, "code": sender.codes[email]},
)
assert login.status_code == 200
data = login.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_email_relogin_maps_to_same_user(env: tuple[Any, CapturingSender]) -> None:
"""幂等:同邮箱重复登录返回同一 user_id(provider=email 映射唯一)。"""
app, sender = env
email = _rand_email()
async with _client(app) as http:
user_ids = []
for _ in range(2):
await _code(http, email) # cooldown=0,可立即重发取新码
login = await http.post(
"/api/v1/auth/email/login",
json={"email": email, "code": sender.codes[email]},
)
assert login.status_code == 200
user_ids.append(login.json()["data"]["user_id"])
assert user_ids[0] == user_ids[1]
async def test_email_case_insensitive_login(env: tuple[Any, CapturingSender]) -> None:
"""大小写归一:注册用小写,登录用大写混搭,映射到同一 user_id。"""
app, sender = env
email = _rand_email()
async with _client(app) as http:
await _code(http, email)
login = await http.post(
"/api/v1/auth/email/login",
json={"email": email.upper(), "code": sender.codes[email]},
)
assert login.status_code == 200
first = login.json()["data"]["user_id"]
await _code(http, email)
relogin = await http.post(
"/api/v1/auth/email/login",
json={"email": email, "code": sender.codes[email]},
)
assert relogin.status_code == 200
assert relogin.json()["data"]["user_id"] == first
async def test_scripted_flood_eleventh_send_hits_daily_limit(
env: tuple[Any, CapturingSender],
) -> None:
"""FR-ACC-14 口径的邮箱版验收:同邮箱连发 11 次。
前 5 次放行;第 6~10 次先撞 1h≤5 的小时窗(42912);第 11 次撞 24h≤10
的日窗(42913)。设备逐次更换以隔离设备维度。
"""
app, _ = env
email = _rand_email()
async with _client(app) as http:
for i in range(11):
resp = await _code(http, email, device_key=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"] == 42912, body
else:
assert resp.status_code == 429 and body["code"] == 42913, body
async def test_ip_dimension_blocks_twenty_first_send(env: tuple[Any, CapturingSender]) -> None:
"""IP 维度刷量:同一 IP 打 21 个不同邮箱,第 21 次 42913。"""
app, _ = env
ip = _rand_ip()
async with _client(app, ip=ip) as http:
for _ in range(20):
resp = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
assert resp.status_code == 200, resp.text
blocked = await _code(http, _rand_email(), device_key=f"ipdev-{uuid.uuid4().hex[:8]}")
assert blocked.status_code == 429
assert blocked.json()["code"] == 42913
async def test_device_dimension_blocks_eleventh_send(env: tuple[Any, CapturingSender]) -> None:
"""设备维度刷量:同一 X-Device-Key 换邮箱换 IP 打 11 次,第 11 次 42913。"""
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 _code(http, _rand_email(), device_key=device)
assert resp.status_code == 200, resp.text
blocked = await _code(http, _rand_email(), device_key=device)
assert blocked.status_code == 429
assert blocked.json()["code"] == 42913
async def test_device_dimension_falls_back_to_ip(env: tuple[Any, CapturingSender]) -> None:
"""缺 X-Device-Key 时设备维度以 IP 兜底:同 IP 打 11 个邮箱,第 11 次 42913。"""
app, _ = env
ip = _rand_ip()
async with _client(app, ip=ip) as http:
for _ in range(10):
resp = await _code(http, _rand_email())
assert resp.status_code == 200, resp.text
blocked = await _code(http, _rand_email())
assert blocked.status_code == 429
assert blocked.json()["code"] == 42913
async def test_five_wrong_logins_invalidate_the_code(env: tuple[Any, CapturingSender]) -> None:
"""验证码错 5 次作废——第 5 次仍报 40112,之后正确码也只剩 40113。"""
app, sender = env
email = _rand_email()
async with _client(app) as http:
await _code(http, email)
code = sender.codes[email]
wrong = code[:-1] + ("0" if code[-1] != "0" else "1") # 保证 != 真码
for _ in range(5):
resp = await http.post("/api/v1/auth/email/login", json={"email": email, "code": wrong})
assert resp.status_code == 401
assert resp.json()["code"] == 40112
# 码已作废:即使拿到正确码也只能 40113(一次性 + 作废都不留活口)
after = await http.post("/api/v1/auth/email/login", json={"email": email, "code": code})
assert after.status_code == 401
assert after.json()["code"] == 40113
async def test_login_without_code_is_expired(env: tuple[Any, CapturingSender]) -> None:
"""从未发过验证码的邮箱直接登录:40113(端点活着、校验链路通)。"""
app, _ = env
async with _client(app) as http:
resp = await http.post(
"/api/v1/auth/email/login",
json={"email": _rand_email(), "code": "000000"},
)
assert resp.status_code == 401
assert resp.json()["code"] == 40113
async def test_code_rejects_malformed_email(env: tuple[Any, CapturingSender]) -> None:
"""服务层规范化兜底:能过 Pydantic 长度但非邮箱 → 40000。"""
app, _ = env
async with _client(app) as http:
resp = await _code(http, "not-an-email")
assert resp.status_code == 400
assert resp.json()["code"] == 40000
+391
View File
@@ -0,0 +1,391 @@
"""EmailAuthService 单测(packages/identity/email_login.py)——无 IO、无网络。
存储用自写内存 dict 实现 Protocol 化的 EmailStore 接口(与 test_sms_login
同一做法,不引 fakeredis);TTL 用可注入时钟模拟过期。频控计数语义按
「请求」计(见被测模块 docstring),错误码矩阵对应 FR-ACC-14 v2 的
三重频控(邮箱版)。
"""
from __future__ import annotations
import re
import time
from collections.abc import Callable
from typing import Any
import pytest
from packages.identity.email_login import (
EmailAuthService,
FakeEmailSender,
SmtpEmailSender,
build_email_auth_service,
email_hash_key,
email_union_id,
masked_email,
normalize_email,
)
from packages.identity.sms_login import hash_code
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
EMAIL = "someone@example.com"
MASKED = "so***@example.com"
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 InMemoryEmailStore:
"""dict 实现的 EmailStore:键 → (值, 过期时刻);过期惰性清除。"""
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, email: str, masked: str, code: str) -> None:
self.sent.append((email, masked, code))
class FailingSender:
async def send_code(self, email: 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, "email_resend_cooldown_seconds": 0}
values.update(overrides)
return Settings(**values)
def _service(
settings: Settings, sender: Any | None = None
) -> tuple[EmailAuthService, InMemoryEmailStore, CapturingSender, FakeClock]:
clock = FakeClock()
store = InMemoryEmailStore(clock)
capturing = CapturingSender()
service = EmailAuthService(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"),
[
("someone@example.com", "someone@example.com"),
(" Someone@Example.COM ", "someone@example.com"),
("a.b+c@example.com", "a.b+c@example.com"),
],
)
def test_normalize_email_lowercases_and_strips(raw: str, expected: str) -> None:
assert normalize_email(raw) == expected
@pytest.mark.parametrize("bad", ["not-an-email", "a@b", "a b@c.com", "plainaddress", "@x.com"])
def test_normalize_email_rejects_invalid_formats(bad: str) -> None:
# 非法格式是 400 类错误,且不消耗频控额度(不碰存储)
with pytest.raises(AppError) as excinfo:
normalize_email(bad)
assert excinfo.value.code == ErrorCode.BAD_REQUEST
def test_masked_email_hides_local_part() -> None:
assert masked_email(EMAIL) == MASKED
assert "someone" not in masked_email(EMAIL)
def test_email_hash_key_is_16_hex_chars() -> None:
digest = email_hash_key(EMAIL)
assert len(digest) == 16
assert re.fullmatch(r"[0-9a-f]{16}", digest)
def test_email_union_id_is_plaintext_lowercase_email() -> None:
# 产品契约(与 phone 通道哈希 union_id 不同):email union_id 即明文小写邮箱
uid = email_union_id(" Someone@Example.COM ")
assert uid == "email:someone@example.com"
# --- 发送:码形态与存储纪律 ---------------------------------------------------
async def test_request_code_sends_six_digit_code() -> None:
service, _, sender, _ = _service(_settings())
await service.request_code(EMAIL, ip="1.2.3.4", device_id="dev-1")
assert len(sender.sent) == 1
email, masked, code = sender.sent[0]
assert email == EMAIL
assert masked == MASKED
assert re.fullmatch(r"\d{6}", code)
async def test_code_stored_as_sha256_never_plaintext() -> None:
service, store, sender, _ = _service(_settings())
await service.request_code(EMAIL, ip="203.0.113.7", device_id="dev-1")
code = _last_code(sender)
entry = store._data.get(f"email:code:{email_hash_key(EMAIL)}")
assert entry is not None and entry[0] == hash_code(code)
for key, (value, _) in store._data.items():
assert EMAIL not in key and EMAIL 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
# --- 频控矩阵(FR-ACC-14 v2)---------------------------------------------------
async def test_hourly_limit_sixth_request_rejected() -> None:
# 同邮箱 1h ≤ 5:第 6 次 42912
service, _, sender, _ = _service(_settings(email_hourly_limit=5))
for _ in range(5):
await service.request_code(EMAIL, ip="1.1.1.1")
with pytest.raises(AppError) as excinfo:
await service.request_code(EMAIL, ip="1.1.1.1")
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
assert len(sender.sent) == 5
async def test_daily_limit_eleventh_request_rejected() -> None:
# 同邮箱 24h ≤ 10:第 11 次 42913(小时上限抬高以单独验证日窗)
service, _, sender, _ = _service(_settings(email_hourly_limit=99, email_daily_limit=10))
for _ in range(10):
await service.request_code(EMAIL, ip="1.1.1.1")
with pytest.raises(AppError) as excinfo:
await service.request_code(EMAIL, ip="1.1.1.1")
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
assert len(sender.sent) == 10
async def test_ip_dimension_twenty_first_rejected() -> None:
# 同 IP 24h ≤ 20:每次换新邮箱 + 新设备键(设备维度缺省回退 IP,不隔离会
# 先撞设备上限 10——这正是「缺省用 IP」的语义),第 21 次 42913
service, _, _, _ = _service(_settings(email_ip_daily_limit=20))
for i in range(20):
await service.request_code(f"user{i}@example.com", ip="9.9.9.9", device_id=f"dev-{i}")
with pytest.raises(AppError) as excinfo:
await service.request_code("other@example.com", ip="9.9.9.9", device_id="dev-x")
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
async def test_device_dimension_eleventh_rejected() -> None:
# 同设备 24h ≤ 10:第 11 次 42913(邮箱与 IP 都逐次更换以隔离维度)
service, _, _, _ = _service(_settings(email_device_daily_limit=10))
for i in range(10):
await service.request_code(f"user{i}@example.com", ip=f"10.0.0.{i}", device_id="device-a")
with pytest.raises(AppError) as excinfo:
await service.request_code("other@example.com", ip="10.0.0.99", device_id="device-a")
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
async def test_device_dimension_falls_back_to_ip() -> None:
# 不传 device_id 时该维度以 IP 兜底计数:同 IP 打 11 个邮箱,第 11 次 42913
service, _, _, _ = _service(_settings(email_device_daily_limit=10, email_ip_daily_limit=99))
for i in range(10):
await service.request_code(f"user{i}@example.com", ip="10.0.0.1")
with pytest.raises(AppError) as excinfo:
await service.request_code("other@example.com", ip="10.0.0.1")
assert excinfo.value.code == ErrorCode.EMAIL_DAILY_LIMIT
async def test_cooldown_blocks_resend_within_window_and_allows_after() -> None:
# 同邮箱 60s 冷却:期内二次 42912,过窗放行(小时上限抬高排除干扰)
service, _, sender, clock = _service(
_settings(email_resend_cooldown_seconds=60, email_hourly_limit=99)
)
await service.request_code(EMAIL, ip="1.1.1.1")
with pytest.raises(AppError) as excinfo:
await service.request_code(EMAIL, ip="1.1.1.1")
assert excinfo.value.code == ErrorCode.EMAIL_SEND_TOO_FREQUENT
clock.advance(61)
await service.request_code(EMAIL, ip="1.1.1.1")
assert len(sender.sent) == 2
async def test_sender_failure_maps_to_provider_failed() -> None:
# 通道故障 → 61001(外部依赖错误,不与内部 5xxxx 混淆)
service = EmailAuthService(InMemoryEmailStore(), FailingSender(), settings=_settings())
with pytest.raises(AppError) as excinfo:
await service.request_code(EMAIL, ip="1.1.1.1")
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
# --- 校验:验证码生命周期 -----------------------------------------------------
async def test_verify_success_is_one_time() -> None:
# 一次性:成功即删,同一码二次使用按「已过期」回应
service, _, sender, _ = _service(_settings())
await service.request_code(EMAIL, ip="1.1.1.1")
code = _last_code(sender)
assert await service.verify(EMAIL, code) == email_union_id(EMAIL)
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, code)
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
async def test_verify_expired_after_ttl() -> None:
# 5 分钟 TTL:过窗后校验 40113
service, _, sender, clock = _service(_settings(email_code_ttl_seconds=300))
await service.request_code(EMAIL, ip="1.1.1.1")
clock.advance(301)
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, _last_code(sender))
assert excinfo.value.code == ErrorCode.EMAIL_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(EMAIL, ip="1.1.1.1")
wrong = _wrong_code(_last_code(sender))
for _ in range(4):
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, wrong)
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
async def test_fifth_wrong_attempt_invalidates_code() -> None:
# 第 5 次错误:返回 40112 且码已删——之后连正确码都只剩 40113
service, _, sender, _ = _service(_settings())
await service.request_code(EMAIL, ip="1.1.1.1")
correct = _last_code(sender)
for _ in range(5):
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, _wrong_code(correct))
assert excinfo.value.code == ErrorCode.EMAIL_CODE_INVALID
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, correct)
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
async def test_new_code_resets_attempt_counter() -> None:
# 「失败 5 次」按码计不按邮箱计:换新码后失败计数清零
service, _, sender, _ = _service(_settings(email_hourly_limit=99))
await service.request_code(EMAIL, ip="1.1.1.1")
for _ in range(4):
with pytest.raises(AppError):
await service.verify(EMAIL, _wrong_code(_last_code(sender)))
await service.request_code(EMAIL, ip="1.1.1.1") # cooldown=0 可立即重发
assert await service.verify(EMAIL, _last_code(sender)) == email_union_id(EMAIL)
async def test_verify_unknown_email_is_expired() -> None:
# 从未发过码的邮箱:40113(不区分「无码」细节,防状态探测)
service, _, _, _ = _service(_settings())
with pytest.raises(AppError) as excinfo:
await service.verify(EMAIL, "123456")
assert excinfo.value.code == ErrorCode.EMAIL_CODE_EXPIRED
# --- 通道装配与 SMTP 实现(不连网:桩替真实连接)-------------------------------
def test_build_service_fake_provider() -> None:
service = build_email_auth_service(_settings(email_provider="fake"), object())
assert isinstance(service._sender, FakeEmailSender)
def test_build_service_unknown_provider_is_config_error() -> None:
with pytest.raises(AppError) as excinfo:
build_email_auth_service(_settings(email_provider="pigeon"), object())
assert excinfo.value.code == ErrorCode.CONFIG_ERROR
def test_build_service_smtp_without_config_maps_61001() -> None:
# 构造失败(SMTP 配置缺失)→ 61001:装配期就炸,不带病上岗
with pytest.raises(AppError) as excinfo:
build_email_auth_service(_settings(email_provider="smtp"), object())
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED
_SMTP_SETTINGS: dict[str, Any] = {
"email_provider": "smtp",
"smtp_host": "smtp.example.com",
"smtp_user": "noreply@example.com",
"smtp_pass": "secret",
}
class _RecordingSmtpSender(SmtpEmailSender):
"""替换 _send_sync 为记录调用(不碰网络)的桩实现。"""
def __init__(self, settings: Settings) -> None:
super().__init__(settings)
self.calls: list[tuple[str, str]] = []
def _send_sync(self, email: str, code: str) -> None:
self.calls.append((email, code))
async def test_smtp_sender_builds_message_and_accepts_success() -> None:
sender = _RecordingSmtpSender(_settings(**_SMTP_SETTINGS))
await sender.send_code(EMAIL, MASKED, "123456")
assert sender.calls == [(EMAIL, "123456")]
class _ExplodingSmtpSender(SmtpEmailSender):
def _send_sync(self, email: str, code: str) -> None:
raise TimeoutError("connect timeout")
async def test_smtp_sender_network_error_maps_61001() -> None:
sender = _ExplodingSmtpSender(_settings(**_SMTP_SETTINGS))
with pytest.raises(AppError) as excinfo:
await sender.send_code(EMAIL, MASKED, "123456")
assert excinfo.value.code == ErrorCode.EMAIL_PROVIDER_FAILED