"""手机号验证码登录(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", ]