diff --git a/docs/design/sms-channels-and-auth-policy.md b/docs/design/sms-channels-and-auth-policy.md new file mode 100644 index 000000000..1d9068df3 --- /dev/null +++ b/docs/design/sms-channels-and-auth-policy.md @@ -0,0 +1,285 @@ +# 短信渠道插件与登录注册策略方案 + +状态:设计提案,未实施。本文不表示接口、数据库或页面已交付。 + +## 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 测试、前端相关测试和构建;供应商真实验收必须有已审核签名/模板、受限测试凭据与用户认可的接收号码。本次方案阶段不运行业务测试、不启动服务、不产生短信费用。 diff --git a/docs/index.md b/docs/index.md index 046f09a30..19595d369 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ ## 设计沉淀 +- [短信渠道插件与登录注册策略](design/sms-channels-and-auth-policy.md):阿里云/腾讯云官方 SDK 候选基线、多渠道与模板路由、发送记录、验证码安全、短信和邮箱登录注册组合及分阶段验收(设计提案,未实施)。 + - [画布批量生成一致性治理](design/canvas-consistency-repair.mdx):节点丢失、批次状态与引用解析的根因、已实施边界重构、回归证据和仍待验收的性能/同步场景。 - [画布 Agent 外观与 Live2D](content/docs/backend/canvas-agent-appearance.mdx):独立助手名称、文案模板、模型包边界、Core 部署和验收要求。