Herald(鸦使)是一个轻量的 OTP 和验证码服务。
它负责创建一次“验证挑战”,生成验证码,通过邮件、短信、钉钉这类通道把验证码送出去,再在用户输入验证码之后完成校验。它可以接在 Stargate 和 Warden 后面,作为认证链路里的“送信人”;也可以独立运行,给内部后台、脚本、平台或其他服务提供统一的验证码能力。
写在前面
前两篇里,我们先聊了 Stargate,又聊了 Warden。
Stargate 解决的是第一件事:
有没有登录?
它站在反向代理后面、业务服务前面,把认证逻辑前移到入口侧。后端服务不需要自己写登录页,不需要自己处理会话,也不需要在每个接口里重复判断“这个请求能不能进来”。
Warden 解决的是第二件事:
谁可以继续往下走?
它不做登录页,也不发验证码。它维护一份准入名册,让 Stargate 或其他调用方可以用邮箱、手机号或 user_id 查询一个人是否存在,状态是不是 active,后续验证应该联系到哪里。
这两步跑通之后,登录链路里还会剩下一个很现实的问题:
怎么确认这次登录,确实是这个人在操作?
如果用户输入的是邮箱,要不要发一封验证码邮件?如果输入的是手机号,要不要发一条短信?如果是企业内部协作场景,要不要走钉钉?验证码多久过期?输错几次以后要不要锁住?同一个用户反复点“重新发送”,要不要限流?发送失败了,是继续创建 challenge,还是直接让这次流程失败?
这些问题也和登录有关,但不太适合继续塞进 Stargate,也不应该塞进 Warden。
Stargate 应该继续守入口,管会话和登录流程。Warden 应该继续维护准入名单,回答“这个人现在还能不能继续往下走”。
验证码的创建、发送、校验、过期、限流和审计,应该交给另一个更专门的服务。
Herald 要补的,就是这一段。
它不是完整 IAM,也不是营销短信平台,更不是要替代邮件网关、短信服务商或企业通知系统。它只先做好一件小事:把认证流程里的验证码和一次性口令,送到该送的人手里,并在用户提交之后给出清楚的验证结果。

项目地址: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