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
+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, "邮件服务暂时不可用", "请稍后重试"),
}