Files
dsh_shenxian/dsh-server-docs/04-调整方案/134-注册页人机验证与邮箱验证码.md
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

21 KiB
Raw Blame History

134-注册页人机验证与邮箱验证码(2026-09-19 落地)

一句话:注册页从「用户名 + 密码」变成「用户名 + 密码 + 邮箱验证码(+ 人机验证)」,并配一套 可解释、可审计、重启不清零的防爆破策略。 触发:用户 2026-09-19 原话 —— 「注册账号页面增加,cloudfare人机验证和 amber发送邮件验证码的验证(增加重复获取验证码的爆破设计)」

背景与动机

  1. 注册口是唯一无鉴权写入口。原实现只有「用户名 + 密码」+ @fastify/rate-limit(每 IP 每分钟 5 次), 机器人可以低成本批量灌 pending 用户(每个用户都会占一条 DB 行 + 一个 home 目录)。
  2. 平台本来就没有邮件基础设施(全库 grep -iE "smtp|nodemailer|mail" = 0 命中)⇒ 邮箱验证码 不是"加个字段",而是从零建一条外发通道。
  3. 用户明确要了防爆破设计 ⇒ "能发验证码"只算一半,另一半是"不能被当成免费邮件炮台 + 不能被当口令猜"。

用户决策

时间 用户口径 落地
2026-09-19 「增加 cloudflare 人机验证」 接 Cloudflare Turnstile(服务端 siteverify)。⚠️ 密钥待用户提供(见 §待办)
2026-09-19 「amber 发送邮件验证码」 用户当日澄清:「amber 是日志服务我说错了」 ⇒ 与邮件无关(即档案 129 的 yaop-labs/amber)|落地 = Brevo + 可插拔驱动(brevo / 通用 http / log,换供应商只改 env、不改代码)
2026-09-19 「用 B 方案:[email protected]」 已落地:Brevo 建域名 ai1net.com → 4 条 DNS + SPF → authenticated: true / verified: true → 发件人 id=2 [email protected](AI1NET) → DSHS_MAIL_FROM 切换并重启|实测 delivered from= [email protected]
2026-09-19 「1、说一下在哪里操作」(Turnstile 密钥) 见 §待办 1 —— 给了控制台逐步路径
2026-09-19 「使用代理访问」(抓官方 prompt.md) 经本机代理抓到 Cloudflare 官方 Turnstile Spin skill(developers.cloudflare.com/turnstile/spin/prompt.md,31.6 KB)⇒ 对照出我实现里的两处校验缺口并已补齐(见 §实现 D):官方 canonical siteverify = success ∧ action ∧ hostname 三项,我原先只校 success
2026-09-19 「4、调整完成后在处理」 调整已完成;提交/推送留待你一句话(本轮未 commit)
2026-09-19 提供 Turnstile 两把密钥(「站点密钥:0x4AAAAAAE8aE7sRfp10el5C / 站点 secret:…」) 人机验证正式启用:写入 600 的 drop-in + restart dshs;密钥自证 + 配置端点 + 403 拦截三项实测(见 §实现 E)。⛔ 私钥不入任何文档与记忆
2026-09-19 「登录和注册页面上的 deepseek的图标去掉,改为中文:能力枢纽…CapabilityNet」+「一并替换」+「会话页面左上角的 deepseek 先不替换」 另立档案 137(品牌标识改造:四页 + favicon;会话页面那处不动)—— 与本节同一会话的相邻改动,见 137-品牌标识改造-去DeepSeek图形.md
2026-09-19 「增加重复获取验证码的爆破设计」 见 §实现 B:三层配额 + 递增冷却 + 试错上限 + 单次使用,全部落库

实现

A. 代码

文件 改动
src/db/schema.ts 迁移 v10:users.email + 唯一索引 LOWER(email) + email_codes 事件表(2 个索引)
src/db/types.ts User.email、EmailCodeRow、RecordEmailCodeInput、EmailCodeCounts、toEmailCode()
src/db/adapter.ts DbAdapter 新增 7 个方法(findUserByEmail + 6 个事件表方法)
src/db/repo.ts / sqlite.ts / pg.ts 两方言各自实现(计数一律现算,不维护冗余计数器)
src/web/register-guard.ts 新增:纯函数策略(发码判定 / 校验前判定),零 IO,可逐边界断言
src/web/mail.ts 新增:邮件驱动(brevo / http / log)+ 双语正文渲染
src/web/turnstile.ts 新增:Cloudflare Turnstile 服务端校验(失败关闭)
src/web/email-code.ts 新增:编排(人机验证 → 配额 → 发信 → 落事件 / 校验 → 消费)
src/web/routes/auth.ts 新增 GET /api/auth/register/config、POST /api/auth/register/email-code;POST /api/auth/register 加人机验证与验证码校验
src/config.ts 新增 12 个配置项 + 3 个解析工具(全部默认空 ⇒ 不配即老行为)
web/register.html 邮箱 + 「获取验证码」(带倒计时)+ 验证码 + Turnstile 容器;配置由服务端下发
web/i18n.js 新增 25 条 register.* 词条(中英)
test/register-guard.test.mjs 新增 19 条用例(策略边界 / 哈希 / 邮件驱动 / Turnstile 三项判定逐组合 / 主机名归一 / 编排 / 迁移 v10)
package.json 测试清单纳入 test/register-guard.test.mjs

两个刻意的默认值: ① 所有新配置默认为空 ⇒ 「不配任何 env = 与改造前完全一致」; ② 迁移 v10 是纯加列/加表,回滚只需不读新列(列可留着不删)。

E. Turnstile 正式启用(2026-09-19 08:3x · 用户提供密钥)

用户按「路 A」在 CF 控制台建好 widget 并把两把密钥发来,落地:

项 值
env(drop-in /etc/systemd/system/dshs.service.d/register-verify.conf,600) DSHS_TURNSTILE_SITE_KEY=0x4AAAAAAE8aE7sRfp10el5C(公钥,公开)/DSHS_TURNSTILE_SECRET(私钥,只落 600 文件,⛔ 不入任何文档与记忆)/DSHS_TURNSTILE_ACTION=signup/DSHS_TURNSTILE_HOSTNAMES=ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com
密钥自证 用假 token 打 siteverify ⇒ 回 invalid-input-response(而非 invalid-input-secret)⇒ 私钥被 CF 认可
部署后验收 GET /api/auth/register/config ⇒ {"captcha":{"enabled":true,"siteKey":"0x4AAAAAAE8aE7sRfp10el5C","action":"signup"},…}|无 token 提交 ⇒ 403 captcha_failed|假 token 提交 ⇒ 403 captcha_failed
备份 register-verify.conf.bak-turnstile-20260919-083853(= 只有邮件通道、无 Turnstile 的上一版)

为什么 hostnames 写 4 条(超集):这是安全的 —— CF 的 widget 域名列表是第一道闸门 (只有注册过的域名才会被签发 token),我方白名单列全只是保证"旧域仍在线期间"从旧域注册的访客不被误拒。 若将来旧域退役(档案 135),可收窄为两条。

D. Turnstile 校验补强(2026-09-19 · 对照官方 prompt.md 后补齐)

抓官方文档后发现原先只校 success,漏了另外两项 —— 这是真实的安全缺口,已补齐:

校验项 不校它的后果 现状
success === true 无 ✅ 原先就有
action 匹配 同一 sitekey 的所有入口共享 token ⇒ 人机验证退化成"过任意一处即可用到处" ✅ 新增(默认 signup,前后端取值由服务端下发,防漂移)
🔴 hostname 在白名单 sitekey 是公开的(就在页面 HTML 里)⇒ 攻击者可在自己站点嵌入我们的 sitekey、为真人访客拿到合法 token,再拿去打我们的注册接口。这是三项里最关键的一项 ✅ 新增(默认从 baseDomain 派生 <domain> + www.<domain>)

附带加固:

  • token 长度上限 2048(CF 规定)+ 空 token ⇒ 本地就拒,不浪费上游往返;
  • hostnames 为空数组 ⇒ turnstileEnabled=false ⇒ 视为未配置完成(宁可功能不启用,也不放开);
  • 「密钥给了但主机名没给」= 人机验证静默不生效 ⇒ captchaPartiallyConfigured() 检测 + 进程内首条 WARN(这种"看起来配了、实际没防住"的形态必须能在日志里被看见);
  • 主机名归一化(去协议 / 端口 / 路径、小写、去重):运维照 URL 习惯写 https://ai1net.com/ 也能匹配;⛔ 绝不自动加 localhost/127.0.0.1(生产白名单里放它们 = 放开本地伪造);
  • verifyUrl 可覆盖 ⇒ action/hostname 的每个组合都能用本地假 siteverify 断言(否则只能靠真 token 打线上才验得到)。
  • 新增 2 个配置:DSHS_TURNSTILE_ACTION(默认 signup)、DSHS_TURNSTILE_HOSTNAMES(逗号分隔)。 ⚠️ 旧域 alotbuy.com 门户仍在线 ⇒ 部署时须显式写全(ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com), 否则从旧域注册的访客会被人机验证拒掉(实测配置解析已确认四种形态均按预期)。

B. 防爆破(用户点名的部分)

四道闸门,层层独立:

维度 阈值(默认) 作用
邮箱 · 冷却阶梯 60s → 60s → 180s → 300s → 900s → 1800s(按最近 1 小时已发起次数取档) 让脚本化重试收益递减;真人重发一次仍只等 60 秒
邮箱 · 小时/天配额 每小时 ≤ 6 次发起;每 24 小时 ≤ 8 封真正发出 卡住"盯着一个邮箱猛发"
IP · 小时配额 ≤ 20 次(IP 可能 NAT,故意比邮箱宽) 卡住"一台机器换邮箱猛发"
全局 · 小时配额 ≤ 200 次 保护邮件服务商日配额(Brevo 免费档 300 封/天)

校验侧(防猜码):

机制 值
验证码 6 位数字,crypto.randomInt(非 Math.random)
存储 只存哈希:sha256(email|purpose|code|encryptionSecret),明文只出现在邮件里
有效期 10 分钟
试错上限 5 次;达上限立即作废(连正确码也不再认,必须重新获取)
单次使用 校验通过即 consumed_at(CAS 写入,并发重放只有一次成功)
绑定 码与「申请时填的用户名」绑定,换用户名重放不作数
比较 timingSafeEqual

三条不变式(顺序即语义): ① 先人机验证再花配额 —— 否则机器人只要猛点就能把真用户的配额吃光; ② 被拒绝也落库(status='throttled')—— 它既是配额的一部分,也是"有人在撞"的唯一证据; ③ 发信成功才记 sent —— 记早了会让"上游全挂"看起来像"发出去过"。

⚠️ 计数器落 DB 而不是进程内 Map:进程内计数一次 restart 就清零,而本部署天天重启 ⇒ 等于把限流关掉。

C. 部署

  • 产物:lib/**(tsc 产物)+ web/register.html + web/i18n.js → /opt/dshs/(只传本次改的文件,非整包覆盖)
  • 备份:/opt/dsh/backups/register-verify-20260919_070418/{pre.tar,pre2.tar}(600)
  • env:新 drop-in /etc/systemd/system/dshs.service.d/register-verify.conf(600)
    DSHS_MAIL_DRIVER=brevo
    DSHS_MAIL_API_KEY=<Brevo 密钥>
    [email protected]      # 2026-09-19 08:07 由 [email protected] 切换
    DSHS_MAIL_FROM_NAME=aisharenet
    
  • 发件域名认证(2026-09-19 已在 Cloudflare 加 5 条记录):
    类型 主机 值
    CNAME brevo1._domainkey b1.ai1net-com.dkim.brevo.com(DNS-only)
    CNAME brevo2._domainkey b2.ai1net-com.dkim.brevo.com(DNS-only)
    TXT @ brevo-code:2d01b491ca8de2c083b49bcbc606c95b
    TXT @ v=spf1 include:spf.brevo.com ~all
    TXT _dmarc 已有(p=quarantine,平台原有),未动
    ⇒ Brevo 侧 GET /senders/domains = authenticated: true / verified: true;发件人 id=2 [email protected]。
    ⚠️ 触发验证的正确端点 = PUT /v3/senders/domains/{domain}/authenticate(…/validate 不存在,会 404 resource_not_found)。
  • ⚠️ 未配 DSHS_TURNSTILE_SITE_KEY / DSHS_TURNSTILE_SECRET ⇒ 人机验证当前为停用状态 (未配置 = 跳过,不是"配了一半")

验证记录

本机

项 结果
tsc -p tsconfig.json 0 报错
node --test test/register-guard.test.mjs 19/19 通过(含 Turnstile action/hostname 五个组合 + 本地拒空/超长 token 不打上游)
全量单测(15 个文件) 219 通过 / 1 skipped(pg,缺 DSHS_TEST_DB_URL)/ 0 失败(# tests 220)
配置解析实测(5 种形态) 派生 / 显式含旧域 / URL 形态误填 / 显式空 / action 非法 ⇒ 全部按预期
check-layering.mjs 无新增违规
verify-static.mjs 全部合格(含 register.html 内联脚本语法、去痕迹、i18n 无裸中文)
其余 10 个 verify-*.mjs/cjs 全 OK

线上(https://ai1net.com,2026-09-19 07:0x)

步骤 实测输出 结论
GET /api/auth/register/config {"captcha":{"enabled":false,"siteKey":""},"emailCode":{"enabled":true,"ttlSeconds":600,"maxAttempts":5},…} 配置下发通路可用;人机验证按预期停用
POST …/email-code(首发) 200 {"ok":true,"retryAfterSeconds":60,"expiresInSeconds":600} 发码成功
立刻重发 429 + retry-after: 58 冷却生效
事件表 sent(07:05:23,hash 前 8 位 7f4d15a3)+ throttled(07:05:26,reason=email_cooldown) 落库口径正确
审计 register_code_sent / register_code_rejected,带真实客户端 IPv6 取证可用
Brevo 投递事件 delivered ×2(07:05:25 959046 is your DSH verification code;07:09:16 261788 is your AI1NET verification code) 端到端投递成功
错码提交 400 {"error":"code_invalid","attemptsLeft":4} 试错计数生效
正确码提交 201 + 建号成功,users.email 落库 全链路通
同邮箱重放 409 {"error":"email_taken"} 重放被挡
md5 对账 lib/web/mail.js / email-code.js 两端一致 部署无漂移
Turnstile 补强部署后复验 配置端点新增 "action":"signup";register.html 含 cfg.captcha.action;门户 200;发码仍 200 补强零回归(未配密钥 ⇒ 行为与补强前一致)

清理:测试账号 rcverify1(DB 行 + 47/106 上的目录)、tzcheck1/nrverify1 的测试事件行全部删除; email_codes 现存 0 行;47 users/ 目录数 7(= 2 真实用户 + 5 个历史孤儿,本轮未新增)。

事故/踩坑记录

  1. 🔴 人机验证只校 success 是不够的(本档案最重要的教训 · 2026-09-19 对照官方文档才发现): sitekey 本身就是公开的(写在页面 HTML 里)⇒ 攻击者可以在自己的站点用我们的 sitekey 渲染 widget、 为真人访客拿到合法 token,再拿到我们的注册接口上用 —— 只校 success 时这条路完全通畅。 必须同时校 hostname(Cloudflare 服务端判定并回显,访客篡改不了)与 action。 官方 canonical siteverify 就是三项齐备。⇒ 凡"公开 key + 服务端校验"的模型,都要问一句 "这个 token 凭什么只是给我用的",答案往往就是 hostname / audience / origin 这类服务端回显的绑定字段。
  2. DSH 曾写进用户可见邮件主题。首版 renderVerificationMail 把 brand 默认为 'DSH', 实测投递的主题是 959046 is your DSH verification code ⇒ 平台内部名泄露给终端用户。 修法:brand 缺省为空,由 baseDomain 主标签大写(ai1net.com → AI1NET)推导; 空则整句退化为"你的验证码"。已加断言 !/DSH|dshs/i.test(subject+text)。
  3. 本机默认 node 是 24.x,而 better-sqlite3 是按 22 编译的(NODE_MODULE_VERSION 127 vs 137) ⇒ ERR_DLOPEN_FAILED。跑测试必须用 Node 22(E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe)。
  4. 冷却阶梯与小时配额曾互相打架:emailPerHour=5 时,阶梯第 6 级(1800s)永远走不到 ⇒ 白写一级。改为 emailPerHour=6,并加断言 ladder.length === emailPerHour。
  5. 新用户的实例目录落在 w-106 而不是 47(集群按容量分配)⇒ 清理测试账号时容易漏。 本次已按 w-106:/var/lib/dshs/users/<id> 单点删除。
  6. Brevo 事件接口有分钟级延迟:发信后立刻查 /smtp/statistics/events 查不到, ≈25 秒后才出现。别据此误判"没发出去",应以业务侧的 sent 落库 + 稍后复查事件为准。
  7. Brevo 触发域名验证的端点是 PUT /v3/senders/domains/{domain}/authenticate; …/validate 会回 404 resource_not_found(官方文档未列,实测而来)。
  8. ai1net.com 原先没有 SPF 记录 ⇒ 本次新建时一并补上 v=spf1 include:spf.brevo.com ~all (没有 SPF 的域用第三方发信,被判伪造 / 进垃圾箱的概率显著上升)。既有的 _dmarc(p=quarantine)未动。
  9. 主机名白名单必须含旧域:alotbuy.com 门户仍在线(软回滚路径)⇒ 只写 ai1net.com 会让人机验证把从旧域注册的访客拒掉。配置解析已实测四种形态(派生 / 显式含旧域 / URL 形态误填 / 显式空)均按预期。

回滚 / 注意

回滚(三选一,代价递增)

  1. 只停用功能:mv /etc/systemd/system/dshs.service.d/register-verify.conf{,.off} && systemctl daemon-reload && systemctl restart dshs ⇒ emailCodeActive=false,注册页恢复成"用户名 + 密码"(迁移 v10 的列/表留着不碍事)。
  2. 回滚代码:从 /opt/dsh/backups/register-verify-20260919_070418/pre.tar(+ pre2.tar)覆盖回 /opt/dshs/ 后重启。
  3. 回滚 schema:需要 DROP TABLE email_codes / DROP INDEX idx_users_email / ALTER TABLE users DROP COLUMN email
    • 删 schema_migrations 里 version=10 的行。⚠️ 不必做 —— 纯加列加表对旧代码零影响。

注意

  • 注册页改的是静态文件(web/*.html),scp 即生效、不需要重启;本次重启只为 lib/**。
  • ⚠️ Turnstile 脚本来自 challenges.cloudflare.com:中国大陆网络下可能加载慢/被拦截。 前端已做失败关闭 + 明确提示(提示关掉广告拦截或换网络),不会静默变成"点不了"。
  • ⚠️ 发件人是 [email protected](Brevo 里唯一已验证的发件人)。发给 QQ/163 等国内邮箱 大概率进垃圾箱,建议在 Brevo 验证自有域名(ai1net.com)再改 DSHS_MAIL_FROM。
  • email_codes 表自带自维护清理(每小时最多一次,清 30 天前的行),不需要额外 cron。

待办 / 未闭环

  1. ✅ 人机验证已启用(2026-09-19 08:3x,用户按「路 A」建好 widget 并提供密钥)—— 见 §实现 E。 密钥自证(假 token ⇒ invalid-input-response)、配置端点 enabled:true、无 token / 假 token 提交均 403 三项已实测。 ⚠️ 唯一未做的是真实人机通过后的端到端(需要一个真人/真浏览器取得真 token 再提交)—— 该项留给用户首次真实注册时自然验证:若届时收到"人机验证未通过",最常见的两类原因是 ① widget 的 Hostnames 没覆盖当前访问域名;② 访客侧 CF 脚本被广告拦截插件挡住(页面已做明确提示而非静默失败)。 (历史备查 · 两条原始路径:路 A = CF 控制台 → 左侧 Turnstile → Add widget(Hostnames 填域名、Mode = Managed)→ 取两把密钥; 路 B = 给一个含 Account → Turnstile → Edit 的 account 级令牌,我用官方 API 自建。本轮走的是路 A。)
  2. ✅ 「amber」已对号 —— 用户当日澄清 = 日志服务(即档案 129 的 yaop-labs/amber),与邮件无关; 邮件按 Brevo 落地,且驱动可插拔(换供应商零代码)。
  3. ✅ 发件人已换 [email protected] —— Brevo 域名 ai1net.com 已认证 + 4 条 DNS + SPF; 实测 delivered from= [email protected]。替代了原先的个人 Gmail。
  4. 🟡 未提交 / 未推送(用户级规矩:未明确要求不 commit/push;且用户 2026-09-19 回「调整完成后在处理」 ⇒ 调整已完成,待你一句话即提交)。改动面较大(5 个 DB 文件 + 4 个新模块), 建议尽快提交以免被并行会话的 checkout 冲掉。
  5. 🟡 users.email 目前只在 DB,未进 PublicUser / admin 用户列表 ⇒ admin 看不到用户邮箱。
  6. 🟡 47 上另有 5 个孤儿用户目录(3ec95f69…/4eaeb26b…/74e8804a…/7ba268be…/ca3f36e0…, 09-15 产生,DB 无对应行)—— 本次未动,属历史遗留,建议单独清理。
  7. 🟡 CF 侧仍有 1 处遗留:本次为 Brevo 加的 5 条记录已生效,但未动任何既有记录; 若日后停用 Brevo,需回收这 4 条(brevo-code / 两条 DKIM CNAME / SPF)。