## 签名协议(客户端必须遵守) 请求头固定格式: ``` 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)} ``` 其中: - `method`:`GET` / `POST` / ...(大写) - `path`:不含 host 与 query 的路径(如 `/api/mcli/personnel/sync`) - `bodyHash`:`SHA256(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 ``` **实现难点**:`AuthFilter` 是 `javax.servlet.Filter`,在 Spring MVC 分发前执行,无法直接拿到 `HandlerMethod` 检查 `@NoAuth`。两种解决方案: - **方案 A**(推荐):`AuthFilter` 只做 Header 齐全性与时间戳/Nonce 校验;签名校验延后到 `HandlerInterceptor.preHandle`(那时能拿到 HandlerMethod,可读 `@NoAuth`) - **方案 B**:`AuthFilter` 一次到位,`@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: true` 但 `secret` 为空) | 出于安全考虑,错误信息**不暴露**具体环节("Header 缺" 不告诉缺哪个、"签名错" 不告诉期望值),避免给攻击者定位线索。日志侧记录详情。 ## 配置项详细 ```yaml 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 提供的 `UtilSignature` 与 `NonceCache`**,只增加"按 callerId 动态查 secret"与"按 callerId 限流"两层。 ## 风险与缓解 | 风险 | 缓解 | |------|------| | 默认关闭 → 用户忘记开启,裸奔 | README + CLAUDE.md 明示"生产客户建议开启" | | 签名计算客户端实现差异大 | 提供 Java / Python / Node.js 三份参考实现代码 | | 时间窗过严导致客户端时钟漂移失败 | 默认 5 分钟宽松;文档说明 NTP 同步要求 | | `@NoAuth` 滥用 | Code review 规定:豁免必须在 PR 描述里写明理由 |