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_id 和 code 交给 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 表示发送通道,可以是 email、sms 或 dingtalk。
  • destination 是实际送达地址。邮件通道里它是邮箱,短信通道里它是手机号,钉钉通道里可以是钉钉用户 ID,或者在对应适配器支持的情况下使用手机号。
  • purpose 表示这次验证码的用途,比如 login、reset、bind、stepup。

我建议从第一天就认真填写 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
}

对调用方来说,重点是 ok 和 user_id。

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

验证失败时,Herald 会返回:

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

reason 在验证阶段常见的是 expired、invalid、locked、verification_failed 等。创建 challenge 或重发验证码时,还可能遇到 rate_limit_exceeded、resend_cooldown、user_locked、send_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_KEY、HMAC_SECRET 和 HERALD_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_EXPIRY 和 MAX_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_ATTEMPTS 和 LOCKOUT_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