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)。
427 lines
18 KiB
Python
427 lines
18 KiB
Python
"""手机号验证码登录(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",
|
||
]
|