# T11 · 登录·手机号验证码(代码先行,联调后置) > 对应任务:`T-P1-12b/12c` | 需求:`FR-ACC-04`(6 位、5 分钟、一次性、失败 5 次作废)、`FR-ACC-14`(三重频控防刷)、`TD-32` | 分支:`task/t11-auth-sms` | worktree:`/home/xsl/meida_backend-t11` > 先读《README.md(协作规程)》,遵守全部纪律。 ## 1. 目标 手机号验证码登录全链路(发送 → 校验 → 建户 → 双令牌)+ **短信轰炸防护**。腾讯云短信签名/模板还在审核(《前置开通清单》§1),因此真实发送后置:`SmsSender` 走 Protocol 隔离,默认 `fake` 实现(验证码打日志,dev 可用),腾讯实现写好待凭证。 **FR-ACC-04 纪律:与华为通道不得互相依赖**——本任务不 import 华为相关任何东西,登录成功路径只依赖 identity 域已有设施。 ## 2. 已就绪的契约(协调提交已提供) ### 配置(config.py 已加,.env.example 已示例) ```python sms_provider: str = "fake" # SMS_PROVIDER: fake | tencent tencent_sms_secret_id/key/sdk_app_id/sign_name/template_id: str = "" sms_code_ttl_seconds: int = 300 # 5 分钟(FR-ACC-04) sms_code_length: int = 6 sms_verify_max_attempts: int = 5 # 校验失败 5 次作废 sms_resend_cooldown_seconds: int = 60 # 同号 60s 内 1 条 sms_hourly_limit: int = 5 # 同号 1h ≤5 sms_daily_limit: int = 10 # 同号 24h ≤10 sms_ip_daily_limit: int = 20 # 同 IP 24h ≤20 sms_device_daily_limit: int = 10 # 同设备 24h ≤10 ``` ### 错误码(errors.py 已加,含 meta) - `SMS_CODE_INVALID = 40110`(401「验证码错误」hint「请检查后重试」) - `SMS_CODE_EXPIRED = 40111`(401「验证码已过期」hint「请重新获取」) - `SMS_SEND_TOO_FREQUENT = 42910`(429「发送太频繁了」hint「请稍后再试」) - `SMS_DAILY_LIMIT = 42911`(429「今日发送次数已达上限」hint「明天再试」) - `SMS_PROVIDER_FAILED = 61000`(502「短信服务暂时不可用」) ### 依赖与设施 - `tencentcloud-sdk-python-sms` 已装(协调提交)。 - 可复用(先读再写):`packages/identity/service.py` 的 `get_or_create_by_external_identity`(provider 用 `"phone"`)与 `issue_session`(T4);`packages/identity/schemas.py` 的 `TokenPairResponse`——**复用,不重复定义、不修改该文件**。 - 路由骨架:`apps/api/routers/auth_sms.py` 空路由(prefix `/auth`)已注册 main.py——归你填充。`apps/api/routers/auth.py` 归 T10,**禁碰**。 ### 号码存储决策(写进代码注释) V1 简化:`id_user_identity` 现有列无 `phone_enc`,用 `union_id = "phone:" + sha256(规范化号码)[:16]`(E.164 规范化:去 +86/前导零/空白后哈希)。**Redis 与日志中一律不存明文号码**——键用 `sha256(phone)` 前 16 位,日志打码 `138****1234`(FR-ACC-14 掩码要求)。`phone_enc` 列的补齐属后续迁移任务,不在本批。 ## 3. 实现要点 允许改动:`packages/identity/sms_login.py`(新建,全部核心逻辑)、`apps/api/routers/auth_sms.py`、测试文件。**不改 identity/{service,repo,schemas,tokens,huawei}.py。** 1. **`packages/identity/sms_login.py`**: - `SmsSender` Protocol:`async def send_code(phone_masked_key: str, code: str) -> None`(收掩码键不收明文——防实现层记日志泄漏); - `FakeSmsSender`:`logger.info("sms_code_issued", masked=..., code=...)`(dev 专用,注释写明生产必须切 tencent); - `TencentSmsSender`:包官方 SDK(`SmsClient` + `SendSmsRequest`,参数从 settings 读);构造失败/响应非 ok → `AppError(SMS_PROVIDER_FAILED)`;**本批不要求真实联调,但代码路径要完整可读**; - `SmsAuthService(redis, sender, *, settings)`: - `request_code(phone, ip, device_id)`:号码格式校验(大陆手机号正则 `^1[3-9]\d{9}$`,写死并注释 V1 只做大陆)→ **先频控后发送**:同号 cooldown/hourly/daily、IP daily、设备 daily 全过才生成 6 位随机码(`secrets.randbelow`,非 random)→ 存 Redis `sms:code:{phone_hash}`(值 = sha256(code),**不存明文**,TTL 300s)+ 计数器自增 → sender 发送; - 频控计数键按自然窗口(cooldown 用 TTL;hourly/daily 用 UTC+8 小时/日期桶,对齐 billing 的时区纪律); - `verify(phone, code, ip, device_id)`:取码哈希比对(**恒定时间比较** `hmac.compare_digest`)→ 错误则 attempts+1(`sms:attempts:{phone_hash}`,达 5 删码并返回 40110)→ 成功删码(一次性)→ 由调用方建户; - 全部日志打码:`masked_phone(phone) -> "138****1234"`。 2. **路由**(auth_sms.py): - `POST /auth/sms/send` body `{phone, device_id}`(ip 从 `Request.client.host` 取)→ 限流错误码透传; - `POST /auth/sms/verify` body `{phone, code, device_id}` → 成功后 `get_or_create_by_external_identity(provider="phone", union_id=..., register_source="phone")` + `issue_session` → `ok(TokenPairResponse(...))`; - 请求/响应模型定义在 sms_login.py 或新建 `packages/identity/sms_schemas.py`(二选一,不碰共享 schemas)。 3. **刷量测试**(FR-ACC-14 验收原文"脚本化刷量测试证明拦截有效"):集成测试用循环模拟。 ## 4. 测试要求 - 单测 `tests/unit/test_sms_login.py`(fakeredis 或自写内存 dict 实现 Protocol 化的 redis 接口——参考 `InMemoryCostCounter` 的做法,不引新依赖): 1. 号码正则(非法格式 400 类错误); 2. 频控矩阵:cooldown 内二次 → 42910;1h 第 6 条 → 42910;24h 第 11 条 → 42911;IP 第 21 条 / 设备第 11 条 → 42911; 3. 验证码:错误 4 次可再试、第 5 次后码作废(40110 且码已删)、过期(TTL 模拟)→ 40111、成功后码即失效(二次 verify 40111); 4. 掩码函数;恒定时间比较(不 assert 时长,只 assert 行为)。 - 集成 `tests/integration/test_auth_sms.py`(新文件,SMS_PROVIDER=fake,从响应前的日志拿不到码——**测试钩子**:fake sender 把码写进 Redis 测试键或直接用 fixture 替换 sender 为捕获型 fake,不要为测试开后门到生产代码): 1. send → verify(fixture 捕获的码)→ 200 双令牌,新 access 可用; 2. 同号再登录同 user_id(映射唯一); 3. **刷量脚本化**:连发 11 次,第 11 次 42911;换 IP 维度再验一轮; 4. 错误码 5 次作废路径。 ## 5. 禁改清单 通用禁改之外:`packages/identity/{service,repo,schemas,tokens,huawei}.py`、`apps/api/routers/auth.py`、`packages/{kb,rules,retrieval}/**`。 ## 6. 验收标准(FR-ACC-04 / FR-ACC-14) 1. 验证码 6 位、5 分钟、一次性、失败 5 次作废——四点各有断言; 2. 三重频控全部生效且有脚本化刷量测试证明; 3. 号码与验证码在日志/Redis 均无明文(掩码 + 哈希); 4. 与华为通道零耦合(不 import huawei 模块); 5. 全部检查绿(mypy 范围同 T6 列表)。 ## 7. 提交信息 ``` feat(identity): phone SMS login with triple rate limiting and anti-abuse SmsSender Protocol(fake/tencent)+ 验证码生命周期(哈希存储/一次性/ 5 次作废)+ 号码-IP-设备三重频控 + 掩码纪律(FR-ACC-04/14,TD-32; 真实发送待签名审核)。 偏差说明:<如有写在这里,无则写"无"> ```