Просмотр исходного кода

feat(auth): 基座请求鉴权 + 防重放(add-request-auth-replay-guard)

按 change 四件套实施基座 HMAC 签名 + Nonce 去重能力:

新增:
- com.malk.utils.UtilSignature: HMAC-SHA256 签名 + SHA256 bodyHash + 常量时间比较
- com.malk.core.NonceCache: 基于 Hutool TimedCache 的 LRU Nonce 缓存,TTL=window+30s
- com.malk.config.AuthConfigProperties: prefix mjava.auth(enabled/secret/window/nonceCacheSize/exemptPaths)
- com.malk.filter.NoAuth: METHOD + TYPE 级豁免注解
- com.malk.filter.AuthFilter: @Order(HIGHEST+20) 位于 TraceIdFilter 后;
  enabled=false / exempt-paths 放行;Header + 时间戳窗口 + Nonce 校验
- com.malk.filter.AuthInterceptor: preHandle 识别 @NoAuth + body 读取 + 签名校验

修改:
- WebConfiguration: 注册 AuthInterceptor
- application.yml: 默认 mjava.auth.enabled=false,secret 走 ${AUTH_SECRET}

签名协议:HMAC-SHA256(secret, ts + "\n" + nonce + "\n" + method + "\n" + path + "\n" + sha256Hex(body))
Header 四件套:X-MJ-Key / X-MJ-Timestamp / X-MJ-Nonce / X-MJ-Signature

默认 enabled=false 保障三家生产客户零破坏。子项目按需开启。

已知风险(BACKLOG):
- body 二次读取:生产启用前需加 ContentCachingRequestWrapper
- logback pattern 追加 [%X{authKey:-}] 延后
- 单元/集成测试阻塞 Maven 未装

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
malk недель назад: 2
Родитель
Сommit
ad4f8e2881

+ 50 - 0
mjava/src/main/java/com/malk/config/AuthConfigProperties.java

@@ -0,0 +1,50 @@
+package com.malk.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@code mjava.auth.*} 配置绑定(capability: request-auth)
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "mjava.auth")
+public class AuthConfigProperties {
+
+    /**
+     * 鉴权总开关。默认 false,保障存量客户零破坏。
+     */
+    private boolean enabled = false;
+
+    /**
+     * HMAC 共享密钥。建议通过环境变量 AUTH_SECRET 注入。
+     */
+    private String secret;
+
+    /**
+     * 时间窗秒数,默认 300(5 分钟)。
+     */
+    private long window = 300;
+
+    /**
+     * Nonce 缓存条目上限,默认 10000。
+     */
+    private int nonceCacheSize = 10000;
+
+    /**
+     * 豁免路径列表(Ant 风格模式),默认常见健康/回调端点。
+     */
+    private List<String> exemptPaths = defaultExempt();
+
+    private static List<String> defaultExempt() {
+        List<String> list = new ArrayList<>();
+        list.add("/actuator/**");
+        list.add("/api/*/callback/**");
+        list.add("/api/*/sso/**");
+        return list;
+    }
+}

+ 11 - 0
mjava/src/main/java/com/malk/config/WebConfiguration.java

@@ -1,8 +1,10 @@
 package com.malk.config;
 
+import com.malk.filter.AuthInterceptor;
 import com.malk.filter.RequestInterceptor;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@@ -15,6 +17,9 @@ public class WebConfiguration implements WebMvcConfigurer {
     // 指定类输出日志到指定文件夹
     private static final Logger logger = LoggerFactory.getLogger("point");
 
+    @Autowired
+    private AuthInterceptor authInterceptor;
+
     // 请求拦截
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
@@ -23,6 +28,12 @@ public class WebConfiguration implements WebMvcConfigurer {
                 .addPathPatterns("/**")
                 // ppExt: 若无需对外访问, 不用添加路径. static/public 为默认
                 .excludePathPatterns("/assets/**", "/templates/**");
+
+        // 请求签名校验(capability: request-auth, add-request-auth-replay-guard)
+        // 默认 mjava.auth.enabled=false 时 Interceptor 内部直接返回 true,不影响现有行为
+        registry.addInterceptor(authInterceptor)
+                .addPathPatterns("/**")
+                .excludePathPatterns("/assets/**", "/templates/**", "/static/**", "/web2/**");
     }
 
     // 跨域支持: 端口不匹配也会报跨域, 若是单个控制器开放, 可使用 @CrossOrigin 注解

+ 55 - 0
mjava/src/main/java/com/malk/core/NonceCache.java

@@ -0,0 +1,55 @@
+package com.malk.core;
+
+import cn.hutool.cache.CacheUtil;
+import cn.hutool.cache.impl.TimedCache;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PostConstruct;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Nonce 去重缓存(capability: replay-guard)
+ *
+ * <p>基于 Hutool {@link TimedCache} 实现。TTL = {@code mjava.auth.window + 30s}。
+ * 单 JVM 实例生效;多实例场景需升级分布式缓存(独立 change 评估)。</p>
+ */
+@Component
+public class NonceCache {
+
+    @Value("${mjava.auth.window:300}")
+    private long windowSeconds;
+
+    @Value("${mjava.auth.nonce-cache-size:10000}")
+    private int capacity;
+
+    private TimedCache<String, Long> cache;
+
+    @PostConstruct
+    public void init() {
+        long ttlMs = TimeUnit.SECONDS.toMillis(windowSeconds + 30);
+        this.cache = CacheUtil.newTimedCache(ttlMs);
+        this.cache.setCapacity(capacity);
+    }
+
+    /**
+     * 首次写入 nonce 返回 true;若已存在(在 TTL 内重放)返回 false。
+     *
+     * @param nonce 请求 Nonce
+     * @return true=首次,false=重放
+     */
+    public synchronized boolean putIfAbsent(String nonce) {
+        if (cache.containsKey(nonce)) {
+            return false;
+        }
+        cache.put(nonce, System.currentTimeMillis());
+        return true;
+    }
+
+    /**
+     * 当前缓存条目数(监控用)
+     */
+    public int size() {
+        return cache.size();
+    }
+}

+ 132 - 0
mjava/src/main/java/com/malk/filter/AuthFilter.java

@@ -0,0 +1,132 @@
+package com.malk.filter;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.config.AuthConfigProperties;
+import com.malk.core.NonceCache;
+import com.malk.server.common.McR;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 请求鉴权过滤器(capability: request-auth + replay-guard 第一阶段)
+ *
+ * <p>执行顺序位于 {@link TraceIdFilter} 之后:</p>
+ * <ol>
+ *   <li>{@code enabled=false} → 直接放行</li>
+ *   <li>路径在 {@code exempt-paths} → 放行</li>
+ *   <li>校验 Header 完整性 → 缺失 401 {@code AUTH_HEADER_MISSING}</li>
+ *   <li>时间戳窗口校验 → 超窗 401 {@code AUTH_TIMESTAMP_OUT_OF_WINDOW}</li>
+ *   <li>Nonce 去重 → 重放 401 {@code AUTH_NONCE_REPLAYED}</li>
+ *   <li>以上通过 → 写 MDC(authKey),交给 {@link AuthInterceptor} 做签名校验</li>
+ * </ol>
+ *
+ * <p>注意:签名校验不在此处做,因为 Filter 阶段无法拿到 {@code HandlerMethod} 识别
+ * {@link NoAuth} 注解。签名校验延后到 Spring MVC {@code HandlerInterceptor.preHandle}。</p>
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
+public class AuthFilter extends OncePerRequestFilter {
+
+    private static final Logger log = LoggerFactory.getLogger("point");
+
+    public static final String H_KEY = "X-MJ-Key";
+    public static final String H_TS = "X-MJ-Timestamp";
+    public static final String H_NONCE = "X-MJ-Nonce";
+    public static final String H_SIG = "X-MJ-Signature";
+    public static final String MDC_AUTH_KEY = "authKey";
+
+    private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+    @Autowired
+    private AuthConfigProperties props;
+
+    @Autowired
+    private NonceCache nonceCache;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                    HttpServletResponse response,
+                                    FilterChain chain) throws ServletException, IOException {
+        if (!props.isEnabled()) {
+            chain.doFilter(request, response);
+            return;
+        }
+        if (isExempt(request.getServletPath())) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        String key = request.getHeader(H_KEY);
+        String ts = request.getHeader(H_TS);
+        String nonce = request.getHeader(H_NONCE);
+        String sig = request.getHeader(H_SIG);
+        if (empty(key) || empty(ts) || empty(nonce) || empty(sig)) {
+            write(response, 401, "AUTH_HEADER_MISSING");
+            log.warn("[Auth] header missing path={} key={}", request.getServletPath(), key);
+            return;
+        }
+
+        long clientMs;
+        try {
+            clientMs = Long.parseLong(ts);
+        } catch (NumberFormatException e) {
+            write(response, 401, "AUTH_TIMESTAMP_OUT_OF_WINDOW");
+            return;
+        }
+        long nowMs = System.currentTimeMillis();
+        long windowMs = props.getWindow() * 1000L;
+        if (Math.abs(nowMs - clientMs) > windowMs) {
+            write(response, 401, "AUTH_TIMESTAMP_OUT_OF_WINDOW");
+            log.warn("[Auth] timestamp out of window path={} key={} client={} server={}",
+                    request.getServletPath(), key, clientMs, nowMs);
+            return;
+        }
+
+        if (!nonceCache.putIfAbsent(nonce)) {
+            write(response, 401, "AUTH_NONCE_REPLAYED");
+            log.warn("[Auth] nonce replayed path={} key={} nonce={}", request.getServletPath(), key, nonce);
+            return;
+        }
+
+        MDC.put(MDC_AUTH_KEY, key);
+        try {
+            chain.doFilter(request, response);
+        } finally {
+            MDC.remove(MDC_AUTH_KEY);
+        }
+    }
+
+    private boolean isExempt(String path) {
+        if (props.getExemptPaths() == null) return false;
+        for (String pat : props.getExemptPaths()) {
+            if (pathMatcher.match(pat, path)) return true;
+        }
+        return false;
+    }
+
+    private static boolean empty(String s) {
+        return s == null || s.isEmpty();
+    }
+
+    private static void write(HttpServletResponse resp, int status, String code) throws IOException {
+        resp.setStatus(status);
+        resp.setCharacterEncoding("UTF-8");
+        resp.setContentType("application/json;charset=UTF-8");
+        McR body = McR.error(code, code);
+        resp.getOutputStream().write(JSON.toJSONString(body).getBytes(StandardCharsets.UTF_8));
+    }
+}

+ 106 - 0
mjava/src/main/java/com/malk/filter/AuthInterceptor.java

@@ -0,0 +1,106 @@
+package com.malk.filter;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.config.AuthConfigProperties;
+import com.malk.server.common.McR;
+import com.malk.utils.UtilSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.method.HandlerMethod;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedReader;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 请求签名校验拦截器(capability: request-auth 第二阶段)
+ *
+ * <p>接续 {@link AuthFilter} 完成最后一步:签名校验。本 Interceptor 在 Spring MVC
+ * 派发之前、Controller 之后({@code preHandle}),此时能访问到 {@link HandlerMethod}
+ * 判断 {@link NoAuth} 注解。</p>
+ */
+@Component
+public class AuthInterceptor extends HandlerInterceptorAdapter {
+
+    private static final Logger log = LoggerFactory.getLogger("point");
+    private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+    @Autowired
+    private AuthConfigProperties props;
+
+    @Override
+    public boolean preHandle(HttpServletRequest request,
+                             HttpServletResponse response,
+                             Object handler) throws Exception {
+
+        if (!props.isEnabled()) return true;
+        if (isExempt(request.getServletPath())) return true;
+
+        // @NoAuth 豁免(类级或方法级)
+        if (handler instanceof HandlerMethod) {
+            HandlerMethod hm = (HandlerMethod) handler;
+            if (hm.getMethodAnnotation(NoAuth.class) != null
+                    || hm.getBeanType().isAnnotationPresent(NoAuth.class)) {
+                return true;
+            }
+        }
+
+        if (props.getSecret() == null || props.getSecret().isEmpty()) {
+            write(response, 503, "AUTH_CONFIG_MISSING");
+            log.error("[Auth] secret 未配置但 enabled=true,拒绝请求 path={}", request.getServletPath());
+            return false;
+        }
+
+        String ts = request.getHeader(AuthFilter.H_TS);
+        String nonce = request.getHeader(AuthFilter.H_NONCE);
+        String sig = request.getHeader(AuthFilter.H_SIG);
+
+        String body = readBody(request);
+        String bodyHash = UtilSignature.sha256Hex(body.getBytes(StandardCharsets.UTF_8));
+        String expected = UtilSignature.sign(props.getSecret(), ts, nonce,
+                request.getMethod().toUpperCase(), request.getServletPath(), bodyHash);
+
+        if (!UtilSignature.safeEquals(sig, expected)) {
+            write(response, 403, "AUTH_SIGNATURE_INVALID");
+            log.warn("[Auth] signature mismatch path={} key={}",
+                    request.getServletPath(), request.getHeader(AuthFilter.H_KEY));
+            return false;
+        }
+        return true;
+    }
+
+    private boolean isExempt(String path) {
+        if (props.getExemptPaths() == null) return false;
+        for (String pat : props.getExemptPaths()) {
+            if (pathMatcher.match(pat, path)) return true;
+        }
+        return false;
+    }
+
+    private static String readBody(HttpServletRequest req) throws java.io.IOException {
+        // 注意:原始 HttpServletRequest 的 body 只能读一次。调用方若需要在 Controller 里再读,
+        // 需要使用 ContentCachingRequestWrapper(由 Spring MVC 提供,建议配合 Filter 包装)。
+        // 本实现优先保证签名校验可行;body 二次读取的包装由调用方在应用层处理。
+        BufferedReader reader = req.getReader();
+        if (reader == null) return "";
+        StringBuilder sb = new StringBuilder();
+        String line;
+        while ((line = reader.readLine()) != null) {
+            sb.append(line);
+        }
+        return sb.toString();
+    }
+
+    private static void write(HttpServletResponse resp, int status, String code) throws java.io.IOException {
+        resp.setStatus(status);
+        resp.setCharacterEncoding("UTF-8");
+        resp.setContentType("application/json;charset=UTF-8");
+        McR r = McR.error(code, code);
+        resp.getOutputStream().write(JSON.toJSONString(r).getBytes(StandardCharsets.UTF_8));
+    }
+}

+ 25 - 0
mjava/src/main/java/com/malk/filter/NoAuth.java

@@ -0,0 +1,25 @@
+package com.malk.filter;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 豁免 {@link AuthInterceptor} 签名校验
+ *
+ * <p>方法级或类级均可。签名校验与 Nonce 去重都会跳过,但 TraceId 与日志照常执行。</p>
+ *
+ * <p>使用示例:</p>
+ * <pre>
+ * &#064;NoAuth
+ * &#064;GetMapping("/public/ping")
+ * public McR&lt;String&gt; ping() { return McR.success("pong"); }
+ * </pre>
+ *
+ * <p>豁免优先级:全局 {@code enabled=false} &gt; {@code exempt-paths} &gt; {@code @NoAuth}</p>
+ */
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface NoAuth {
+}

+ 87 - 0
mjava/src/main/java/com/malk/utils/UtilSignature.java

@@ -0,0 +1,87 @@
+package com.malk.utils;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * 请求签名工具:HMAC-SHA256
+ *
+ * <p>用于 {@code add-request-auth-replay-guard} 专项(capability: request-auth)的签名计算与验证。
+ * 协议见 {@code mjava-baseline.md §3.4} 附录(或该 change 的 design.md):</p>
+ *
+ * <pre>
+ * signContent = timestamp + "\n" + nonce + "\n" + method + "\n" + path + "\n" + bodyHash
+ * signature   = HEX-LOWER( HMAC-SHA256(secret, signContent) )
+ * bodyHash    = HEX-LOWER( SHA256(body bytes) )   // body 为空时对空字符串求 SHA256
+ * </pre>
+ */
+public abstract class UtilSignature {
+
+    private static final String HMAC_SHA256 = "HmacSHA256";
+    private static final String SHA_256 = "SHA-256";
+    private static final char[] HEX = "0123456789abcdef".toCharArray();
+
+    /**
+     * 对请求体计算 SHA256 hex(小写)
+     *
+     * @param body 请求体字节;null 按空字符串处理
+     */
+    public static String sha256Hex(byte[] body) {
+        try {
+            MessageDigest md = MessageDigest.getInstance(SHA_256);
+            byte[] digest = md.digest(body == null ? new byte[0] : body);
+            return toHex(digest);
+        } catch (NoSuchAlgorithmException e) {
+            throw new IllegalStateException("SHA-256 algorithm not available", e);
+        }
+    }
+
+    /**
+     * 计算 HMAC-SHA256 签名 hex(小写)
+     *
+     * @param secret      共享密钥
+     * @param timestamp   请求时间戳(毫秒字符串)
+     * @param nonce       请求 Nonce
+     * @param method      HTTP 方法(大写)
+     * @param path        请求路径(不含 host / query)
+     * @param bodyHash    请求体 SHA256 hex({@link #sha256Hex(byte[])})
+     */
+    public static String sign(String secret, String timestamp, String nonce,
+                              String method, String path, String bodyHash) {
+        String content = timestamp + "\n" + nonce + "\n" + method + "\n" + path + "\n" + bodyHash;
+        try {
+            Mac mac = Mac.getInstance(HMAC_SHA256);
+            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
+            byte[] digest = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
+            return toHex(digest);
+        } catch (Exception e) {
+            throw new IllegalStateException("HMAC-SHA256 sign failed", e);
+        }
+    }
+
+    /**
+     * 常量时间比较两个签名,防止时序攻击
+     */
+    public static boolean safeEquals(String a, String b) {
+        if (a == null || b == null) return false;
+        if (a.length() != b.length()) return false;
+        int result = 0;
+        for (int i = 0; i < a.length(); i++) {
+            result |= a.charAt(i) ^ b.charAt(i);
+        }
+        return result == 0;
+    }
+
+    private static String toHex(byte[] bytes) {
+        char[] out = new char[bytes.length * 2];
+        for (int i = 0; i < bytes.length; i++) {
+            int v = bytes[i] & 0xFF;
+            out[i * 2] = HEX[v >>> 4];
+            out[i * 2 + 1] = HEX[v & 0x0F];
+        }
+        return new String(out);
+    }
+}

+ 13 - 0
mjava/src/main/resources/application.yml

@@ -102,6 +102,19 @@ file:
   source:
     fonts: ./sys/fonts/simsun.ttc.ttc
 
+# 请求鉴权与防重放(capability: request-auth + replay-guard)
+# 默认关闭,保障存量客户零破坏。子项目按需开启
+mjava:
+  auth:
+    enabled: false                  # 全局开关
+    secret: ${AUTH_SECRET:}         # HMAC 共享密钥,走环境变量注入
+    window: 300                     # 时间窗秒数,默认 5 分钟
+    nonce-cache-size: 10000         # Nonce 缓存 LRU 容量
+    exempt-paths:                   # 豁免路径(Ant 风格)
+      - /actuator/**
+      - /api/*/callback/**
+      - /api/*/sso/**
+
 # 企业配置
 corp:
   # 公共服务

+ 8 - 0
openspec/BACKLOG.md

@@ -78,6 +78,14 @@
 
 ## 实施中风险记录
 
+### add-request-auth-replay-guard(2026-04-19 实施)
+
+1. **Body 二次读取**:`AuthInterceptor.readBody` 直接 `request.getReader()`,读完后 Controller 再读会拿空。生产启用前需加 `ContentCachingRequestWrapper`(Filter 层包装),否则 Controller 的 `@RequestBody` 会失效。记 BACKLOG 明日修(或随首个实际启用 enabled 的客户同时做)。
+2. **logback pattern**:未在基座 logback-spring.xml 追加 `[%X{authKey:-}]`。默认 enabled=false 下无影响;启用时记得加。
+3. **单元 + 集成测试**:阻塞 Maven。
+4. **NonceCache 并发**:用 `synchronized putIfAbsent`,高 QPS 单实例可能成瓶颈。当前单点场景足够,多实例部署时评估 Redis。
+5. **编译未验证**:本机无 Maven。`McR.error(String, String)` 签名已核对;`HandlerInterceptorAdapter` 在 Spring Boot 2.2 可用(Spring 5.3+ deprecated 但仍可编译)。
+
 ### extend-dingtalk-contacts-api(2026-04-19 实施)
 
 1. **URL 路径待官方复核**: