本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年05月30日 统计字数: 22312字 阅读时间: 45分钟阅读 本文链接: https://soulteary.com/2026/05/30/herald-lightweight-otp-and-verification-code-delivery-service.html ----- # Herald(鸦使):把验证码送到该送的人手里 Herald(鸦使)是一个轻量的 OTP 和验证码服务。 它负责创建一次“验证挑战”,生成验证码,通过邮件、短信、钉钉这类通道把验证码送出去,再在用户输入验证码之后完成校验。它可以接在 Stargate 和 Warden 后面,作为认证链路里的“送信人”;也可以独立运行,给内部后台、脚本、平台或其他服务提供统一的验证码能力。 ## 写在前面 前两篇里,我们先聊了 Stargate,又聊了 Warden。 Stargate 解决的是第一件事: > 有没有登录? 它站在反向代理后面、业务服务前面,把认证逻辑前移到入口侧。后端服务不需要自己写登录页,不需要自己处理会话,也不需要在每个接口里重复判断“这个请求能不能进来”。 Warden 解决的是第二件事: > 谁可以继续往下走? 它不做登录页,也不发验证码。它维护一份准入名册,让 Stargate 或其他调用方可以用邮箱、手机号或 `user_id` 查询一个人是否存在,状态是不是 `active`,后续验证应该联系到哪里。 这两步跑通之后,登录链路里还会剩下一个很现实的问题: > 怎么确认这次登录,确实是这个人在操作? 如果用户输入的是邮箱,要不要发一封验证码邮件?如果输入的是手机号,要不要发一条短信?如果是企业内部协作场景,要不要走钉钉?验证码多久过期?输错几次以后要不要锁住?同一个用户反复点“重新发送”,要不要限流?发送失败了,是继续创建 challenge,还是直接让这次流程失败? 这些问题也和登录有关,但不太适合继续塞进 Stargate,也不应该塞进 Warden。 Stargate 应该继续守入口,管会话和登录流程。Warden 应该继续维护准入名单,回答“这个人现在还能不能继续往下走”。 验证码的创建、发送、校验、过期、限流和审计,应该交给另一个更专门的服务。 Herald 要补的,就是这一段。 它不是完整 IAM,也不是营销短信平台,更不是要替代邮件网关、短信服务商或企业通知系统。它只先做好一件小事:把认证流程里的验证码和一次性口令,送到该送的人手里,并在用户提交之后给出清楚的验证结果。 ![soulteary/herald](https://attachment.soulteary.com/2026/05/30/herald-github.jpg) 项目地址:[https://github.com/soulteary/herald](https://github.com/soulteary/herald) 如果 Stargate 是先把门装上,Warden 是让门开始认人,那么 Herald 做的事情,就是在门开之前,把这次临时通行码送出去。 门开之前,鸦使先送信。 ## 验证码这件事为什么会变麻烦 很多内部服务一开始并不会做验证码。 一个共享口令,一个管理员密码,一个只有几个人知道的内部地址,往往就能让最初的服务跑起来。HomeLab、测试环境、小团队内部工具、临时后台,第一天就接邮件、短信、限流、审计和多因素认证,确实有点重。 这没什么问题。 先把门装上,比继续裸奔要好得多。 但只要使用时间拉长,服务变多,使用的人变多,共享口令就会慢慢不够用。于是你开始引入 Warden,用一份名单判断“这个人有没有资格继续登录”。 名单解决的是资格问题。 它能告诉你:这个邮箱在不在名单里,这个用户现在是不是 `active`,后续可以联系哪个邮箱或手机号。 但它还没有解决另一个问题: > 这次操作,是不是这个人真的在操作? 比如用户输入了 `admin@example.com`。Warden 查到了这个邮箱,状态也是 `active`。接下来,登录流程通常还需要往这个邮箱、手机号或企业 IM 账号里发一个验证码。只有能收到验证码,并且能正确输入的人,才继续创建会话。 这一步看起来很简单。第一反应往往是:在 Stargate 里写几行发邮件的代码不就行了?或者 Warden 既然已经查到手机号了,顺手调短信服务商不就好了? 一开始当然可以这么做。 但验证码链路很快会比想象中麻烦。 验证码需要生命周期。它不能一直有效,通常几分钟后就应该过期。用户输错几次以后,也不应该继续无限尝试。 验证码需要限流。一个用户、一个 IP、一个邮箱或手机号,都不能无限制地请求验证码。否则轻则刷爆短信费用,重则变成撞库、骚扰和探测入口。 验证码需要发送通道。邮件、短信、钉钉、企业微信、飞书、Webhook,每一种通道的配置方式、失败方式和排查方式都不一样。 验证码还需要审计。什么时候创建了 challenge,什么时候发送失败,什么时候验证失败,什么时候命中了限流,什么时候被锁住,这些事情只靠几行散落在日志里的文本,很难长期排查。 还有一个容易被忽略的问题:验证码服务本身也需要服务间认证。能调用它的人,可能触发验证码发送,也可能参与登录流程。它不是一个应该公开给所有人随便调的接口。 如果把这些都塞进 Stargate,入口服务会越来越重。它不再只是 ForwardAuth 和会话网关,还要维护验证码状态、接短信网关、处理邮件模板、做限流、写审计、管理 HMAC、处理 mTLS。 如果把这些都塞进 Warden,名单服务也会开始跑偏。Warden 原本只应该回答“这个人是谁、状态是什么、后续验证应该联系哪里”。一旦它开始发送验证码,它就会关心模板、通道、过期时间、重试、锁定和审计。 这不是我想要的方向。 所以验证码这件事,也应该被拆出来。 **Herald 负责送信。** 它不判断这个人有没有资格登录,也不负责最终创建会话。它只负责在调用方已经决定“可以进入验证步骤”之后,创建一个 challenge,把验证码送出去,并在用户提交验证码时给出明确结果。 拆开之后,整个链路会清楚很多: ```Text Stargate 负责入口和会话 Warden 负责名单和状态 Herald 负责验证码和送达 ``` 这样分开以后,排查问题时也更容易知道应该看哪里。 ## 以前通常怎么解决 遇到验证码和一次性口令的问题,常见做法大概有几种。 **第一种,是直接写在业务服务里。** 如果只有一个应用,这样当然可以。业务服务自己知道用户是谁,手机号是什么,什么时候应该发验证码,也能自己校验。 但内部工具常常不是这样。 很多服务只是 Grafana、文档站、测试面板、临时后台、脚本服务、第三方应用。你不一定能改它们的代码,也不一定值得为每一个服务都写一套验证码逻辑。 最后很容易变成:这个服务用邮件验证码,那个服务用短信验证码,另一个服务还在用固定口令。过期时间不一样,失败次数不一样,日志格式不一样,排查方式也不一样。 服务数量多起来之后,真正麻烦的不是“能不能发出去”,而是“每个地方到底按什么规则在发”。 **第二种,是把验证码写进认证入口里。** 这比每个服务都写一套要好一些。至少验证码逻辑集中到了 Stargate 这样的入口服务里,下游服务不用重复实现。 但入口服务会变重。 一开始只是发一封邮件。后面要支持短信,再后面要支持钉钉,再后来要加限流、锁定、审计、模板、多语言、HMAC、mTLS、Redis、多实例。到这一步,入口服务就不再只是入口了。 Stargate 的核心问题应该是: - 这个请求是否已经登录? - 未登录时应该跳转到哪里? - 登录完成后如何创建会话并回到原服务? 验证码可以出现在这个流程里,但它不应该长成 Stargate 的主要身体。 **第三种,是直接依赖某个短信或邮件服务商。** 比如业务代码直接调用某个短信 API,或者直接配置 SMTP 发邮件。 这能跑起来,但会把服务商细节暴露给调用方。每个调用方都要知道短信 API 怎么签名、错误码怎么处理、邮件模板怎么写、失败要不要重试、调用频率怎么控制。 一旦要换服务商,或者一个环境走邮件、另一个环境走短信,调用方就会被迫知道更多不该知道的东西。 更理想的方式,是调用方只说: 给这个 `user_id`,通过这个通道,向这个 destination 发送一次 `login` 验证码。 至于背后是 SMTP、短信网关、钉钉机器人,还是某个内部通知系统,应该由验证码服务自己处理。 **第四种,是直接上完整身份系统。** 如果你已经有公司 SSO、IAM、OIDC、MFA 或完整身份中台,那么很多验证码和多因素认证问题确实应该交给它们。 完整身份系统能处理的事情更多:账号生命周期、绑定关系、MFA 策略、登录风险、审计、协议、组织架构和权限治理。 但很多内部服务并没有一开始就到这个规模。 你可能只是想给几个内部服务加一道门,先从共享口令升级到名单,再让登录时多一步验证码。为了这个目标,第一天就搭完整 IAM、接回调、建用户库、配置策略和多因素认证,对很多 HomeLab、小团队和测试环境来说会显得太重。 所以我想要的是一个中间方案。 它不接管完整身份治理,不要求业务服务改代码,不把发送逻辑塞进 Stargate,也不让 Warden 变成验证码系统。 它只先做好一件事:**给内部认证链路提供一个稳定的 OTP 和验证码服务。** Herald 站在这里,专门处理这一步。 ## 为什么要把送信单独拆出来 把 Herald 单独拆出来,最重要的原因不是“微服务化”,而是边界清楚。 **验证码这件事本身有自己的状态。** 一个 challenge 至少会经历这些阶段: ```Text 创建 challenge ↓ 生成验证码 ↓ 发送验证码 ↓ 等待用户输入 ↓ 验证成功、验证失败、过期或锁定 ``` 这里每一步都和登录有关,但又不完全等于登录。 Herald 不需要知道登录页长什么样,也不需要知道用户最终会被带到哪个服务。它只需要知道几个比较具体的东西: ```Text user_id 是谁 channel 是什么 验证码要发到哪里 purpose 是什么 过期时间是多少 失败几次后锁定 是否命中限流 ``` 同样,Warden 不需要知道验证码是怎么发的。它只需要返回用户状态和联系方式。比如: ```json { "user_id": "admin-001", "destination": { "email": "admin@example.com", "phone": "13800138000" }, "status": "active", "channel_hint": "sms", "name": "管理员" } ``` Stargate 拿到这个结果以后,如果确认用户存在,并且状态是 `active`,再调用 Herald: ```json { "user_id": "admin-001", "channel": "sms", "destination": "13800138000", "purpose": "login" } ``` Herald 创建 challenge,发送验证码,然后返回一个 `challenge_id`。 用户输入验证码后,Stargate 再把 `challenge_id` 和 `code` 交给 Herald 校验。校验通过,Stargate 创建会话;校验失败,Stargate 继续停留在验证流程。 这样拆开之后,每个服务都不用知道太多。 - Stargate 不需要知道短信服务商怎么签名。 - Warden 不需要知道验证码多久过期。 - Herald 不需要判断这个人有没有资格登录。 后面替换起来也会轻很多。 - 验证码通道从邮件换成短信,主要改 Herald。 - 用户名单从本地 JSON 换成远程 API,主要改 Warden。 - 入口从单域名登录变成多域名会话,主要改 Stargate。 不要让一个工具因为“顺手”就开始管太多。顺手写进去的时候很省事,后面拆出来的时候会很疼。 Herald 的边界应该保持简单: ```Text 创建验证码 challenge 发送验证码 校验验证码 撤销 challenge 限流、锁定、审计和可观测性 ``` 除此之外,它不应该变成用户目录,不应该变成登录网关,也不应该替代完整身份系统。 它只是认证链路里的鸦使,把该送的信送到该送的人手里。 ## Herald 是怎么工作的 Herald 的工作方式可以先用一句话理解: 调用方创建一个 challenge,Herald 生成验证码并发送;用户提交验证码后,调用方再让 Herald 校验这个 challenge。 最常见的流程是: ```Text 调用方准备 user_id、channel、destination、purpose ↓ 调用 Herald 创建 challenge ↓ Herald 生成验证码,写入 Redis,并通过指定通道发送 ↓ Herald 返回 challenge_id、expires_in、next_resend_in ↓ 用户输入验证码 ↓ 调用方把 challenge_id 和 code 交给 Herald 校验 ↓ Herald 返回 ok / reason ↓ 调用方根据结果决定是否创建会话,或者继续留在验证流程里 ``` 这里最关键的是 `challenge_id`。 调用方不应该自己保存明文验证码,也不应该把验证码塞进 Cookie 或前端状态里。它只需要保存或传递 `challenge_id`。后续验证时,把用户输入的 `code` 一起交给 Herald 就够了。 创建 challenge 的请求大概是这样: ```json { "user_id": "admin-001", "channel": "email", "destination": "admin@example.com", "purpose": "login", "locale": "zh-CN", "client_ip": "192.168.1.10", "ua": "Mozilla/5.0..." } ``` - `user_id` 是调用方已经确认过的用户标识。它通常来自 Warden,也可以来自你自己的系统。 - `channel` 表示发送通道,可以是 `email`、`sms` 或 `dingtalk`。 - `destination` 是实际送达地址。邮件通道里它是邮箱,短信通道里它是手机号,钉钉通道里可以是钉钉用户 ID,或者在对应适配器支持的情况下使用手机号。 - `purpose` 表示这次验证码的用途,比如 `login`、`reset`、`bind`、`stepup`。 我建议从第一天就认真填写 `purpose`。登录、重置、绑定、敏感操作二次确认,不应该全部混成一个默认用途。现在看只是多传一个字段,后面做审计、排查和策略调整时,会省很多事。 返回结果大概是这样: ```json { "challenge_id": "ch_7f9b...", "expires_in": 300, "next_resend_in": 60 } ``` - `expires_in` 告诉调用方验证码还有多久过期。 - `next_resend_in` 告诉调用方下一次可以重新发送前,需要等待多久。 如果是本地测试,并且启用了 `HERALD_TEST_MODE=true`,创建 challenge 的响应里还可能带上 `debug_code`。这对本地联调很方便,但生产环境一定不要打开。 验证时,请求会更简单: ```json { "challenge_id": "ch_7f9b...", "code": "123456", "client_ip": "192.168.1.10" } ``` 验证成功时,Herald 会返回类似: ```json { "ok": true, "user_id": "admin-001", "amr": ["otp"], "issued_at": 1730000000 } ``` 对调用方来说,重点是 `ok` 和 `user_id`。 确认返回成功,并且 `user_id` 和当前登录流程里期待的用户一致,再继续创建会话。不要只看 `ok=true` 就往下走,尤其是一个页面里可能重新发起过多次 challenge 的时候。 验证失败时,Herald 会返回: ```json { "ok": false, "reason": "invalid" } ``` `reason` 在验证阶段常见的是 `expired`、`invalid`、`locked`、`verification_failed` 等。创建 challenge 或重发验证码时,还可能遇到 `rate_limit_exceeded`、`resend_cooldown`、`user_locked`、`send_failed` 这类错误。调用方可以根据这些原因做不同提示,也可以统一展示更模糊的错误信息,避免给攻击者太多细节。 Herald 还支持撤销 challenge: ```Text POST /v1/otp/challenges/{id}/revoke ``` 用户取消登录、重新开始流程、绑定操作中途终止,或者你希望主动让某次验证码失效时,这个接口会很有用。 整个过程中,Redis 很重要。Herald 会用 Redis 保存 challenge 状态、限流计数、幂等记录和锁定信息。生产部署时,建议给 Herald 使用独立 Redis 实例,或者至少使用独立数据库索引。它的数据和认证流程有关,不适合和一堆普通业务缓存混在一起。 ## 用 Docker Compose 快速跑起来 理解了工作方式之后,先把 Herald 跑起来。 这一节先不接 Stargate,也不接 Warden,不接真实短信服务商。只做一件事:让 Herald 能启动,能创建 challenge,能在本地测试模式下拿到验证码,并完成一次校验。 先把项目拉下来: ```bash git clone https://github.com/soulteary/herald.git cd herald ``` 创建一个用于本地测试的 `compose.yaml`: ```yaml services: herald: build: context: . dockerfile: docker/Dockerfile container_name: herald restart: unless-stopped ports: - "8082:8082" environment: - PORT=:8082 - REDIS_ADDR=redis:6379 - REDIS_PASSWORD= - REDIS_DB=0 - LOG_LEVEL=info - API_KEY=your-secret-api-key-here - CHALLENGE_EXPIRY=5m - MAX_ATTEMPTS=5 - LOCKOUT_DURATION=10m - RESEND_COOLDOWN=60s - CODE_LENGTH=6 - ALLOWED_PURPOSES=login,reset,bind,stepup - RATE_LIMIT_PER_USER=10 - RATE_LIMIT_PER_IP=5 - RATE_LIMIT_PER_DESTINATION=10 # 只适合本地测试,生产环境必须关闭 - HERALD_TEST_MODE=true # 本地没有配置真实发送通道时,先允许创建 challenge,方便把链路跑通 - PROVIDER_FAILURE_POLICY=soft depends_on: - redis redis: image: redis:7-alpine container_name: herald-redis restart: unless-stopped volumes: - redis_data:/data volumes: redis_data: ``` 这份配置是本地体验用的,不是生产模板。 里面有两处要特别注意。 ```yaml - HERALD_TEST_MODE=true ``` 这只适合本地和测试环境。测试模式可能返回明文验证码,也可能开放测试取码接口。生产环境必须关掉。 ```yaml ports: - "8082:8082" ``` 本地调试为了方便,可以把端口映射出来。真实部署时,Herald 更适合放在内部网络里,只允许 Stargate 或其他可信调用方访问。 启动服务: ```bash docker compose up -d --build ``` 看一下容器状态: ```bash docker compose ps ``` 再看健康检查: ```bash curl http://localhost:8082/healthz ``` 正常情况下,会看到类似: ```json { "status": "ok", "service": "herald" } ``` 接着设置 API Key: ```bash export API_KEY="your-secret-api-key-here" ``` 创建一个测试 challenge: ```bash curl -X POST http://localhost:8082/v1/otp/challenges \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: login-admin-001-demo" \ -d '{ "user_id": "admin-001", "channel": "email", "destination": "admin@example.com", "purpose": "login", "locale": "zh-CN", "client_ip": "127.0.0.1", "ua": "curl" }' ``` 如果启用了测试模式,返回里可能会包含 `debug_code`: ```json { "challenge_id": "ch_7f9b...", "expires_in": 300, "next_resend_in": 60, "debug_code": "123456" } ``` 如果返回里没有 `debug_code`,也可以在测试模式下用 challenge ID 查询: ```bash curl -H "X-API-Key: $API_KEY" \ http://localhost:8082/v1/test/code/ch_7f9b... ``` 然后验证验证码: ```bash curl -X POST http://localhost:8082/v1/otp/verifications \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "challenge_id": "ch_7f9b...", "code": "123456", "client_ip": "127.0.0.1" }' ``` 成功时,会看到类似: ```json { "ok": true, "user_id": "admin-001", "amr": ["otp"], "issued_at": 1730000000 } ``` 到这里,Herald 的最小链路就跑通了。 我们已经确认了几件事:服务能启动,Redis 能连上,API Key 能保护接口,challenge 能创建,验证码能校验。 这一步完成后,先不要急着接真实短信网关,也不要急着接 Stargate。 先把最短链路跑顺。 验证码链路一旦接入真实发送通道,排查范围会变大:可能是 Herald 配置问题,可能是 Redis 问题,可能是服务间鉴权问题,也可能是邮件、短信、钉钉服务本身的问题。 先用测试模式确认 challenge 和验证流程,再接通道,排查会轻很多。 ## 常用配置说明 Herald 的配置项不少,但第一次使用时,不需要一口气看完。 我会先把它拆成三类。 第一类,是最小链路一定会碰到的: ```Text PORT REDIS_ADDR / REDIS_PASSWORD / REDIS_DB API_KEY CHALLENGE_EXPIRY MAX_ATTEMPTS LOCKOUT_DURATION RESEND_COOLDOWN CODE_LENGTH ALLOWED_PURPOSES RATE_LIMIT_* HERALD_TEST_MODE ``` 第二类,是接真实通道时才需要认真看的: ```Text SMTP_* HERALD_SMTP_API_URL / HERALD_SMTP_API_KEY SMS_PROVIDER / SMS_API_BASE_URL / SMS_API_KEY HERALD_DINGTALK_API_URL / HERALD_DINGTALK_API_KEY TEMPLATE_DIR PROVIDER_FAILURE_POLICY ``` 第三类,是生产化和长期运行时再看的: ```Text HMAC_SECRET / HERALD_HMAC_KEYS TLS_CERT_FILE / TLS_KEY_FILE / TLS_CA_CERT_FILE AUDIT_* OTLP_* HERALD_TOTP_* ``` 这样拆开以后,会好理解很多。第一天先别急着把所有能力都打开。 ### 端口和 Redis Herald 默认监听 `:8082`。 ```bash PORT=:8082 ``` 本地测试时保持默认即可。如果你的习惯是不带冒号,也可以写成: ```bash PORT=8082 ``` Redis 是 Herald 的核心依赖,用来保存 challenge、限流计数、幂等记录和锁定状态。 最小配置可以是: ```bash REDIS_ADDR=redis:6379 REDIS_PASSWORD= REDIS_DB=0 ``` 本地测试时,同一个 Compose 里的 Redis 就够了。 生产环境里,建议给 Herald 使用专用 Redis,或者至少使用独立数据库索引。验证码数据虽然不是完整用户资料,但它直接参与认证流程,不应该和普通缓存混在一起。 ### `API_KEY`、`HMAC_SECRET` 和 `HERALD_HMAC_KEYS`:服务间认证 Herald 的接口不应该裸奔。 创建 challenge 意味着可能触发验证码发送;验证 challenge 意味着参与登录流程。这类接口从第一天开始就应该有服务间认证。 最简单的方式是 API Key: ```bash API_KEY=your-secret-api-key-here ``` 如果没有配置 API Key、HMAC 或 mTLS,Herald 可能会记录警告并允许未认证请求。这个行为只适合开发和测试环境,真实部署时不要依赖它。 调用 Herald 的业务接口时,把 API Key 放在 `X-API-Key` 请求头里即可。比如前面创建 challenge 的请求里,我们已经这样带上了它: ```bash -H "X-API-Key: $API_KEY" ``` 本地测试可以用占位值。真实环境不要继续使用 `your-secret-api-key-here`,也不要把真实 API Key 写进仓库。 如果 Herald 已经成为认证链路里的关键服务,可以考虑 HMAC。 单密钥配置: ```bash HMAC_SECRET=your-hmac-secret ``` 多密钥配置: ```bash HERALD_HMAC_KEYS='{"stargate-v1":"secret-1","stargate-v2":"secret-2"}' ``` 多密钥的好处是可以做轮换。调用方通过 `X-Key-Id` 指定使用哪个 key,服务端可以在一段时间里同时接受新旧密钥。等迁移完成后,再移除旧密钥。 HMAC 请求一般会带上: ```Text X-Signature X-Timestamp X-Service X-Key-Id ``` HMAC 签名不是只检查几个请求头,而是用 `timestamp:service:body` 和密钥计算签名。也就是说,调用方和 Herald 两边要对时间、服务名和请求体保持一致。 这里的 `X-Timestamp` 很重要。它可以降低请求被重放的风险。 更高要求的环境,可以继续上 mTLS。它的部署成本更高,不一定是第一天必须做的事,但如果 Herald 位于更复杂的网络环境里,或者被多个关键服务调用,就值得考虑。 我的习惯是这样推进: ```Text 开发环境:API Key 内部测试:API Key + 内部网络 准生产:HMAC + 密钥轮换 生产关键链路:HMAC 或 mTLS + TLS + 审计 ``` 不要第一天就把链路做得太复杂,但也不要长期停留在“只要能访问到服务就能调用”的状态。 ### `CHALLENGE_EXPIRY` 和 `MAX_ATTEMPTS`:Challenge 生命周期 几个最常用的配置可以先这样: ```bash CHALLENGE_EXPIRY=5m MAX_ATTEMPTS=5 LOCKOUT_DURATION=10m RESEND_COOLDOWN=60s CODE_LENGTH=6 ALLOWED_PURPOSES=login,reset,bind,stepup ``` `CHALLENGE_EXPIRY` 决定验证码多久过期。内部工具登录场景里,5 分钟通常比较容易接受。更敏感的操作,比如绑定、重置、二次确认,可以根据实际场景缩短。 `MAX_ATTEMPTS` 和 `LOCKOUT_DURATION` 用来控制输错次数。验证码不能无限尝试。同一个 challenge 失败太多次以后,就应该锁住。 这里要注意,锁住的是这次 challenge,不是完整账号体系。Herald 不知道用户完整状态,也不应该替代 Warden。它只负责这次验证挑战本身。 `RESEND_COOLDOWN` 用来控制重发冷却。用户可能没收到邮件,短信可能延迟,页面也可能被误关。重发是必要的,但不能无限制。前端可以根据 Herald 返回的 `next_resend_in` 展示倒计时,而不是让用户反复点击。 `CODE_LENGTH` 默认可以从 6 位开始。验证码位数不应该孤立看,过期时间、失败次数、限流和服务间认证都要一起看。 `ALLOWED_PURPOSES` 建议尽早配置。不要把登录、重置、绑定、二次确认都混成一个用途。 比如: ```Text login 登录 reset 重置 bind 绑定邮箱或手机号 stepup 敏感操作二次确认 ``` 现在分清楚,后面审计和排查会清楚很多。 ### `RATE_LIMIT_*`:验证码接口要限流 验证码服务不应该没有限流。 哪怕只是内部环境,也应该先配一个基础值: ```bash RATE_LIMIT_PER_USER=10 RATE_LIMIT_PER_IP=5 RATE_LIMIT_PER_DESTINATION=10 ``` 大致含义是: ```Text 同一个 user_id 每小时最多创建多少 challenge 同一个 IP 每分钟最多创建多少 challenge 同一个 destination 每小时最多创建多少 challenge ``` 这三类限流都很重要。 只按用户限流,可能挡不住同一个 IP 扫很多目标。 只按 IP 限流,可能误伤 NAT 后面的一组用户。 只按 destination 限流,可能挡不住同一个攻击者换很多邮箱或手机号。 多维限流不是为了把系统弄复杂,而是验证码接口天然容易被滥用。不要等短信费用被刷了,或者邮箱被打爆了,再想起来补这一层。 ### `HERALD_TEST_MODE`:只给本地和测试用 本地联调时,可以启用: ```bash HERALD_TEST_MODE=true ``` 这样创建 challenge 时,响应里可能带 `debug_code`,也可以通过测试接口查询验证码。 这对没有真实短信或邮件服务的环境很方便。你可以先验证 Stargate、Warden、Herald 之间的流程,不必真的发短信。 但生产环境必须关闭: ```bash HERALD_TEST_MODE=false ``` 测试模式会暴露明文验证码。不要因为“反正只有内网访问”就把它留在生产环境。 ### 发送失败策略 发送验证码可能失败。 SMTP 配置不对,短信网关超时,钉钉服务不可达,都可能导致发送失败。 Herald 支持发送失败策略: ```bash PROVIDER_FAILURE_POLICY=soft ``` `soft` 更适合本地测试或非关键场景。即使发送失败,也可以继续创建 challenge,方便你先调通链路。 真实登录场景里,我更倾向于: ```bash PROVIDER_FAILURE_POLICY=strict ``` 如果验证码没发出去,就不要创建一个用户永远收不到的 challenge。 这里没有绝对答案,要看场景。但至少要有意识地选择,不要把本地调试用的 `soft` 无意间带到生产登录链路里。 ## 接真实发送通道 Herald 本地跑通以后,下一步通常是接真实通道。 这里不要贪多。 先选一个通道,把它跑顺,再加第二个。 否则验证码没收到时,你很难判断到底是 Herald 的问题、Redis 的问题、服务间鉴权的问题,还是邮件、短信、钉钉服务本身的问题。 ### 邮件通道 邮件通常是最容易起步的通道。 你可以先用内置 SMTP: ```bash SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=your-user SMTP_PASSWORD=your-password SMTP_FROM=no-reply@example.com ``` 然后创建 email challenge: ```json { "user_id": "admin-001", "channel": "email", "destination": "admin@example.com", "purpose": "login" } ``` 如果邮件收不到,先不要急着看 Stargate。 先看 Herald 日志,再单独测试 SMTP,再确认 `SMTP_FROM`、账号、密码、端口和网络。 如果你希望把 SMTP 凭证和发送逻辑单独拆出去,可以使用 `herald-smtp`: ```bash HERALD_SMTP_API_URL=http://herald-smtp:8084 HERALD_SMTP_API_KEY=your-smtp-plugin-api-key ``` 这样 Herald 不直接保存 SMTP 凭证,而是通过 HTTP 把邮件发送请求转给 `herald-smtp`。 这个方式更符合“每个服务只管一件事”的思路。Herald 管 challenge 和验证,herald-smtp 管 SMTP 凭证和邮件发送。 ### 短信通道 短信适合需要更及时、更贴近手机验证的场景。 但短信也更容易带来成本和滥用风险。 在接短信之前,先确认限流、重发冷却和失败次数已经配置好: ```bash RATE_LIMIT_PER_USER=10 RATE_LIMIT_PER_IP=5 RATE_LIMIT_PER_DESTINATION=10 RESEND_COOLDOWN=60s MAX_ATTEMPTS=5 ``` 短信通道通常建议通过外部 HTTP API 网关接入: ```bash SMS_PROVIDER=http SMS_API_BASE_URL=http://sms-gateway:8080 SMS_API_KEY=your-sms-gateway-key ``` 我不建议让 Herald 直接理解每一个短信厂商的所有细节。 不同服务商的签名方式、模板参数、地域、错误码、频率限制都不一样。如果把这些全部写进 Herald,Herald 很快会变重。 更好的方式,是把短信服务商差异放在一个短信网关里。阿里云、腾讯云或其他服务商的密钥也放在那里。Herald 只知道自己要发一条短信,不直接保存具体厂商密钥。 这样后面换服务商、做灰度、做多通道兜底,都会轻很多。 ### 钉钉通道 如果团队已经在钉钉里工作,钉钉验证码会很自然。 配置大概是: ```bash HERALD_DINGTALK_API_URL=http://herald-dingtalk:8083 HERALD_DINGTALK_API_KEY=your-dingtalk-plugin-api-key ``` 然后创建 challenge 时使用: ```json { "user_id": "admin-001", "channel": "dingtalk", "destination": "admin-dingtalk-userid", "purpose": "login" } ``` 这时钉钉凭证和发送逻辑放在 `herald-dingtalk` 里。Herald 不直接保存钉钉凭证。 这类通道很适合内部系统,尤其是手机号不一定适合保存或发送、邮件又不够及时的环境。 不过也要注意:不要把一个人的企业 IM 账号当成唯一身份来源。 身份和准入仍然应该由 Warden 或你的用户系统判断。 Herald 只负责送达和校验。 ### TOTP Herald 也可以作为 TOTP 代理,把 Authenticator 相关请求转给 `herald-totp`: ```bash HERALD_TOTP_ENABLED=true HERALD_TOTP_BASE_URL=http://herald-totp:8085 HERALD_TOTP_API_KEY=your-totp-api-key ``` 启用后,调用方可以通过同一个 Herald 地址处理 OTP 和 TOTP 流程。 比如: ```Text /v1/totp/status /v1/totp/verify /v1/totp/enroll/start /v1/totp/enroll/confirm /v1/totp/revoke ``` 不过第一次使用 Herald 时,我不建议一上来就接 TOTP。 先把邮件或短信验证码跑通,再考虑 Authenticator 这类更强验证方式。 ## 作为独立服务怎么用 Herald 不一定要接在 Stargate 后面。 它本身就是一个独立的 OTP 和验证码服务。任何内部系统,只要有服务间认证,都可以调用它。 比如,你有一个内部后台,需要在用户执行敏感操作前做二次确认。 后台可以先创建一个 challenge: ```bash curl -X POST http://herald:8082/v1/otp/challenges \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: stepup-admin-001-20260529" \ -d '{ "user_id": "admin-001", "channel": "email", "destination": "admin@example.com", "purpose": "stepup", "locale": "zh-CN" }' ``` 用户收到验证码后,后台再校验: ```bash curl -X POST http://herald:8082/v1/otp/verifications \ -H "X-API-Key: $API_KEY" \ -H "Content-Type: application/json" \ -d '{ "challenge_id": "ch_7f9b...", "code": "123456" }' ``` 如果返回: ```json { "ok": true, "user_id": "admin-001", "amr": ["otp"], "issued_at": 1730000000 } ``` 后台再继续执行敏感操作。 如果返回: ```json { "ok": false, "reason": "expired" } ``` 就让用户重新发起验证。 如果返回: ```json { "ok": false, "reason": "locked" } ``` 就不要继续让用户尝试当前 challenge。 独立使用 Herald 时,我会注意三件事。 第一,调用方要自己确认用户是谁。 Herald 不负责判断 `admin-001` 是不是一个存在的用户,也不判断他是否应该继续操作。这应该由你的业务系统、Warden 或其他用户系统先完成。 第二,调用方要保存好 `challenge_id` 和上下文。 比如这次 challenge 对应的是登录、重置密码,还是敏感操作二次确认。验证成功后,也要确认返回的 `user_id` 和当前流程里期待的用户一致。 第三,调用方不要自己记录明文验证码。 验证码应该由 Herald 生成和校验。除了本地测试模式,不要把验证码明文返回给调用方,也不要把验证码打进日志。 ## 接到 Stargate 和 Warden 后会变成什么样 如果把三者组合起来,登录流程会更完整。 最初只有 Stargate 时,流程大概是: ```Text 用户访问受保护服务 ↓ Stargate 发现用户还没有登录 ↓ 用户输入共享口令 ↓ 口令正确,创建会话 ↓ 回到原来的服务 ``` 接入 Warden 后,流程变成: ```Text 用户访问受保护服务 ↓ Stargate 发现用户还没有登录 ↓ 用户输入邮箱、手机号或 user_id ↓ Stargate 调用 Warden 查询用户 ↓ Warden 返回用户是否存在、状态和联系方式 ↓ Stargate 判断是否继续登录流程 ``` 再接入 Herald 后,流程会变成: ```Text 用户访问受保护服务 ↓ Stargate 发现用户还没有登录 ↓ 用户输入邮箱、手机号或 user_id ↓ Stargate 调用 Warden 查询用户 ↓ Warden 返回 user_id、status、destination、channel_hint ↓ Stargate 确认用户存在,并且 status 是 active ↓ Stargate 调用 Herald 创建 challenge 并发送验证码 ↓ Herald 返回 challenge_id ↓ 用户输入验证码 ↓ Stargate 调用 Herald 校验 challenge ↓ 校验成功,Stargate 创建会话 ↓ 回到原来的服务 ``` 这里最重要的还是边界。 Warden 查到用户,不代表一定要发验证码。Stargate 需要先判断 `status` 是不是 `active`。 Herald 校验成功,也不代表它自己创建会话。创建会话仍然是 Stargate 的事情。 Warden 返回手机号或邮箱,也不代表它要知道验证码有没有发出去。发送、过期、失败次数和限流都是 Herald 的事情。 如果用环境变量表达,组合关系大概可以是这样: ```bash WARDEN_ENABLED=true WARDEN_URL=http://warden:8081 WARDEN_API_KEY=your-warden-api-key HERALD_ENABLED=true HERALD_URL=http://herald:8082 HERALD_API_KEY=your-herald-api-key ``` 具体变量名要以调用方当前实现为准,但关系是清楚的: ```Text Stargate 调 Warden 查人 Stargate 调 Herald 发码和验码 Warden 不调 Herald Herald 不调 Warden ``` 在 Compose 里,Herald 也应该和 Warden 一样放在内部网络里: ```yaml services: stargate: image: soulteary/stargate:latest networks: - proxy - auth-internal environment: - WARDEN_ENABLED=true - WARDEN_URL=http://warden:8081 - WARDEN_API_KEY=${WARDEN_API_KEY} - HERALD_ENABLED=true - HERALD_URL=http://herald:8082 - HERALD_API_KEY=${HERALD_API_KEY} warden: image: ghcr.io/soulteary/warden:latest networks: - auth-internal environment: - API_KEY=${WARDEN_API_KEY} herald: build: context: ./herald dockerfile: docker/Dockerfile networks: - auth-internal environment: - PORT=:8082 - REDIS_ADDR=herald-redis:6379 - API_KEY=${HERALD_API_KEY} - HERALD_TEST_MODE=false herald-redis: image: redis:7-alpine networks: - auth-internal volumes: - herald_redis_data:/data networks: proxy: external: true auth-internal: internal: true volumes: herald_redis_data: ``` 这只是示意配置,重点是: ```Text 外部用户只能访问 Stargate 和被保护服务 Warden 不直接暴露给外部用户 Herald 不直接暴露给外部用户 Redis 不直接暴露给外部用户 ``` 用户能看到登录页,但看不到 Warden,也看不到 Herald。 这条链路里,真正对外的是入口。 送信人不应该站在门外给所有人随便喊。 ## 真实使用时的几个建议 如果你准备把 Herald 放进自己的认证链路里,我建议按小步来。 先用测试模式跑通 challenge。 ```bash HERALD_TEST_MODE=true PROVIDER_FAILURE_POLICY=soft ``` 这一步只验证 Herald 自己的核心能力:创建 challenge,拿到测试验证码,提交 code,验证成功。 如果这一步都没跑通,就不要急着接 Stargate、Warden 或通道服务。 接着打开服务间认证。 本地测试也建议带 API Key: ```bash API_KEY=your-secret-api-key-here ``` 等链路跑通后,再考虑 HMAC 或 mTLS。 然后只接一个真实通道。 先邮件,或者先短信。不要同时接三个通道。验证码没收到时,排查链路要尽量短。 单通道跑通之后,再加第二个通道。 再把 `purpose` 用起来。 不要所有场景都用 `login`。登录、重置、绑定、敏感操作二次确认,最好从一开始就分开。 接着确认限流。 ```bash RATE_LIMIT_PER_USER=10 RATE_LIMIT_PER_IP=5 RATE_LIMIT_PER_DESTINATION=10 RESEND_COOLDOWN=60s ``` 这些值后面可以根据实际情况调整,但不要没有。 最后再接 Stargate 和 Warden。 排查时按顺序拆开: ```Text Warden 能不能查到用户? ↓ Stargate 能不能访问 Warden? ↓ 用户 status 是不是 active? ↓ Stargate 能不能访问 Herald? ↓ Herald API Key 是否正确? ↓ Herald 能不能创建 challenge? ↓ 验证码有没有送达? ↓ Herald 能不能验证 code? ↓ Stargate 有没有创建会话? ``` 不要从“浏览器登录失败”开始猜。 链路越长,越要一段一段确认。 ## 上线前的最小安全检查 Herald 在认证链路里的位置很敏感。 它不保存完整用户资料,也不创建登录会话,但它能触发验证码发送,能验证一次登录或敏感操作的关键步骤。 所以它不应该裸奔。 ### 不要把 Herald 直接暴露到公网 本地测试时使用: ```yaml ports: - "8082:8082" ``` 很方便。 但真实部署时,Herald 更适合只跑在内部网络里。 访问链路应该是: ```Text 用户浏览器 ↓ Traefik / Nginx ↓ Stargate ↓ Herald ``` 用户不应该直接访问 Herald。 如果确实需要跨机器访问,也应该通过 VPN、内网、服务网格、受控反向代理或安全组限制访问范围。 ### 生产环境必须关闭测试模式 上线前确认: ```bash HERALD_TEST_MODE=false ``` 不要让 `debug_code` 出现在生产响应里。 不要让 `/v1/test/code/:challenge_id` 在生产环境可用。 不要把验证码打进日志。 测试模式是为了本地联调,不是为了省事。 ### 服务间认证必须开启 至少设置: ```bash API_KEY=your-real-api-key ``` 更稳的方式是 HMAC: ```bash HERALD_HMAC_KEYS='{"stargate-v1":"secret-1","stargate-v2":"secret-2"}' ``` 更高要求的环境,可以考虑 mTLS。 这里的原则很简单: - 能创建验证码的人,必须是可信调用方。 - 能验证 challenge 的人,也必须是可信调用方。 ### Redis 不要和普通缓存混用 Herald 会在 Redis 里放 challenge、限流、幂等和锁定信息。 生产环境建议: ```Text 独立 Redis 实例 或者独立 Redis DB 开启密码 限制网络访问 监控 Redis 异常 做好持久化和备份策略 ``` 不要把 Herald 的 Redis 直接暴露到公网。 也不要把它和一堆不受控的业务缓存混在一起。 ### 密钥不要进仓库 不要把这些写进 Git: ```Text API_KEY HMAC_SECRET HERALD_HMAC_KEYS SMTP_PASSWORD SMS_API_KEY HERALD_SMTP_API_KEY HERALD_DINGTALK_API_KEY HERALD_TOTP_API_KEY TLS 私钥 ``` 本地可以用 `.env`,但 `.env` 不应该提交。 示例文件可以写: ```bash API_KEY=change-me HMAC_SECRET=change-me SMTP_PASSWORD=change-me ``` 真实值应该来自 Secret 管理、部署平台环境变量、Docker Secret、Kubernetes Secret 或专门的密钥系统。 ### 日志和审计要脱敏 Herald 的日志和审计很有用。 但它们也可能成为敏感信息泄露点。 不要完整记录: ```Text 验证码明文 完整手机号 完整邮箱 API Key HMAC Secret 短信或邮件服务商 Token ``` 可以考虑启用 destination 脱敏: ```bash AUDIT_MASK_DESTINATION=true ``` 生产环境里,日志是为了排查问题,不是为了复制一份验证码和联系方式。 ### `/metrics` 也要控制访问 Herald 可以暴露 Prometheus 指标。 这对长期运行很有用,比如观察 challenge 创建数量、发送成功率、验证成功率、失败原因、限流命中和 Redis 延迟。 但 `/metrics` 也不应该随便暴露给外部用户。 更合适的方式,是只允许 Prometheus、内网监控或受控网段访问。 ### 真实通道建议用严格发送策略 本地测试时: ```bash PROVIDER_FAILURE_POLICY=soft ``` 可以帮助你先跑通流程。 但真实登录环境里,如果验证码没发出去,通常不应该继续创建 challenge。 可以考虑: ```bash PROVIDER_FAILURE_POLICY=strict ``` 这样发送失败时,调用方能更明确地知道这次验证流程没有开始成功。 ### 一个比较稳的最小部署形态 如果只是内部使用,我觉得 Herald 的起步部署可以是这样: ```Text Herald 只在内部网络里 ↓ Redis 独立或独立 DB ↓ API Key 或 HMAC 必须开启 ↓ HERALD_TEST_MODE=false ↓ 至少开启基础限流 ↓ 真实通道配置通过 Secret 注入 ↓ 日志和审计脱敏 ↓ /metrics 只给监控访问 ``` 对应到 Compose,大概像这样: ```yaml services: herald: build: context: ./herald dockerfile: docker/Dockerfile restart: unless-stopped environment: - PORT=:8082 - REDIS_ADDR=herald-redis:6379 - REDIS_PASSWORD=${HERALD_REDIS_PASSWORD} - REDIS_DB=0 - API_KEY=${HERALD_API_KEY} - CHALLENGE_EXPIRY=5m - MAX_ATTEMPTS=5 - LOCKOUT_DURATION=10m - RESEND_COOLDOWN=60s - CODE_LENGTH=6 - ALLOWED_PURPOSES=login,reset,bind,stepup - RATE_LIMIT_PER_USER=10 - RATE_LIMIT_PER_IP=5 - RATE_LIMIT_PER_DESTINATION=10 - HERALD_TEST_MODE=false - AUDIT_ENABLED=true - AUDIT_MASK_DESTINATION=true networks: - auth-internal depends_on: - herald-redis herald-redis: image: redis:7-alpine restart: unless-stopped command: ["redis-server", "--appendonly", "yes", "--requirepass", "${HERALD_REDIS_PASSWORD}"] volumes: - herald_redis_data:/data networks: - auth-internal networks: auth-internal: internal: true volumes: herald_redis_data: ``` 这里没有 `ports`。 这意味着 Herald 不会直接暴露到宿主机端口。它只给同一个内部网络里的 Stargate 或可信调用方访问。 本地调试可以临时加端口映射,调试完成后再去掉。以及,注意不要把调试配置直接带进生产环境。 ## 最后 到这里,Stargate、Warden、Herald 三件事就串起来了。 Stargate 解决入口问题,先把门装上。 Warden 解决名单问题,让门开始认人。 Herald 解决验证问题,把验证码和消息送到该送的人手里。 这三个工具放在一起,并不是想重新做一套完整 IAM。完整 IAM 要处理的是账号生命周期、组织架构、协议、权限、策略、审计、多因素认证和治理体系。 Stargate、Warden、Herald 更像是几个可以独立工作的基础设施小工具。 每个工具只先解决一个具体问题: ```Text Stargate 负责守入口 Warden 负责管名单 Herald 负责送验证码 ``` 你可以先只用 Stargate,用共享口令把几个内部服务保护起来。 等共享口令不够用了,再接 Warden,用邮箱、手机号或 `user_id` 判断谁还能继续登录。 等登录流程需要更明确的验证,再接 Herald,把验证码发出去,并让它负责过期、失败次数、限流、撤销和审计。 再往后,如果服务更多、要求更高,再考虑 HMAC、mTLS、专用 Redis、持久化审计、Prometheus 指标、OpenTelemetry、TOTP,或者直接接入更完整的身份体系。 这条路径不追求第一天就把功能堆满,而是每加一步,都知道它解决了什么问题。 第一天不要太重。 但也不要一直停在共享口令里。 Herald 想补的,就是“已经知道这个人可以继续往下走”之后的那一步: 把这次验证送到他手里。 - 它不决定谁能进。 - 它不创建最终会话。 - 它做的事情不大,但边界很清楚: 创建验证码,送出验证码,校验验证码。 先装门,再认人,最后送信。 如果这个项目对你有帮助,欢迎到 GitHub 顺手点个 Star:[https://github.com/soulteary/herald](https://github.com/soulteary/herald) > 星门启闭,守望验身,鸦使送信。 下一篇系列文章,让我们聊聊如何简单的使用这三个软件。 --EOF