"""邮箱验证码登录(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", ]