必须在Kratos中自研中间件,按顺序校验签名(含原始body+timestamp+nonce)、时间戳(误差≤300秒且拒未来超10秒)、nonce(Redis SETNX原子写入,过期设300秒以上),并确保验签在binding前完成且body可复用。

Kratos 里怎么加时间戳 + nonce 校验
防重放不是靠单个字段,而是时间戳和 nonce 必须同时存在、且都参与签名计算。Kratos 本身不内置验签中间件,得自己写 middleware 拦截 HTTP 请求体并校验。关键点在于:时间戳必须在签名材料里(不能只校验 header 或 query 中的 timestamp),否则攻击者可复用旧请求+伪造新时间;nonce 必须用 Redis 做原子写入(SETNX),过期时间要略大于时间窗口(比如窗口 5 分钟,nonce 过期设 6 分钟)。
常见错误现象:
- 用
time.Now().UnixMilli()直接比对,没考虑服务端时钟漂移,导致合法请求被误拒 - nonce 存 Redis 用
SET而非SETNX,高并发下重复请求可能同时通过校验 - 解析请求体后重新
json.Marshal再签名,空格、键序变化导致验签失败(Stripe 等平台明确要求原始字节)
签名验证必须用原始请求体,不能走 Bind
Kratos 的 binding.Bind 或 httpx.Parse 会提前读取并解码 body,之后 r.Body 就成空了。验签中间件必须在 binding 之前执行,并用 io.ReadAll(r.Body) 一次性读出原始字节,再用 bytes.NewReader 把它“塞回去”。否则后续业务逻辑拿不到数据。
实操建议:
- 在 middleware 中调用
body, _ := io.ReadAll(r.Body),然后立即调用r.Body = io.NopCloser(bytes.NewReader(body)) - 签名算法推荐
HmacSHA256,密钥从config.Config加载,不要硬编码 - 拼接待签名字符串时,按协议固定顺序:比如
timestamp=xxx&nonce=yyy&body=zzz,body 部分用 URL-safe base64 编码原始字节,避免特殊字符干扰
怎么让验签和去重在同一个事务里生效
验签通过 ≠ 可以执行业务。如果只校验签名和时间戳,但没记录“这个 nonce 已处理”,重放请求仍会成功。Kratos 项目通常用 MySQL 或 PostgreSQL,建议把“保存 nonce 记录”和“业务主操作”放在同一个数据库事务中,而不是依赖 Redis 过期自动清理。
原因很实际:
- Redis 故障或网络分区时,nonce 可能漏存,导致重复请求通过
- 业务表和 Redis key 不在同一持久化域,无法保证原子性
- 某些场景(如支付回调)要求强一致性,必须“写成功才认为处理过”
所以更稳妥的做法是:建一张 api_nonce_log 表,联合唯一索引 (app_id, nonce),插入失败即拒绝请求。哪怕 Redis 挂了,DB 层依然能兜底。
为什么 Kratos 的 JWT 中间件不能替代接口验签
middleware/auth/jwt/jwt.go 解的是用户身份令牌,解决的是“你是谁”,不是“这个请求是否被重放”。JWT 里的 exp 是令牌过期时间,不是单次请求的时间窗口;它不包含 nonce,也不校验请求体完整性。两者定位完全不同:JWT 是认证层,验签是接口层安全。
容易混淆的点:
- 有人把 timestamp 放进 JWT claim 里,以为能防重放——但 JWT 签名不覆盖原始 HTTP body,body 仍可被篡改
- 用同一个密钥既签 JWT 又签接口请求,一旦泄露,两个层面全崩
- 内部服务调用走 gRPC,JWT 头根本不存在,而接口验签规则又没适配 gRPC metadata
真正难的不是写验签逻辑,而是让时间戳容忍范围、nonce 生命周期、Redis 连接稳定性、原始 body 读取时机这四件事在所有服务节点上保持一致。任何一环松动,防重放就形同虚设。


















