Files
ttstd-infinite-canvas/docs/design/sms-channels-and-auth-policy.md

286 lines
23 KiB
Markdown
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.
# 短信渠道插件与登录注册策略方案
状态:设计提案,未实施。本文不表示接口、数据库或页面已交付。
## 1. 目标与默认决策
- 支持多个短信渠道实例;同一供应商允许配置多个账号、应用或签名。
- 首批提供阿里云、腾讯云两个系统级官方短信插件,复用现有插件生命周期和配置 schema,不另建插件中心。
- 后台新增短信渠道、场景路由、发送记录;登录注册设置增加短信验证码登录、邮箱验证码登录、短信注册、邮箱注册、短信与邮箱双验证注册。
- “短信+邮箱注册”指两者都必须验证,不是任意一个。短信注册与邮箱注册同时开启则表示任选其一。双验证开启后与单验证注册互斥,防止绕过。
- 第一期只开放中国大陆手机号,内部统一为 E.164。国际短信、营销群发、随机负载均衡、公开第三方可执行短信插件不在本期。
- 保留现有密码登录与会话机制;注册暂保留现有用户名、密码和服务协议要求,不顺便改成无密码注册。验证码登录本身不要求密码。
- 短信发送是有费用的外部写操作。配置检查不发送短信;真实测试需要管理员明确触发并确认接收号码。
## 2. 当前实现与改造依据
仓库检查表明:
| 现状 | 代码依据(仓库相对位置) | 设计影响 |
| --- | --- | --- |
| 密码登录支持用户名或邮箱;普通注册要求邮箱验证码,首个管理员例外 | `backend/internal/auth/auth.go` | 分离登录方法、注册策略与发送能力,不能只加页面开关 |
| 邮件验证码用于注册和找回密码,已有 HMAC 和事务消费 | `backend/internal/auth/email.go`、`password_reset.go`、`backend/internal/repository/repository.go` | 演进为统一挑战机制,保留安全能力,不复制 SMS 版邮件流程 |
| 用户没有手机号和联系方式验证时间 | `backend/internal/model/models_identity.go` | 新增手机与验证状态,历史邮箱不能直接视为已验证 |
| 非空邮箱已有大小写归一化唯一索引 | `backend/internal/database/schema.go` | 手机增加等价唯一约束,不重复建设现有邮箱唯一索引 |
| 支付插件已有系统级启停、host 执行、配置 schema、Registry 及可信进程 RPC | `backend/internal/app/payment_plugins.go`、`plugin_management.go`、`backend/internal/payment/` | 复用插件宿主模式;不要把短信注册在模型 provider 或支付 registry 中 |
| 登录注册后台目前主要管理注册总开关和 Linux.do | `web/src/pages/admin/components/access-settings-panel.tsx` | 改为统一认证策略页面,邮件配置继续负责 SMTP |
| Linux.do 可自动创建新用户;运行平台已有 Redis 协调能力 | `backend/internal/auth/linuxdo.go`、`backend/internal/platform/coordination.go` | 新用户必须经过统一注册策略;多实例限流不能依赖进程 mutex |
工作区存在与本方案无关的用户修改。本次仅新增设计文档和索引,不触碰这些修改。
## 3. 架构和职责
```text
HTTP handler
-> service 别名 -> app 组合根
-> auth:登录、注册、联系方式绑定、策略及会话
-> verification:验证码挑战、失败次数、有效期及消费条件
-> sms:路由、限额、发送尝试及状态对账
-> SMS Registry -> 官方插件适配器 -> 官方 Go SDK
-> 现有 SMTP 发信器
-> repository/model:查询、事务与持久化
-> platform:Redis/单机协调、定时对账
```
建议新增 `backend/internal/sms/`、`backend/internal/verification/`;供应商适配器放 `backend/internal/sms/providers/aliyun/` 与 `tencent/`。注册事务由 repository 统一完成,验证码域不直接创建用户,短信域不决定谁能登录。
`app` 注入邮件与短信发送依赖,域包不反向 import `app` 或 `service`。新增前端业务模块 `services/api/sms.ts`,只使用项目 `http` 入口。
### 3.1 插件、渠道、模板、路由必须分开
| 对象 | 含义 | 示例 |
| --- | --- | --- |
| 插件 | 一种供应商协议能力 | `official-sms-aliyun`、`official-sms-tencent` |
| 渠道实例 | 独立账号/应用、密钥、默认签名、启停、配额 | 阿里云主渠道、阿里云备用账号、腾讯云备用渠道 |
| 场景模板绑定 | 某渠道某场景的云端模板及变量映射 | 登录、注册、管理员测试 |
| 场景路由 | 首选渠道及明确有序的备用渠道 | 登录走阿里,明确拒绝时可尝试腾讯 |
新增 manifest 的 `smsProviders` contribution 和管理分类 `sms`;第一期使用系统官方 host-backed 执行模式。配置 schema 描述密钥、地区、应用和模板字段,宿主渲染渠道表单。密钥按渠道保存,而不是一个插件只能保存一份账号。
插件提供 `Descriptor`、`ValidateConfig`、`Send`,并以能力声明提供送达状态查询。宿主定义模板变量 `code`、`ttlMinutes` 等,再由适配器转换成供应商格式。插件不能创建验证码、登录用户、读取用户表或创建会话。
未来可信进程插件可使用独立的 `yingce.sms/v1` ABI;本期不复制一套大型进程运行时。进程隔离不等于安全沙箱,不能据此允许上传任意不可信二进制。
## 4. SDK 参考基线
本次通过供应商官方 GitHub API 核对 tag、对应提交和模块文件(2026-09-22):
| 供应商 | 官方 SDK / API | 本次发现的最新正式 tag | tag 所指提交时间(UTC) |
| --- | --- | --- | --- |
| 阿里云 | `github.com/alibabacloud-go/dysmsapi-20170525/v5`,API `2017-05-25` | `v5.6.0` | 2026-07-01 13:57:25 |
| 腾讯云 | `github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms`,包 `v20210111` | SDK 仓库 `v1.3.184` | 2026-09-21 18:47:20 |
阿里 tag 的 `go.mod` 声明 Go 1.14,依赖 `darabonba-openapi/v2 v2.2.2` 和 `tea v1.5.1`;腾讯 SMS 子模块声明 Go 1.14,并依赖 `common v1.3.184`。这只是模块声明,与项目 Go 1.25 的实际集成兼容性仍须编译验证。
正式 tag 不等于已经在本项目证明稳定。腾讯此版本刚发布,将其列为候选,不宣称经过长期生产验证。实施时重新核对正式 tag、变更记录和安全修复,通过模块解析、编译、契约测试及真实小流量验收后固定 `go.mod/go.sum`;不要使用浮动 latest 或 master。腾讯还须确认 SMS/common 子模块可解析版本匹配。
已查看官方模型源码:阿里发送使用签名、模板编码和 JSON 模板参数,返回包含 `Code`、`BizId`、`RequestId`;腾讯使用 `SmsSdkAppId`、`TemplateId`、`TemplateParamSet`,手机号支持 E.164,返回逐号码状态与流水号。适配器必须解析业务结果,而不只检查 Go error 或 HTTP 状态。
官方核对入口与固定版本源码:
```text
https://api.github.com/repos/alibabacloud-go/dysmsapi-20170525/tags?per_page=10
https://github.com/alibabacloud-go/dysmsapi-20170525/tree/v5.6.0
https://api.github.com/repos/alibabacloud-go/dysmsapi-20170525/commits/v5.6.0
https://github.com/alibabacloud-go/dysmsapi-20170525/blob/v5.6.0/client/send_sms_request_model.go
https://github.com/alibabacloud-go/dysmsapi-20170525/blob/v5.6.0/client/send_sms_response_body_model.go
https://api.github.com/repos/TencentCloud/tencentcloud-sdk-go/tags?per_page=10
https://api.github.com/repos/TencentCloud/tencentcloud-sdk-go/commits/v1.3.184
https://github.com/TencentCloud/tencentcloud-sdk-go/blob/v1.3.184/tencentcloud/sms/go.mod
https://github.com/TencentCloud/tencentcloud-sdk-go/blob/v1.3.184/tencentcloud/sms/v20210111/models.go
```
SDK 负责签名与协议,不自行实现阿里或腾讯签名算法。发送调用配置明确超时、取消和受控网络出口;关闭或明确约束 SDK 自动重试,防止宿主与 SDK 双层重试。默认使用官方域名和地区枚举,不提供任意 endpoint 输入框;同样遵守项目出站安全策略。
## 5. 登录注册开关合同
### 5.1 管理界面
```text
登录方式
密码登录 保留现有能力
开启短信验证码登录 独立开关
开启邮件验证码登录 独立开关
Linux.do 登录 保留现有配置
注册策略
开放新用户注册 总开关
开启短信验证码注册 单验证入口
开启邮件验证码注册 单验证入口
开启短信+邮箱注册 强制双验证,与上面两个入口互斥
```
持久化不是五个不受约束的布尔值:登录使用独立方法开关;注册使用总开关和 `methods` 集合,允许 `[]`、`[sms]`、`[email]`、`[sms,email]` 或 `[sms_email]`。`sms_email` 不得与单验证方法同时出现。
开启双验证时,UI 明示将关闭单验证入口,服务端同样拒绝冲突配置。关闭双验证后要求明确选择单验证入口,不自动恢复隐含历史配置。
注册总开关关闭时,禁止发注册验证码、创建注册流程及最终提交;不影响已有用户登录。登录验证码不会为未知号码自动注册用户。注册方法与登录方法相互独立:开启短信注册不会隐式开启短信登录。
策略保存与最终提交均强校验依赖;渠道有启用实例、对应模板与路由,邮件有可用配置。检查只能证明本地配置完整,不承诺云端模板审核、余额或网络一定正常。公网配置只返回有效方法与可展示的不可用原因,不泄露供应商或密钥。
策略保存使用版本号防覆盖;认证流程绑定版本,最终提交重新校验当前策略,避免发送验证码后利用旧策略绕过管理员调整。
### 5.2 双验证注册流程
1. 创建短期 `auth_flow`,方法为 `sms_email`,绑定浏览器持有的不可猜测流程凭据。
2. 输入手机号、邮箱;分别发送 `register/sms` 与 `register/email` 验证码,挑战绑定该流程与归一化目标。
3. 两个输入都有明确的倒计时、失效和错误反馈;不能在发送时就创建用户。
4. 最终提交在一个数据库事务里校验并消费两个挑战、创建唯一用户及验证标记;任意失败均不能只消费其中一个。
5. 注册结果/幂等键绑定流程,响应丢失后的重试不能创建第二个用户或重复发放赠送额度。会话创建与注册成功状态协调,通知发送不进入数据库长事务。
成功验证一项后修改手机号或邮箱,必须使对应验证结果失效。不得只拿两个不受流程绑定的 `challengeId` 任意拼接。
### 5.3 账号归属和已有流程
- 同手机号/邮箱的唯一性由数据库兜底;手机号与邮箱已分别属于两个账户时拒绝注册,不自动合并。
- 新注册的联系方式写入验证时间。历史邮箱、首个管理员可选邮箱、OAuth 返回邮箱不能一律补成已验证。
- 对无可信验证证据的已有账户,先通过原有登录方式进入安全设置,再完成邮箱/手机绑定验证;验证码登录只使用已验证联系方式,避免把历史填错的邮箱变成接管入口。
- 本期必须包含最小联系方式绑定入口,否则已有用户无法安全启用手机登录。绑定/换绑需要近期重新认证、新联系方式验证和唯一性检查;不凭新号码验证码直接替换既有账户归属。
- 首个管理员仍使用独立初始化路径,但创建权限必须由数据库事务/全局初始化状态保护,不依赖单进程用户计数和 mutex。
- Linux.do 已有用户登录不变;首次创建用户必须进入统一注册策略。双验证开启时先完成双验证,再绑定 OAuth 身份,不能通过第三方自动注册绕过。
- 邮箱域名白名单仍作用于含邮箱的新注册,不应用于既有用户登录;找回密码目的隔离,不受“关闭邮箱验证码登录”误伤。
## 6. 验证码与反滥用
统一 `verification_challenges`:用途、媒介、流程、目标摘要、验证码 HMAC、状态、过期时间、尝试次数、已用时间。验证码不明文持久化,HMAC 包含挑战 ID、用途、媒介和目标,采用用途分离的密钥派生。
建议初始值(产品配置,不是供应商限制):6 位密码学安全随机数字,5 分钟有效,60 秒重发间隔,每个挑战最多 5 次错误,每个目标每小时 5 次/每天 10 次。IP、流程、目标、渠道、全站分别限流,并给共享出口场景保留可调阈值。
- Redis 模式使用原子限流和共享冷却;单机模式也必须保证原子性。验证码唯一消费、目标唯一归属和硬性发送预算依靠数据库条件更新/事务,不能只依赖 Redis 或进程锁。
- 错误次数更新必须真实提交,不能因为返回业务错误而随验证事务回滚。耗尽后锁定挑战。
- 验证码目的包括登录、注册、绑定、密码找回,不能跨用途复用。新一代验证码发布时失效旧一代,重发受目标总额度约束。
- 全局失败预算防止攻击者通过反复申请新挑战刷新错误次数。高风险请求增加人机校验;不能只依赖浏览器倒计时。
- 登录验证码申请对账户存在性给出统一受理语义,未知账号不真实发送;系统不可用、频率超限等真实错误仍返回机器可读 reason,不伪称“短信已送达”。
- 写操作校验 Origin/CSRF 边界;验证码和手机号不得进入 URL。日志过滤 SDK 原始错误中的请求参数、密钥、正文和地址。
## 7. 发送、路由和状态记录
### 7.1 发送路径
先原子预占预算、创建挑战和发送记录,再在事务外调用供应商,最后持久化结果。第一期使用有上限的同步单次发送,验证码仅在内存中交给 SDK,不引入持久化明文验证码的通用任务队列。
记录创建失败则不发送;调用超时或调用后数据库更新失败,原记录保留为待核对/结果未知。进程崩溃遗留的发送中记录由恢复任务转为未知并对账,禁止自动再次发送。明确失败的挑战失效;结果未知但实际收到的正确验证码可在限时、限次内验证,不要求“送达回执”才能登录。
幂等请求绑定流程、场景、目标和请求摘要,同一 key 不得用来更换目标。每个逻辑消息与每次供应商尝试分开记录。
### 7.2 故障切换原则
| 情况 | 行为 |
| --- | --- |
| 首选渠道本地不可用,尚未调用供应商 | 可按配置选择下一个可用渠道 |
| 供应商明确拒绝且适配器能确认未受理 | 只有列入可切换错误白名单时才试备用渠道,并写独立尝试记录 |
| 超时、断连、响应解析失败、状态不明 | 不自动重试、不跨渠道补发,先对账或让用户冷却后明确申请新验证码 |
| 已受理但没有送达回执 | 不作为失败自动补发 |
| 参数非法、目标非法、平台风险阻断 | 直接拒绝,不通过换渠道绕过校验 |
`OutId`、`SessionContext` 等追踪字段不未经证实就当成供应商幂等保证。不宣称跨网络提供严格 exactly-once;目标是可追踪、保守重试和最小化重复计费。
### 7.3 两个状态维度
发送受理状态:准备中、发送中、已受理、明确失败、结果未知、平台阻断。
送达状态:等待回执、已送达、送达失败、不可确认。API 受理成功不能直接写“发送成功/已送达”。验证码消费状态独立,不与送达状态混用。
第一期优先通过经凭据认证的供应商查询接口补齐回执,带查询时间窗口、退避和停止期限;无查询能力明确显示不可确认。若某供应商使用消费式回执拉取,必须先确定独占消费者及落库策略,避免多个渠道实例争抢回执。公开 webhook 后续按供应商真实认证机制建设,不做通用无鉴权回调。
### 7.4 记录字段与统计
列表:时间、脱敏手机号、业务场景、渠道、模板、受理状态、送达状态、耗时、归一化错误、供应商流水号。详情展示每次渠道尝试、配置版本快照和状态时间线;支持时间、渠道、场景、状态与号码精确查询。
不存验证码、不存完整短信正文、不原样保存 SDK 请求响应。目标按需要加密保存,查询索引使用带密钥摘要,列表仅显示掩码;访问和查询均受管理员权限与审计约束。密钥只回传 `hasSecret`,保留/替换/清空语义明确,不能把掩码当密钥保存。
统计区分受理率和送达率,展示已查询到的计费条数;缺少实际价格时不显示伪造的准确费用。首期不提供历史验证码“一键重发”,避免重放旧码。建议日志默认保留 90 天,具体由平台配置;挑战短期保留后清理。
## 8. 数据模型和迁移
| 数据对象 | 关键内容 |
| --- | --- |
| `users` 扩展 | `phone`、`phone_verified_at`、`email_verified_at`;非空归一化手机号唯一索引 |
| `sms_channels` | 实例 ID、插件/provider ID、启停、加密凭据、非敏感配置、配置版本、操作人 |
| `sms_template_bindings` | 渠道、场景、模板 ID、命名变量/有序变量映射;渠道+场景唯一 |
| `sms_routes` | 场景、首选及有序备用列表、切换策略、配置版本 |
| `sms_messages` | 逻辑发送 ID、流程/挑战关联、幂等摘要、脱敏目标、总体状态 |
| `sms_send_attempts` | 每次供应商调用、渠道/模板快照、请求 ID、流水号、错误及回执 |
| `auth_flows` | 方法、用途、目标绑定、流程凭据摘要、策略版本、完成结果与过期时间 |
| `verification_challenges` | 统一验证码挑战及尝试计数、消费状态 |
| 预算计数对象 | 按渠道/全局时间桶原子预占,未知结果保留额度,不把未确认费用退款 |
现有 SystemSetting 保存认证策略,不另造通用配置中心。数据库持久化是身份与审计真相源;Redis 仅用于协调与加速。
SQLite 与 PostgreSQL 都要覆盖迁移及并发行为。升级时关闭短信能力,按原注册总开关映射原邮箱注册策略,不自动开放注册。旧的短期验证码可明确失效并提示重取,不增加永久双写兼容层。注册及找回密码一起切换到统一挑战消费路径,避免两套不一致的限流实现。
历史邮箱验证状态迁移只采纳可证明的证据;不存在证据时保留未验证,不批量推断。上线先备份、迁移、配置并验收渠道,最后开放入口;回退先关闭新方法并使在途流程失效,不直接删除记录或回滚数据库字段。
## 9. 后台和前台交互
后台增加三个页面/页签:
1. 短信渠道:新增、编辑、启停、场景模板、首选/备用路由、额度和最近错误;静态配置检查与实际试发分开。
2. 短信记录:筛选列表、详情时间线和手动刷新回执(不是重新发送)。
3. 登录与注册:方法开关、双验证互斥提示、依赖状态、注册总开关及 Linux.do 设置。
渠道编辑用现有 AppDrawer;测试发送用带明确费用提示的确认弹窗。插件管理只管理插件可用性,渠道管理负责账号实例,避免两个地方维护两套密钥。
正常停用/卸载操作检查路由与认证依赖,要求先调整依赖;安全紧急停用可以明确确认后立即阻断发送,但要同步标记受影响认证方法不可用并告警,不能悄悄把双验证降为单验证。历史记录保留渠道/provider/模板快照。
前台登录按有效能力展示密码、手机验证码、邮箱验证码入口;注册根据策略展示手机、邮箱任选或双验证表单,双验证不是两个可互相跳过的 tab。首个管理员初始化独立展示。账号安全页提供联系方式验证和绑定。
## 10. API 草案
以下为目标接口,不是已存在接口;与现有 auth/service/handler 统一迁移,不长期保留第二套验证码入口。
```text
GET /api/auth/settings
POST /api/auth/flows
POST /api/auth/flows/:id/codes
POST /api/auth/login/code
POST /api/auth/register
POST /api/auth/contacts/verification
POST /api/auth/contacts/bind
GET /api/admin/sms/providers
GET /api/admin/sms/channels
POST /api/admin/sms/channels
PATCH /api/admin/sms/channels/:id
POST /api/admin/sms/channels/:id/test
GET /api/admin/sms/routes
PUT /api/admin/sms/routes/:scene
GET /api/admin/sms/messages
GET /api/admin/sms/messages/:id
POST /api/admin/sms/messages/:id/refresh-status
GET /api/admin/settings/auth-policy
PATCH /api/admin/settings/auth-policy
```
验证码申请只能选用途与目标,不能由未登录客户端指定供应商、渠道、模板或任意短信正文。对外仅返回流程/挑战标识、过期/重发时间和统一受理状态。测试接口限定管理员、单号码、测试模板及硬性频率上限,不创建可用于登录的挑战。
继续使用 `{code,data,msg,reason}` 信封。新增明确 reason,例如 `AUTH_METHOD_DISABLED`、`REGISTRATION_DISABLED`、`VERIFICATION_INVALID`、`VERIFICATION_EXPIRED`、`VERIFICATION_ATTEMPTS_EXCEEDED`、`SMS_CHANNEL_UNAVAILABLE`、`SMS_QUOTA_EXCEEDED`。最终名称与已有 error-code 表对齐,不以解析 msg 判断错误。
## 11. 实施阶段与验收
### A:短信能力闭环
SDK 固定版本与适配器、系统插件贡献、渠道/模板/路由、后台试发、发送尝试和查询回执。先证明“多个渠道能安全发送并如实记录”,不提前开启公开登录入口。
### B:统一验证码与认证策略
统一挑战模型、原子次数/消费、短信和邮件登录、三种注册组合、联系方式绑定、历史邮箱处理、OAuth 首次注册和初始化边界。
### C:联调与发布
真实页面、供应商小流量、双数据库与多实例验收;更新数据库、HTTP API、插件开发、功能清单和 pending-test 专题。限流、配额、安全及核心失败路径属于发布前必需项,不留作上线后补丁。
必须验证:
- 同一供应商多个实例隔离;插件停用/渠道停用依赖与密钥不回显。
- 两家 SDK 请求转换、业务失败、网络超时、逐号码失败、模板参数顺序、禁用自动重试。
- 超时不自动补发;重放请求不重复发短信;发送后落库失败/进程崩溃不重复调用上游。
- 五个 UI 开关的所有合法组合、冲突拒绝、注册总开关和在途策略变化。
- 双验证任一错误/过期/已消费时不建号、不部分消费;并发提交仅一个成功。
- 验证码不可跨用途/目标/流程使用;失败计数与冷却在多实例生效。
- 既有邮箱、管理员初始化、第三方账号、手机号/邮箱冲突,不发生隐式绑定与账号合并。
- SQLite/PostgreSQL 唯一索引、事务竞争、预算硬上限和迁移默认行为。
- 后台与登录注册真实路由的明暗主题、倒计时、刷新恢复、错误/空态和键盘交互。
按风险选择专项 Go 测试、前端相关测试和构建;供应商真实验收必须有已审核签名/模板、受限测试凭据与用户认可的接收号码。本次方案阶段不运行业务测试、不启动服务、不产生短信费用。