Herald(鸦使)是一个轻量的 OTP 和验证码服务。

它负责创建一次“验证挑战”,生成验证码,通过邮件、短信、钉钉这类通道把验证码送出去,再在用户输入验证码之后完成校验。它可以接在 Stargate 和 Warden 后面,作为认证链路里的“送信人”;也可以独立运行,给内部后台、脚本、平台或其他服务提供统一的验证码能力。

写在前面

前两篇里,我们先聊了 Stargate,又聊了 Warden。

Stargate 解决的是第一件事:

有没有登录?

它站在反向代理后面、业务服务前面,把认证逻辑前移到入口侧。后端服务不需要自己写登录页,不需要自己处理会话,也不需要在每个接口里重复判断“这个请求能不能进来”。

Warden 解决的是第二件事:

谁可以继续往下走?

它不做登录页,也不发验证码。它维护一份准入名册,让 Stargate 或其他调用方可以用邮箱、手机号或 user_id 查询一个人是否存在,状态是不是 active,后续验证应该联系到哪里。

这两步跑通之后,登录链路里还会剩下一个很现实的问题:

怎么确认这次登录,确实是这个人在操作?

如果用户输入的是邮箱,要不要发一封验证码邮件?如果输入的是手机号,要不要发一条短信?如果是企业内部协作场景,要不要走钉钉?验证码多久过期?输错几次以后要不要锁住?同一个用户反复点“重新发送”,要不要限流?发送失败了,是继续创建 challenge,还是直接让这次流程失败?

这些问题也和登录有关,但不太适合继续塞进 Stargate,也不应该塞进 Warden。

Stargate 应该继续守入口,管会话和登录流程。Warden 应该继续维护准入名单,回答“这个人现在还能不能继续往下走”。

验证码的创建、发送、校验、过期、限流和审计,应该交给另一个更专门的服务。

Herald 要补的,就是这一段。

它不是完整 IAM,也不是营销短信平台,更不是要替代邮件网关、短信服务商或企业通知系统。它只先做好一件小事:把认证流程里的验证码和一次性口令,送到该送的人手里,并在用户提交之后给出清楚的验证结果。

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,把验证码送出去,并在用户提交验证码时给出明确结果。

拆开之后,整个链路会清楚很多:

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 至少会经历这些阶段:

创建 challenge
生成验证码
发送验证码
等待用户输入
验证成功、验证失败、过期或锁定

这里每一步都和登录有关,但又不完全等于登录。

Herald 不需要知道登录页长什么样,也不需要知道用户最终会被带到哪个服务。它只需要知道几个比较具体的东西:

user_id 是谁
channel 是什么
验证码要发到哪里
purpose 是什么
过期时间是多少
失败几次后锁定
是否命中限流

同样,Warden 不需要知道验证码是怎么发的。它只需要返回用户状态和联系方式。比如:

{
  "user_id": "admin-001",
  "destination": {
    "email": "admin@example.com",
    "phone": "13800138000"
  },
  "status": "active",
  "channel_hint": "sms",
  "name": "管理员"
}

Stargate 拿到这个结果以后,如果确认用户存在,并且状态是 active,再调用 Herald:

{
  "user_id": "admin-001",
  "channel": "sms",
  "destination": "13800138000",
  "purpose": "login"
}

Herald 创建 challenge,发送验证码,然后返回一个 challenge_id

用户输入验证码后,Stargate 再把 challenge_idcode 交给 Herald 校验。校验通过,Stargate 创建会话;校验失败,Stargate 继续停留在验证流程。

这样拆开之后,每个服务都不用知道太多。

  • Stargate 不需要知道短信服务商怎么签名。
  • Warden 不需要知道验证码多久过期。
  • Herald 不需要判断这个人有没有资格登录。

后面替换起来也会轻很多。

  • 验证码通道从邮件换成短信,主要改 Herald。
  • 用户名单从本地 JSON 换成远程 API,主要改 Warden。
  • 入口从单域名登录变成多域名会话,主要改 Stargate。

不要让一个工具因为“顺手”就开始管太多。顺手写进去的时候很省事,后面拆出来的时候会很疼。

Herald 的边界应该保持简单:

创建验证码 challenge
发送验证码
校验验证码
撤销 challenge
限流、锁定、审计和可观测性

除此之外,它不应该变成用户目录,不应该变成登录网关,也不应该替代完整身份系统。

它只是认证链路里的鸦使,把该送的信送到该送的人手里。

Herald 是怎么工作的

Herald 的工作方式可以先用一句话理解:

调用方创建一个 challenge,Herald 生成验证码并发送;用户提交验证码后,调用方再让 Herald 校验这个 challenge。

最常见的流程是:

调用方准备 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 的请求大概是这样:

{
  "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 表示发送通道,可以是 emailsmsdingtalk
  • destination 是实际送达地址。邮件通道里它是邮箱,短信通道里它是手机号,钉钉通道里可以是钉钉用户 ID,或者在对应适配器支持的情况下使用手机号。
  • purpose 表示这次验证码的用途,比如 loginresetbindstepup

我建议从第一天就认真填写 purpose。登录、重置、绑定、敏感操作二次确认,不应该全部混成一个默认用途。现在看只是多传一个字段,后面做审计、排查和策略调整时,会省很多事。

返回结果大概是这样:

{
  "challenge_id": "ch_7f9b...",
  "expires_in": 300,
  "next_resend_in": 60
}
  • expires_in 告诉调用方验证码还有多久过期。
  • next_resend_in 告诉调用方下一次可以重新发送前,需要等待多久。

如果是本地测试,并且启用了 HERALD_TEST_MODE=true,创建 challenge 的响应里还可能带上 debug_code。这对本地联调很方便,但生产环境一定不要打开。

验证时,请求会更简单:

{
  "challenge_id": "ch_7f9b...",
  "code": "123456",
  "client_ip": "192.168.1.10"
}

验证成功时,Herald 会返回类似:

{
  "ok": true,
  "user_id": "admin-001",
  "amr": ["otp"],
  "issued_at": 1730000000
}

对调用方来说,重点是 okuser_id

确认返回成功,并且 user_id 和当前登录流程里期待的用户一致,再继续创建会话。不要只看 ok=true 就往下走,尤其是一个页面里可能重新发起过多次 challenge 的时候。

验证失败时,Herald 会返回:

{
  "ok": false,
  "reason": "invalid"
}

reason 在验证阶段常见的是 expiredinvalidlockedverification_failed 等。创建 challenge 或重发验证码时,还可能遇到 rate_limit_exceededresend_cooldownuser_lockedsend_failed 这类错误。调用方可以根据这些原因做不同提示,也可以统一展示更模糊的错误信息,避免给攻击者太多细节。

Herald 还支持撤销 challenge:

POST /v1/otp/challenges/{id}/revoke

用户取消登录、重新开始流程、绑定操作中途终止,或者你希望主动让某次验证码失效时,这个接口会很有用。

整个过程中,Redis 很重要。Herald 会用 Redis 保存 challenge 状态、限流计数、幂等记录和锁定信息。生产部署时,建议给 Herald 使用独立 Redis 实例,或者至少使用独立数据库索引。它的数据和认证流程有关,不适合和一堆普通业务缓存混在一起。

用 Docker Compose 快速跑起来

理解了工作方式之后,先把 Herald 跑起来。

这一节先不接 Stargate,也不接 Warden,不接真实短信服务商。只做一件事:让 Herald 能启动,能创建 challenge,能在本地测试模式下拿到验证码,并完成一次校验。

先把项目拉下来:

git clone https://github.com/soulteary/herald.git
cd herald

创建一个用于本地测试的 compose.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:

这份配置是本地体验用的,不是生产模板。

里面有两处要特别注意。

- HERALD_TEST_MODE=true

这只适合本地和测试环境。测试模式可能返回明文验证码,也可能开放测试取码接口。生产环境必须关掉。

ports:
  - "8082:8082"

本地调试为了方便,可以把端口映射出来。真实部署时,Herald 更适合放在内部网络里,只允许 Stargate 或其他可信调用方访问。

启动服务:

docker compose up -d --build

看一下容器状态:

docker compose ps

再看健康检查:

curl http://localhost:8082/healthz

正常情况下,会看到类似:

{
  "status": "ok",
  "service": "herald"
}

接着设置 API Key:

export API_KEY="your-secret-api-key-here"

创建一个测试 challenge:

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

{
  "challenge_id": "ch_7f9b...",
  "expires_in": 300,
  "next_resend_in": 60,
  "debug_code": "123456"
}

如果返回里没有 debug_code,也可以在测试模式下用 challenge ID 查询:

curl -H "X-API-Key: $API_KEY" \
  http://localhost:8082/v1/test/code/ch_7f9b...

然后验证验证码:

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"
  }'

成功时,会看到类似:

{
  "ok": true,
  "user_id": "admin-001",
  "amr": ["otp"],
  "issued_at": 1730000000
}

到这里,Herald 的最小链路就跑通了。

我们已经确认了几件事:服务能启动,Redis 能连上,API Key 能保护接口,challenge 能创建,验证码能校验。

这一步完成后,先不要急着接真实短信网关,也不要急着接 Stargate。

先把最短链路跑顺。

验证码链路一旦接入真实发送通道,排查范围会变大:可能是 Herald 配置问题,可能是 Redis 问题,可能是服务间鉴权问题,也可能是邮件、短信、钉钉服务本身的问题。

先用测试模式确认 challenge 和验证流程,再接通道,排查会轻很多。

常用配置说明

Herald 的配置项不少,但第一次使用时,不需要一口气看完。

我会先把它拆成三类。

第一类,是最小链路一定会碰到的:

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

第二类,是接真实通道时才需要认真看的:

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

第三类,是生产化和长期运行时再看的:

HMAC_SECRET / HERALD_HMAC_KEYS
TLS_CERT_FILE / TLS_KEY_FILE / TLS_CA_CERT_FILE
AUDIT_*
OTLP_*
HERALD_TOTP_*

这样拆开以后,会好理解很多。第一天先别急着把所有能力都打开。

端口和 Redis

Herald 默认监听 :8082

PORT=:8082

本地测试时保持默认即可。如果你的习惯是不带冒号,也可以写成:

PORT=8082

Redis 是 Herald 的核心依赖,用来保存 challenge、限流计数、幂等记录和锁定状态。

最小配置可以是:

REDIS_ADDR=redis:6379
REDIS_PASSWORD=
REDIS_DB=0

本地测试时,同一个 Compose 里的 Redis 就够了。

生产环境里,建议给 Herald 使用专用 Redis,或者至少使用独立数据库索引。验证码数据虽然不是完整用户资料,但它直接参与认证流程,不应该和普通缓存混在一起。

API_KEYHMAC_SECRETHERALD_HMAC_KEYS:服务间认证

Herald 的接口不应该裸奔。

创建 challenge 意味着可能触发验证码发送;验证 challenge 意味着参与登录流程。这类接口从第一天开始就应该有服务间认证。

最简单的方式是 API Key:

API_KEY=your-secret-api-key-here

如果没有配置 API Key、HMAC 或 mTLS,Herald 可能会记录警告并允许未认证请求。这个行为只适合开发和测试环境,真实部署时不要依赖它。

调用 Herald 的业务接口时,把 API Key 放在 X-API-Key 请求头里即可。比如前面创建 challenge 的请求里,我们已经这样带上了它:

-H "X-API-Key: $API_KEY"

本地测试可以用占位值。真实环境不要继续使用 your-secret-api-key-here,也不要把真实 API Key 写进仓库。

如果 Herald 已经成为认证链路里的关键服务,可以考虑 HMAC。

单密钥配置:

HMAC_SECRET=your-hmac-secret

多密钥配置:

HERALD_HMAC_KEYS='{"stargate-v1":"secret-1","stargate-v2":"secret-2"}'

多密钥的好处是可以做轮换。调用方通过 X-Key-Id 指定使用哪个 key,服务端可以在一段时间里同时接受新旧密钥。等迁移完成后,再移除旧密钥。

HMAC 请求一般会带上:

X-Signature
X-Timestamp
X-Service
X-Key-Id

HMAC 签名不是只检查几个请求头,而是用 timestamp:service:body 和密钥计算签名。也就是说,调用方和 Herald 两边要对时间、服务名和请求体保持一致。

这里的 X-Timestamp 很重要。它可以降低请求被重放的风险。

更高要求的环境,可以继续上 mTLS。它的部署成本更高,不一定是第一天必须做的事,但如果 Herald 位于更复杂的网络环境里,或者被多个关键服务调用,就值得考虑。

我的习惯是这样推进:

开发环境:API Key
内部测试:API Key + 内部网络
准生产:HMAC + 密钥轮换
生产关键链路:HMAC 或 mTLS + TLS + 审计

不要第一天就把链路做得太复杂,但也不要长期停留在“只要能访问到服务就能调用”的状态。

CHALLENGE_EXPIRYMAX_ATTEMPTS:Challenge 生命周期

几个最常用的配置可以先这样:

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_ATTEMPTSLOCKOUT_DURATION 用来控制输错次数。验证码不能无限尝试。同一个 challenge 失败太多次以后,就应该锁住。

这里要注意,锁住的是这次 challenge,不是完整账号体系。Herald 不知道用户完整状态,也不应该替代 Warden。它只负责这次验证挑战本身。

RESEND_COOLDOWN 用来控制重发冷却。用户可能没收到邮件,短信可能延迟,页面也可能被误关。重发是必要的,但不能无限制。前端可以根据 Herald 返回的 next_resend_in 展示倒计时,而不是让用户反复点击。

CODE_LENGTH 默认可以从 6 位开始。验证码位数不应该孤立看,过期时间、失败次数、限流和服务间认证都要一起看。

ALLOWED_PURPOSES 建议尽早配置。不要把登录、重置、绑定、二次确认都混成一个用途。

比如:

login    登录
reset    重置
bind     绑定邮箱或手机号
stepup   敏感操作二次确认

现在分清楚,后面审计和排查会清楚很多。

RATE_LIMIT_*:验证码接口要限流

验证码服务不应该没有限流。

哪怕只是内部环境,也应该先配一个基础值:

RATE_LIMIT_PER_USER=10
RATE_LIMIT_PER_IP=5
RATE_LIMIT_PER_DESTINATION=10

大致含义是:

同一个 user_id 每小时最多创建多少 challenge
同一个 IP 每分钟最多创建多少 challenge
同一个 destination 每小时最多创建多少 challenge

这三类限流都很重要。

只按用户限流,可能挡不住同一个 IP 扫很多目标。

只按 IP 限流,可能误伤 NAT 后面的一组用户。

只按 destination 限流,可能挡不住同一个攻击者换很多邮箱或手机号。

多维限流不是为了把系统弄复杂,而是验证码接口天然容易被滥用。不要等短信费用被刷了,或者邮箱被打爆了,再想起来补这一层。

HERALD_TEST_MODE:只给本地和测试用

本地联调时,可以启用:

HERALD_TEST_MODE=true

这样创建 challenge 时,响应里可能带 debug_code,也可以通过测试接口查询验证码。

这对没有真实短信或邮件服务的环境很方便。你可以先验证 Stargate、Warden、Herald 之间的流程,不必真的发短信。

但生产环境必须关闭:

HERALD_TEST_MODE=false

测试模式会暴露明文验证码。不要因为“反正只有内网访问”就把它留在生产环境。

发送失败策略

发送验证码可能失败。

SMTP 配置不对,短信网关超时,钉钉服务不可达,都可能导致发送失败。

Herald 支持发送失败策略:

PROVIDER_FAILURE_POLICY=soft

soft 更适合本地测试或非关键场景。即使发送失败,也可以继续创建 challenge,方便你先调通链路。

真实登录场景里,我更倾向于:

PROVIDER_FAILURE_POLICY=strict

如果验证码没发出去,就不要创建一个用户永远收不到的 challenge。

这里没有绝对答案,要看场景。但至少要有意识地选择,不要把本地调试用的 soft 无意间带到生产登录链路里。

接真实发送通道

Herald 本地跑通以后,下一步通常是接真实通道。

这里不要贪多。

先选一个通道,把它跑顺,再加第二个。

否则验证码没收到时,你很难判断到底是 Herald 的问题、Redis 的问题、服务间鉴权的问题,还是邮件、短信、钉钉服务本身的问题。

邮件通道

邮件通常是最容易起步的通道。

你可以先用内置 SMTP:

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-user
SMTP_PASSWORD=your-password
SMTP_FROM=no-reply@example.com

然后创建 email challenge:

{
  "user_id": "admin-001",
  "channel": "email",
  "destination": "admin@example.com",
  "purpose": "login"
}

如果邮件收不到,先不要急着看 Stargate。

先看 Herald 日志,再单独测试 SMTP,再确认 SMTP_FROM、账号、密码、端口和网络。

如果你希望把 SMTP 凭证和发送逻辑单独拆出去,可以使用 herald-smtp

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 凭证和邮件发送。

短信通道

短信适合需要更及时、更贴近手机验证的场景。

但短信也更容易带来成本和滥用风险。

在接短信之前,先确认限流、重发冷却和失败次数已经配置好:

RATE_LIMIT_PER_USER=10
RATE_LIMIT_PER_IP=5
RATE_LIMIT_PER_DESTINATION=10
RESEND_COOLDOWN=60s
MAX_ATTEMPTS=5

短信通道通常建议通过外部 HTTP API 网关接入:

SMS_PROVIDER=http
SMS_API_BASE_URL=http://sms-gateway:8080
SMS_API_KEY=your-sms-gateway-key

我不建议让 Herald 直接理解每一个短信厂商的所有细节。

不同服务商的签名方式、模板参数、地域、错误码、频率限制都不一样。如果把这些全部写进 Herald,Herald 很快会变重。

更好的方式,是把短信服务商差异放在一个短信网关里。阿里云、腾讯云或其他服务商的密钥也放在那里。Herald 只知道自己要发一条短信,不直接保存具体厂商密钥。

这样后面换服务商、做灰度、做多通道兜底,都会轻很多。

钉钉通道

如果团队已经在钉钉里工作,钉钉验证码会很自然。

配置大概是:

HERALD_DINGTALK_API_URL=http://herald-dingtalk:8083
HERALD_DINGTALK_API_KEY=your-dingtalk-plugin-api-key

然后创建 challenge 时使用:

{
  "user_id": "admin-001",
  "channel": "dingtalk",
  "destination": "admin-dingtalk-userid",
  "purpose": "login"
}

这时钉钉凭证和发送逻辑放在 herald-dingtalk 里。Herald 不直接保存钉钉凭证。

这类通道很适合内部系统,尤其是手机号不一定适合保存或发送、邮件又不够及时的环境。

不过也要注意:不要把一个人的企业 IM 账号当成唯一身份来源。

身份和准入仍然应该由 Warden 或你的用户系统判断。

Herald 只负责送达和校验。

TOTP

Herald 也可以作为 TOTP 代理,把 Authenticator 相关请求转给 herald-totp

HERALD_TOTP_ENABLED=true
HERALD_TOTP_BASE_URL=http://herald-totp:8085
HERALD_TOTP_API_KEY=your-totp-api-key

启用后,调用方可以通过同一个 Herald 地址处理 OTP 和 TOTP 流程。

比如:

/v1/totp/status
/v1/totp/verify
/v1/totp/enroll/start
/v1/totp/enroll/confirm
/v1/totp/revoke

不过第一次使用 Herald 时,我不建议一上来就接 TOTP。

先把邮件或短信验证码跑通,再考虑 Authenticator 这类更强验证方式。

作为独立服务怎么用

Herald 不一定要接在 Stargate 后面。

它本身就是一个独立的 OTP 和验证码服务。任何内部系统,只要有服务间认证,都可以调用它。

比如,你有一个内部后台,需要在用户执行敏感操作前做二次确认。

后台可以先创建一个 challenge:

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"
  }'

用户收到验证码后,后台再校验:

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"
  }'

如果返回:

{
  "ok": true,
  "user_id": "admin-001",
  "amr": ["otp"],
  "issued_at": 1730000000
}

后台再继续执行敏感操作。

如果返回:

{
  "ok": false,
  "reason": "expired"
}

就让用户重新发起验证。

如果返回:

{
  "ok": false,
  "reason": "locked"
}

就不要继续让用户尝试当前 challenge。

独立使用 Herald 时,我会注意三件事。

第一,调用方要自己确认用户是谁。

Herald 不负责判断 admin-001 是不是一个存在的用户,也不判断他是否应该继续操作。这应该由你的业务系统、Warden 或其他用户系统先完成。

第二,调用方要保存好 challenge_id 和上下文。

比如这次 challenge 对应的是登录、重置密码,还是敏感操作二次确认。验证成功后,也要确认返回的 user_id 和当前流程里期待的用户一致。

第三,调用方不要自己记录明文验证码。

验证码应该由 Herald 生成和校验。除了本地测试模式,不要把验证码明文返回给调用方,也不要把验证码打进日志。

接到 Stargate 和 Warden 后会变成什么样

如果把三者组合起来,登录流程会更完整。

最初只有 Stargate 时,流程大概是:

用户访问受保护服务
Stargate 发现用户还没有登录
用户输入共享口令
口令正确,创建会话
回到原来的服务

接入 Warden 后,流程变成:

用户访问受保护服务
Stargate 发现用户还没有登录
用户输入邮箱、手机号或 user_id
Stargate 调用 Warden 查询用户
Warden 返回用户是否存在、状态和联系方式
Stargate 判断是否继续登录流程

再接入 Herald 后,流程会变成:

用户访问受保护服务
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 的事情。

如果用环境变量表达,组合关系大概可以是这样:

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

具体变量名要以调用方当前实现为准,但关系是清楚的:

Stargate 调 Warden 查人
Stargate 调 Herald 发码和验码
Warden 不调 Herald
Herald 不调 Warden

在 Compose 里,Herald 也应该和 Warden 一样放在内部网络里:

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:

这只是示意配置,重点是:

外部用户只能访问 Stargate 和被保护服务
Warden 不直接暴露给外部用户
Herald 不直接暴露给外部用户
Redis 不直接暴露给外部用户

用户能看到登录页,但看不到 Warden,也看不到 Herald。

这条链路里,真正对外的是入口。

送信人不应该站在门外给所有人随便喊。

真实使用时的几个建议

如果你准备把 Herald 放进自己的认证链路里,我建议按小步来。

先用测试模式跑通 challenge。

HERALD_TEST_MODE=true
PROVIDER_FAILURE_POLICY=soft

这一步只验证 Herald 自己的核心能力:创建 challenge,拿到测试验证码,提交 code,验证成功。

如果这一步都没跑通,就不要急着接 Stargate、Warden 或通道服务。

接着打开服务间认证。

本地测试也建议带 API Key:

API_KEY=your-secret-api-key-here

等链路跑通后,再考虑 HMAC 或 mTLS。

然后只接一个真实通道。

先邮件,或者先短信。不要同时接三个通道。验证码没收到时,排查链路要尽量短。

单通道跑通之后,再加第二个通道。

再把 purpose 用起来。

不要所有场景都用 login。登录、重置、绑定、敏感操作二次确认,最好从一开始就分开。

接着确认限流。

RATE_LIMIT_PER_USER=10
RATE_LIMIT_PER_IP=5
RATE_LIMIT_PER_DESTINATION=10
RESEND_COOLDOWN=60s

这些值后面可以根据实际情况调整,但不要没有。

最后再接 Stargate 和 Warden。

排查时按顺序拆开:

Warden 能不能查到用户?
Stargate 能不能访问 Warden?
用户 status 是不是 active?
Stargate 能不能访问 Herald?
Herald API Key 是否正确?
Herald 能不能创建 challenge?
验证码有没有送达?
Herald 能不能验证 code?
Stargate 有没有创建会话?

不要从“浏览器登录失败”开始猜。

链路越长,越要一段一段确认。

上线前的最小安全检查

Herald 在认证链路里的位置很敏感。

它不保存完整用户资料,也不创建登录会话,但它能触发验证码发送,能验证一次登录或敏感操作的关键步骤。

所以它不应该裸奔。

不要把 Herald 直接暴露到公网

本地测试时使用:

ports:
  - "8082:8082"

很方便。

但真实部署时,Herald 更适合只跑在内部网络里。

访问链路应该是:

用户浏览器
Traefik / Nginx
Stargate
Herald

用户不应该直接访问 Herald。

如果确实需要跨机器访问,也应该通过 VPN、内网、服务网格、受控反向代理或安全组限制访问范围。

生产环境必须关闭测试模式

上线前确认:

HERALD_TEST_MODE=false

不要让 debug_code 出现在生产响应里。

不要让 /v1/test/code/:challenge_id 在生产环境可用。

不要把验证码打进日志。

测试模式是为了本地联调,不是为了省事。

服务间认证必须开启

至少设置:

API_KEY=your-real-api-key

更稳的方式是 HMAC:

HERALD_HMAC_KEYS='{"stargate-v1":"secret-1","stargate-v2":"secret-2"}'

更高要求的环境,可以考虑 mTLS。

这里的原则很简单:

  • 能创建验证码的人,必须是可信调用方。
  • 能验证 challenge 的人,也必须是可信调用方。

Redis 不要和普通缓存混用

Herald 会在 Redis 里放 challenge、限流、幂等和锁定信息。

生产环境建议:

独立 Redis 实例
或者独立 Redis DB
开启密码
限制网络访问
监控 Redis 异常
做好持久化和备份策略

不要把 Herald 的 Redis 直接暴露到公网。

也不要把它和一堆不受控的业务缓存混在一起。

密钥不要进仓库

不要把这些写进 Git:

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 不应该提交。

示例文件可以写:

API_KEY=change-me
HMAC_SECRET=change-me
SMTP_PASSWORD=change-me

真实值应该来自 Secret 管理、部署平台环境变量、Docker Secret、Kubernetes Secret 或专门的密钥系统。

日志和审计要脱敏

Herald 的日志和审计很有用。

但它们也可能成为敏感信息泄露点。

不要完整记录:

验证码明文
完整手机号
完整邮箱
API Key
HMAC Secret
短信或邮件服务商 Token

可以考虑启用 destination 脱敏:

AUDIT_MASK_DESTINATION=true

生产环境里,日志是为了排查问题,不是为了复制一份验证码和联系方式。

/metrics 也要控制访问

Herald 可以暴露 Prometheus 指标。

这对长期运行很有用,比如观察 challenge 创建数量、发送成功率、验证成功率、失败原因、限流命中和 Redis 延迟。

/metrics 也不应该随便暴露给外部用户。

更合适的方式,是只允许 Prometheus、内网监控或受控网段访问。

真实通道建议用严格发送策略

本地测试时:

PROVIDER_FAILURE_POLICY=soft

可以帮助你先跑通流程。

但真实登录环境里,如果验证码没发出去,通常不应该继续创建 challenge。

可以考虑:

PROVIDER_FAILURE_POLICY=strict

这样发送失败时,调用方能更明确地知道这次验证流程没有开始成功。

一个比较稳的最小部署形态

如果只是内部使用,我觉得 Herald 的起步部署可以是这样:

Herald 只在内部网络里
Redis 独立或独立 DB
API Key 或 HMAC 必须开启
HERALD_TEST_MODE=false
至少开启基础限流
真实通道配置通过 Secret 注入
日志和审计脱敏
/metrics 只给监控访问

对应到 Compose,大概像这样:

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 更像是几个可以独立工作的基础设施小工具。

每个工具只先解决一个具体问题:

Stargate 负责守入口
Warden   负责管名单
Herald   负责送验证码

你可以先只用 Stargate,用共享口令把几个内部服务保护起来。

等共享口令不够用了,再接 Warden,用邮箱、手机号或 user_id 判断谁还能继续登录。

等登录流程需要更明确的验证,再接 Herald,把验证码发出去,并让它负责过期、失败次数、限流、撤销和审计。

再往后,如果服务更多、要求更高,再考虑 HMAC、mTLS、专用 Redis、持久化审计、Prometheus 指标、OpenTelemetry、TOTP,或者直接接入更完整的身份体系。

这条路径不追求第一天就把功能堆满,而是每加一步,都知道它解决了什么问题。

第一天不要太重。

但也不要一直停在共享口令里。

Herald 想补的,就是“已经知道这个人可以继续往下走”之后的那一步:

把这次验证送到他手里。

  • 它不决定谁能进。
  • 它不创建最终会话。
  • 它做的事情不大,但边界很清楚:

创建验证码,送出验证码,校验验证码。

先装门,再认人,最后送信。

如果这个项目对你有帮助,欢迎到 GitHub 顺手点个 Star:https://github.com/soulteary/herald

星门启闭,守望验身,鸦使送信。

下一篇系列文章,让我们聊聊如何简单的使用这三个软件。

–EOF