Files
meida_backend/packages/identity/sms_login.py
T
xsl cf9d500bfa feat(identity): phone SMS login with triple rate limiting and anti-abuse
SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/
5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32;
真实发送待签名审核)。

实现:packages/identity/sms_login.py(SmsStore/SmsSender 双 Protocol、
FakeSmsSender 打日志、TencentSmsSender 包官方 SDK 失败映射 61000、
SmsAuthService 验证码生命周期 + UTC+8 时段桶频控);路由 POST
/auth/sms/send、POST /auth/sms/verify(provider="phone" 映射 +
issue_session + TokenPairResponse,与华为通道零耦合)。测试:单测 37 个
(内存 dict 实现 store、注入时钟模拟 TTL;号码规范化/掩码/频控矩阵/
验证码四要素/腾讯通道桩测),集成 7 个(send→verify 双令牌闭环、同号
同 user、脚本化刷量 11 发第 11 次 42911、IP/设备维度、错 5 次作废)。
门禁:ruff/mypy(identity+apps/api)/lint-imports 全绿,单测 275 通过、
集成 23 通过。

偏差说明:
1. SmsSender.send_code 收 (phone, masked, code) 三参而非单一「掩码键」:
   腾讯通道下发必须有真实号码(掩码/哈希无法投递);防泄漏由「实现层
   不得记录 phone」约定 + structlog 全局手机号脱敏兜底。
2. 频控计数按「请求」计(含被拒请求)且在限额检查前自增:否则被拒
   请求不烧额度,攻击者可无限试探;同时这是「连发 11 次第 11 次
   42911」脚本化验收在真实时钟下成立的前提。
3. request_code 对 sender 逃逸的非 AppError 异常兜底包成 61000:
   通道故障不得以 5xxxx 内部错误形式稀释告警(TencentSmsSender 自身
   映射为主,此处仅接漏网)。
4. wire 请求模型(SmsSendRequest/SmsVerifyRequest)定义在 sms_login.py
   内(任务文档允许的两选一,未新建 sms_schemas.py)。
2026-08-14 20:04:46 +08:00

427 lines
18 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-04 / FR-ACC-14,TD-32)。
与华为通道(FR-ACC-01,T10)**零耦合**:本模块不 import 华为相关任何内容,
登录成功路径只依赖 identity 域已有设施(``get_or_create_by_external_identity``
+ ``issue_session`` 由路由层调用)。
关键纪律与设计决策:
- **明文号码不出现在 Redis 与日志**:Redis 键用 ``sha256(规范化号码)`` 前 16
位,日志一律掩码(``138****1234``);验证码在 Redis 里只存 sha256 哈希,
比对用 ``hmac.compare_digest``(恒定时间,防时序侧信道逐位猜码)。
- **号码存储决策(V1 简化)**:``id_user_identity`` 现无 ``phone_enc`` 列,
``union_id = "phone:" + sha256(号码)[:16]``(E.164 规范化:去 +86/0086/
前导零/空白后哈希);``phone_enc`` 补齐属后续迁移任务。
- **频控计数按「请求」计而非「成功发送」计**:脚本刷量时被拒的请求也要烧
额度,否则攻击者可以用被拒请求无限试探而不触发任何上限;这也是
FR-ACC-14「连发 11 次、第 11 次 42911」脚本化验收在真实时钟下成立的
前提。代价是用户连点两次会多烧一次额度——防刷优先。
- 时段桶对齐 billing 的时区纪律:统一 UTC+8 自然小时/日边界。
与任务文档的两处实现偏差(见提交信息):
1. ``SmsSender.send_code`` 收 ``(phone, masked, code)`` 三参而非单一
「掩码键」——腾讯通道下发必须有真实号码(掩码/哈希无法投递);明文
防泄漏由三层兜底:协议约定实现层不得记录 ``phone``(日志只用 ``masked``)、
structlog 全局脱敏处理器会掩码任何 11 位手机号样式的字符串、
FakeSmsSender 只打 ``masked``。
2. 频控计数自增发生在限额检查**之前**(按请求计,见上)。
"""
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Protocol, runtime_checkable
from pydantic import BaseModel, Field
from tencentcloud.common.credential import Credential
from tencentcloud.sms.v20210111 import models as tencent_sms_models
from tencentcloud.sms.v20210111.sms_client_async import SmsClient as AsyncTencentSmsClient
from packages.shared.config import Settings
from packages.shared.errors import AppError, ErrorCode
from packages.shared.logging import get_logger
logger = get_logger(__name__)
#: V1 只做大陆手机号(写死并注释):境外号码格式与通道资质都不在本批范围。
_MAINLAND_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
#: 自然窗口的时区:与 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 固定 "phone")。
_PHONE_PROVIDER = "phone"
# --- 号码与验证码的纯函数 ----------------------------------------------------
def normalize_phone(raw: str) -> str:
"""规范化大陆手机号为 11 位裸号码。
容忍 ``+86`` / ``0086`` / ``86`` 国家码、空白、连字符、括号与前导零;
规范化后不匹配 ``^1[3-9]\\d{9}$`` 即 40000(400 类错误,不消耗频控额度
——非法格式的请求连 Redis 都不碰,避免垃圾键)。
"""
digits = re.sub(r"\D", "", raw)
if digits.startswith("0086"):
digits = digits[4:]
if len(digits) == 13 and digits.startswith("86"):
digits = digits[2:]
digits = digits.lstrip("0")
if not _MAINLAND_PHONE_RE.fullmatch(digits):
raise AppError(ErrorCode.BAD_REQUEST, message="手机号格式不正确", hint="请输入大陆手机号")
return digits
def masked_phone(phone: str) -> str:
"""日志掩码:``138****1234``(输入应为规范化后的 11 位号码)。"""
return f"{phone[:3]}****{phone[-4:]}"
def _hash_key(value: str) -> str:
"""任意维度标识(IP/设备号)进 Redis 键前的哈希:键会进监控/RDB,不落明文。"""
return hashlib.sha256(value.encode()).hexdigest()[:16]
def phone_hash_key(phone: str) -> str:
"""号码在 Redis 键中的形态:sha256 前 16 位(不存明文,FR-ACC-14 掩码纪律)。"""
return hashlib.sha256(phone.encode()).hexdigest()[:16]
def phone_union_id(phone: str) -> str:
"""号码映射到 ``id_user_identity.union_id``(内部先规范化,同号恒同 ID)。"""
return f"{_PHONE_PROVIDER}:{phone_hash_key(normalize_phone(phone))}"
def generate_code(length: int) -> str:
"""6 位数字验证码。``secrets`` 而非 ``random``:验证码是安全敏感值,
Mersenne Twister 可预测;均匀取 ``[0, 10^length)`` 后补前导零。"""
return f"{secrets.randbelow(10**length):0{length}d}"
def hash_code(code: str) -> str:
"""验证码只以 sha256 形态落 Redis:泄漏 RDB 换不回可用验证码。"""
return hashlib.sha256(code.encode()).hexdigest()
# --- wire 模型(任务文档允许放本模块;共享 schemas.py 不碰)-------------------
class SmsSendRequest(BaseModel):
"""POST /auth/sms/send 请求体。
号码格式校验放服务层:规范化(+86/前导零/空白)不是一条正则能表达的。
"""
phone: str = Field(
min_length=5, max_length=20, description="大陆手机号,容忍 +86/空格/连字符写法"
)
device_id: str | None = Field(
default=None, max_length=128, description="设备指纹;缺省则该维度不参与频控"
)
class SmsVerifyRequest(BaseModel):
"""POST /auth/sms/verify 请求体。"""
phone: str = Field(min_length=5, max_length=20)
code: str = Field(min_length=4, max_length=8, description="6 位数字验证码")
device_id: str | None = Field(default=None, max_length=128)
# --- 存储接口(Redis 生产 / dict 单测)----------------------------------------
@runtime_checkable
class SmsStore(Protocol):
"""验证码与频控计数的最小存储接口。
生产用 :class:`RedisSmsStore`(跨进程原子);单测自写内存 dict 实现
(参考 ``InMemoryCostCounter`` 的做法,不引 fakeredis)。
"""
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 RedisSmsStore:
"""生产实现:包一层 ``redis.asyncio.Redis`` 适配到 :class:`SmsStore`。
持有 ``object`` 而非 ``Redis`` 具体类型并局部 ignore——与
``RedisCostCounter`` 相同的做法: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:计数与过期原子下发,滚动续期(桶名含
# 日期/小时,键多活一会儿不影响窗口语义)。
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 SmsSender(Protocol):
"""验证码发送通道。fake 打日志(dev),tencent 真实下发(待凭证)。
实现层约定:**不得记录 ``phone`` 明文**,日志/异常只允许 ``masked``;
``code`` 明文仅 dev 专用实现可为联调打印。
"""
async def send_code(self, phone: str, masked: str, code: str) -> None: ...
class FakeSmsSender:
"""dev 专用(``SMS_PROVIDER=fake``,默认值):验证码打日志。
生产必须切 tencent——这里把 code 明文打进日志只是本地联调需要,
任何非 local 环境都不该用它。
"""
async def send_code(self, phone: str, masked: str, code: str) -> None:
logger.info("sms_code_issued", masked=masked, code=code)
class TencentSmsSender:
"""腾讯云短信(真实通道,签名/模板审核通过后启用,本批不联调)。
构造失败(凭证缺失)与响应非 Ok 一律映射 61000(SMS_PROVIDER_FAILED):
通道故障是外部依赖问题,不能被当成 5xxxx 内部错误稀释告警。
"""
#: 短信是全局服务,SDK 签名要 region 参数,按官方示例固定广州即可。
_REGION = "ap-guangzhou"
def __init__(self, settings: Settings) -> None:
missing = [
name
for name, value in (
("TENCENT_SMS_SECRET_ID", settings.tencent_sms_secret_id),
("TENCENT_SMS_SECRET_KEY", settings.tencent_sms_secret_key),
("TENCENT_SMS_SDK_APP_ID", settings.tencent_sms_sdk_app_id),
("TENCENT_SMS_SIGN_NAME", settings.tencent_sms_sign_name),
("TENCENT_SMS_TEMPLATE_ID", settings.tencent_sms_template_id),
)
if not value
]
if missing:
# 只回显配置项名字,绝不回显已配置的值(secret 可能就在其中)
raise AppError(
ErrorCode.SMS_PROVIDER_FAILED,
message="短信通道凭证未配置",
data={"missing": missing},
)
self._settings = settings
self._client: object | None = None
def _ensure_client(self) -> object:
"""惰性构造 SDK 客户端:凭证明文只活在客户端对象里。"""
if self._client is None:
self._client = AsyncTencentSmsClient(
Credential(
self._settings.tencent_sms_secret_id,
self._settings.tencent_sms_secret_key,
),
self._REGION,
)
return self._client
async def send_code(self, phone: str, masked: str, code: str) -> None:
request = tencent_sms_models.SendSmsRequest()
request.PhoneNumberSet = [f"+86{phone}"] # SDK 要求 E.164(含国家码)
request.SmsSdkAppId = self._settings.tencent_sms_sdk_app_id
request.SignName = self._settings.tencent_sms_sign_name
request.TemplateId = self._settings.tencent_sms_template_id
request.TemplateParamSet = [code] # 模板变量即验证码本身
try:
resp = await self._ensure_client().SendSms(request) # type: ignore[attr-defined]
except Exception as exc:
# 网络超时/签名错误/限流都视为通道故障;异常细节只留类型名
logger.warning("sms_provider_error", masked=masked, error=type(exc).__name__)
raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc
statuses = list(getattr(resp, "SendStatusSet", None) or [])
status = statuses[0] if statuses else None
if status is None or getattr(status, "Code", None) != "Ok":
# 只打掩码与腾讯侧错误码;响应里的 PhoneNumber 是明文号码,绝不入日志
logger.warning(
"sms_send_rejected",
masked=masked,
provider_code=getattr(status, "Code", None),
)
raise AppError(ErrorCode.SMS_PROVIDER_FAILED)
# --- 服务:验证码生命周期 + 三重频控 ------------------------------------------
class SmsAuthService:
"""验证码生命周期(6 位 / 300s / 一次性 / 失败 5 次作废)+ 三重频控。
FR-ACC-14 的三重频控维度:同号(60s 冷却 / 1h≤5 / 24h≤10)、同 IP
(24h≤20)、同设备(24h≤10)。时段桶用 UTC+8 自然窗口,对齐 billing。
"""
def __init__(self, store: SmsStore, sender: SmsSender, *, settings: Settings) -> None:
self._store = store
self._sender = sender
self._settings = settings
async def request_code(self, phone: str, *, ip: str, device_id: str | None = None) -> None:
"""发送验证码:先频控后发送(FR-ACC-14),全过才生成并存储随机码。"""
normalized = normalize_phone(phone)
pkey = phone_hash_key(normalized)
masked = masked_phone(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"sms:cooldown:{pkey}"
# 计数按请求计(含被拒请求):模块 docstring 论证过为什么。
hourly = await self._store.incr(f"sms:cnt:h:{pkey}:{hour}", ttl_seconds=_HOUR_BUCKET_TTL)
daily = await self._store.incr(f"sms:cnt:d:{pkey}:{day}", ttl_seconds=_DAY_BUCKET_TTL)
ip_count = await self._store.incr(
f"sms:cnt:ip:{_hash_key(ip)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
)
device_count = 0
if device_id:
# device_id 是客户端自报的任意串:不传则该维度不参与(宁可少拦不可误伤)
device_count = await self._store.incr(
f"sms:cnt:dev:{_hash_key(device_id)}:{day}", ttl_seconds=_DAY_BUCKET_TTL
)
# 检查顺序:先报「今日已达上限」(信息量最大),再小时/冷却,最后维度限额。
if daily > cfg.sms_daily_limit:
logger.info("sms_rate_limited", masked=masked, dimension="phone_daily", count=daily)
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
if hourly > cfg.sms_hourly_limit:
logger.info("sms_rate_limited", masked=masked, dimension="phone_hourly", count=hourly)
raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT)
if cfg.sms_resend_cooldown_seconds > 0 and await self._store.get(cooldown_key) is not None:
logger.info("sms_rate_limited", masked=masked, dimension="cooldown")
raise AppError(ErrorCode.SMS_SEND_TOO_FREQUENT)
if ip_count > cfg.sms_ip_daily_limit:
logger.info("sms_rate_limited", masked=masked, dimension="ip_daily", count=ip_count)
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
if device_count > cfg.sms_device_daily_limit:
logger.info(
"sms_rate_limited", masked=masked, dimension="device_daily", count=device_count
)
raise AppError(ErrorCode.SMS_DAILY_LIMIT)
code = generate_code(cfg.sms_code_length)
await self._store.set(
f"sms:code:{pkey}", hash_code(code), ttl_seconds=cfg.sms_code_ttl_seconds
)
# 新码作废旧码的全部失败计数:「失败 5 次」按码计,不按号计
await self._store.delete(f"sms:attempts:{pkey}")
if cfg.sms_resend_cooldown_seconds > 0:
await self._store.set(cooldown_key, "1", ttl_seconds=cfg.sms_resend_cooldown_seconds)
logger.info("sms_code_dispatched", masked=masked, ttl=cfg.sms_code_ttl_seconds)
try:
await self._sender.send_code(normalized, masked, code)
except AppError:
raise
except Exception as exc:
# 兜底:sender 实现漏映射异常时,通道故障也不许变成 5xxxx 内部错误
# 稀释告警(TencentSmsSender 自己会先映射一次,这里只接住漏网的)。
logger.warning("sms_sender_unexpected_error", masked=masked, error=type(exc).__name__)
raise AppError(ErrorCode.SMS_PROVIDER_FAILED) from exc
async def verify(
self, phone: str, code: str, *, ip: str = "", device_id: str | None = None
) -> str:
"""校验验证码;成功返回该号码的 ``union_id``,由调用方建户/查户。
``ip``/``device_id`` 为风控审计预留(V1 不参与校验判定——校验侧的
防护是「失败 5 次作废」+ 一次性删除,不需要再叠频控)。
"""
normalized = normalize_phone(phone)
pkey = phone_hash_key(normalized)
masked = masked_phone(normalized)
stored = await self._store.get(f"sms:code:{pkey}")
if stored is None:
# 无码 = 过期 / 从未发送 / 已用尽作废 / 已消费——统一按过期回应,
# 不区分细节,避免给攻击者探测状态的机会
raise AppError(ErrorCode.SMS_CODE_EXPIRED)
if not hmac.compare_digest(stored, hash_code(code)):
attempts = await self._store.incr(
f"sms:attempts:{pkey}", ttl_seconds=self._settings.sms_code_ttl_seconds
)
if attempts >= self._settings.sms_verify_max_attempts:
await self._store.delete(f"sms:code:{pkey}")
await self._store.delete(f"sms:attempts:{pkey}")
logger.warning("sms_code_invalidated", masked=masked, attempts=attempts)
else:
logger.info("sms_code_rejected", masked=masked, attempts=attempts)
raise AppError(ErrorCode.SMS_CODE_INVALID)
# 一次性:成功即删(连同失败计数),二次使用按「已过期」回应
await self._store.delete(f"sms:code:{pkey}")
await self._store.delete(f"sms:attempts:{pkey}")
logger.info("sms_code_verified", masked=masked)
return f"{_PHONE_PROVIDER}:{pkey}"
def build_sms_auth_service(settings: Settings, redis: object) -> SmsAuthService:
"""按 ``SMS_PROVIDER`` 装配(apps/api 层调用;单测直接构造 service)。"""
sender: SmsSender
if settings.sms_provider == "fake":
sender = FakeSmsSender()
elif settings.sms_provider == "tencent":
sender = TencentSmsSender(settings) # 凭证缺失在这里就抛 61000(构造失败)
else:
raise AppError(
ErrorCode.CONFIG_ERROR, message=f"未知 SMS_PROVIDER: {settings.sms_provider}"
)
return SmsAuthService(RedisSmsStore(redis), sender, settings=settings)
__all__ = [
"FakeSmsSender",
"RedisSmsStore",
"SmsAuthService",
"SmsSendRequest",
"SmsSender",
"SmsStore",
"SmsVerifyRequest",
"TencentSmsSender",
"build_sms_auth_service",
"generate_code",
"hash_code",
"masked_phone",
"normalize_phone",
"phone_hash_key",
"phone_union_id",
]