Files
meida_backend/packages/identity/email_login.py
T

385 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""邮箱验证码登录(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",
]