design.md 5.4 KB

签名协议(客户端必须遵守)

请求头固定格式:

X-MJ-Key:       {调用方标识,便于审计,非秘密}
X-MJ-Timestamp: {Unix 毫秒}
X-MJ-Nonce:     {32 字符随机串}
X-MJ-Signature: {HMAC-SHA256(sharedSecret, timestamp + "\n" + nonce + "\n" + method + "\n" + path + "\n" + bodyHash)}

其中:

  • methodGET / POST / ...(大写)
  • path:不含 host 与 query 的路径(如 /api/mcli/personnel/sync
  • bodyHashSHA256(requestBody bytes) 的 hex;GET 请求 body 为空字符串

签名算法固定 HMAC-SHA256,结果以 hex 小写输出。

请求生命周期

请求到达
  ↓
AuthFilter.doFilter
  ├── enabled == false?  → 直接 chain.doFilter (默认行为)
  ├── path 在 exempt-paths?  → 放行
  ├── handler method 有 @NoAuth?  → 放行(需要 spring MVC 上下文,见下)
  │
  ├── 校验 Header 齐全 → 缺字段 401
  ├── 校验 timestamp 在 ±window 内 → 超窗 401
  ├── 校验 nonce 未用过 → NonceCache.putIfAbsent → 冲突 401(重放)
  ├── 计算期望 signature → 比对 → 不匹配 403
  └── 全部通过 → MDC 记录 authKey → chain.doFilter

afterCompletion
  ├── 清 MDC

实现难点AuthFilterjavax.servlet.Filter,在 Spring MVC 分发前执行,无法直接拿到 HandlerMethod 检查 @NoAuth。两种解决方案:

  • 方案 A(推荐):AuthFilter 只做 Header 齐全性与时间戳/Nonce 校验;签名校验延后到 HandlerInterceptor.preHandle(那时能拿到 HandlerMethod,可读 @NoAuth
  • 方案 BAuthFilter 一次到位,@NoAuth 只支持类级别(通过 RequestMappingHandlerMapping 提前解析)

Phase 1 选方案 A(拆分两层),让注解豁免精确到方法。

Nonce 去重

NonceCache 基于 UtilToken.TimedCache

  • Key = nonce:{X-MJ-Nonce}
  • Value = 任意占位(如 X-MJ-Key,便于日志)
  • TTL = mjava.auth.window + 30s(窗口 + 冗余 30s,避免边界时间戳造成"刚过期还被接受")
  • 容量上限 nonce-cache-size,LRU 淘汰

Nonce 已存在 → 判定重放。

豁免策略

三种豁免方式(优先级从上到下):

  1. enabled: false(全局开关):完全不做校验
  2. exempt-paths(路径匹配):Ant 风格,/actuator/** / /api/*/callback/**
  3. @NoAuth 注解:方法级或类级,粒度最细

密钥管理

  • Phase 1:单一共享密钥 mjava.auth.secret,通过环境变量 AUTH_SECRET 注入
  • 换密钥:两段式。先同时接受旧 + 新密钥(secret + secret-next),客户端切完后再移除旧密钥。本 change 不做换密钥能力(YAGNI),需要时再加
  • Phase 2(mjava-com):从宜搭权限表单动态加载 callerId → secret 映射,替换 Phase 1 的单密钥

错误返回

统一返回 McR.fail(),错误码:

HTTP code 原因
401 AUTH_HEADER_MISSING 必备 Header 缺失
401 AUTH_TIMESTAMP_OUT_OF_WINDOW 时间戳超窗
401 AUTH_NONCE_REPLAYED Nonce 已使用(重放)
403 AUTH_SIGNATURE_INVALID 签名不匹配
503 AUTH_CONFIG_MISSING 服务端未配置 secret(enabled: truesecret 为空)

出于安全考虑,错误信息不暴露具体环节("Header 缺" 不告诉缺哪个、"签名错" 不告诉期望值),避免给攻击者定位线索。日志侧记录详情。

配置项详细

mjava:
  auth:
    enabled: false                # 开关
    secret: ${AUTH_SECRET}        # HMAC 共享密钥
    secret-next:                  # 灰度切换密钥(可选,本 change 先不实现)
    window: 300                   # 秒
    nonce-cache-size: 10000       # 条目上限
    exempt-paths:
      - /actuator/**
      - /api/*/callback/**
      - /api/*/sso/**             # 部分 SSO 回跳自带签名

兼容策略

  • 默认 enabled: false,生产服务启动后行为 = 当前现状 ✅ 零破坏
  • 客户升级时机:客户侧接入完成签名 → 打开 enabled: true → 灰度切换
  • 若某客户永远不开启,也不影响(功能可选)

Non-Goals(设计边界)

  • 不做分布式 Nonce 去重(单实例内存够用;多实例部署时再评估 Redis,见 BACKLOG 触发条件)
  • 不做密钥轮换自动化
  • 不做基于 IP 的访问控制(交给运维层 / 网关)
  • 不做 QPS 限流(CallerRateLimiter 是独立专项,见 mjava-com)

与 mjava-com 的关系

提供什么 谁用
本 change(request-auth + replay-guard 通用 HMAC 签名 + 防重放基础设施 所有 mjava 子模块(按需启用)
add-mjava-com 调用方注册表(宜搭权限表单)+ 多 caller 独立密钥 + 限流 mjava-com 网关

mjava-com 的 CallerAuthInterceptor 内部将直接调用本 change 提供的 UtilSignatureNonceCache,只增加"按 callerId 动态查 secret"与"按 callerId 限流"两层。

风险与缓解

风险 缓解
默认关闭 → 用户忘记开启,裸奔 README + CLAUDE.md 明示"生产客户建议开启"
签名计算客户端实现差异大 提供 Java / Python / Node.js 三份参考实现代码
时间窗过严导致客户端时钟漂移失败 默认 5 分钟宽松;文档说明 NTP 同步要求
@NoAuth 滥用 Code review 规定:豁免必须在 PR 描述里写明理由