alphaH 5 dagar sedan
förälder
incheckning
e870777f9d

+ 80 - 24
java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java

@@ -11,8 +11,11 @@ import com.payment.platform.module.payment.apikey.entity.TenantApiLogEntity;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
 import com.payment.platform.module.payment.apikey.mapper.TenantApiLogMapper;
 import jakarta.servlet.FilterChain;
+import jakarta.servlet.ReadListener;
 import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletInputStream;
 import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
@@ -23,9 +26,10 @@ import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
 import org.springframework.stereotype.Component;
 import org.springframework.web.filter.OncePerRequestFilter;
-import org.springframework.web.util.ContentCachingRequestWrapper;
-
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.io.InputStreamReader;
 import java.nio.charset.StandardCharsets;
 import java.time.OffsetDateTime;
 import java.util.List;
@@ -123,7 +127,7 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
         long startTime = System.currentTimeMillis();
 
         // 包装 request 以便可以多次读取 body (Python L87-90 中读了一次 JSON body)
-        ContentCachingRequestWrapper wrappedRequest = wrapRequest(request);
+        CachedBodyRequestWrapper wrappedRequest = wrapRequest(request);
 
         // ---- 1. 提取 Authorization header (Python L37-41) ----
         String apiKey = extractApiKey(wrappedRequest);
@@ -261,23 +265,8 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
      * 读取请求体 JSON — 对应 Python L87-90
      *   request_data = await request.json()
      */
-    private Map<String, Object> readRequestBody(ContentCachingRequestWrapper request) {
-        byte[] content = request.getContentAsByteArray();
-        if (content.length == 0) {
-            // ContentCachingRequestWrapper 惰性缓存: getContentAsByteArray() 只在 InputStream
-            // 被消费后才填充。认证阶段无人读过 body (Controller 的 @RequestBody 在 filter 之后),
-            // 必须主动消费一次, 否则 requestData 恒为空 Map → 期望签名恒为 HMAC(secret, ""),
-            // 任何带真实请求体的签名必然验签失败 (调用方"空串签名可以通过"的根因)。
-            // 消费后 Controller 的 @RequestBody 仍可正常读取 (wrapper 缓存 body 支持多次读)。
-            try {
-                jakarta.servlet.ServletInputStream in = request.getInputStream();
-                byte[] buf = new byte[4096];
-                while (in.read(buf) != -1) { /* 消费以填充缓存 */ }
-            } catch (IOException e) {
-                log.debug("读取请求体失败: {}", e.getMessage());
-            }
-            content = request.getContentAsByteArray();
-        }
+    private Map<String, Object> readRequestBody(CachedBodyRequestWrapper request) {
+        byte[] content = request.getCachedBody();
         if (content.length == 0) {
             return Map.of();
         }
@@ -368,10 +357,77 @@ public class TenantApiKeyAuthFilter extends OncePerRequestFilter {
 
     // ========================= 工具 =========================
 
-    private ContentCachingRequestWrapper wrapRequest(HttpServletRequest request) {
-        if (request instanceof ContentCachingRequestWrapper) {
-            return (ContentCachingRequestWrapper) request;
+    private CachedBodyRequestWrapper wrapRequest(HttpServletRequest request) {
+        if (request instanceof CachedBodyRequestWrapper) {
+            return (CachedBodyRequestWrapper) request;
+        }
+        try {
+            return new CachedBodyRequestWrapper(request);
+        } catch (IOException e) {
+            // 读取 body 失败(连接中断等): 记录日志, 返回无 body 的包装(验签会失败, 由后续逻辑处理)
+            log.warn("缓存请求体失败: {}", e.getMessage());
+            return new CachedBodyRequestWrapper(request, new byte[0]);
+        }
+    }
+
+    // ========================= 请求体缓存 wrapper (支持多次读取) =========================
+
+    /**
+     * 构造时一次性读入请求体, getInputStream()/getReader() 基于缓存的 byte[] 返回新流,
+     * 因此 filter 验签消费后, Controller 的 @RequestBody 仍可正常读取。
+     *
+     * 不能使用 ContentCachingRequestWrapper: 其 getInputStream() 只返回同一个流,
+     * 被消费后即 EOF, Controller 再读会得到空 body → HttpMessageNotReadableException
+     * "Required request body is missing"。
+     */
+    static class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
+        private final byte[] body;
+
+        CachedBodyRequestWrapper(HttpServletRequest request) throws IOException {
+            super(request);
+            try (ServletInputStream is = request.getInputStream()) {
+                this.body = is.readAllBytes();
+            }
+        }
+
+        CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) {
+            super(request);
+            this.body = body;
+        }
+
+        byte[] getCachedBody() {
+            return body;
+        }
+
+        @Override
+        public ServletInputStream getInputStream() {
+            ByteArrayInputStream bais = new ByteArrayInputStream(body);
+            return new ServletInputStream() {
+                @Override
+                public int read() {
+                    return bais.read();
+                }
+
+                @Override
+                public boolean isFinished() {
+                    return bais.available() == 0;
+                }
+
+                @Override
+                public boolean isReady() {
+                    return true;
+                }
+
+                @Override
+                public void setReadListener(ReadListener listener) {
+                    // 非异步读取, 无需回调
+                }
+            };
+        }
+
+        @Override
+        public BufferedReader getReader() {
+            return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
         }
-        return new ContentCachingRequestWrapper(request);
     }
 }

+ 39 - 0
java/src/test/java/com/payment/platform/core/security/TenantApiKeyAuthFilterTest.java

@@ -0,0 +1,39 @@
+package com.payment.platform.core.security;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 回归测试: CachedBodyRequestWrapper 必须支持多次读取请求体。
+ * 认证 filter 验签时要读一次 body, Controller 的 @RequestBody 还要再读一次,
+ * 若 wrapper 只支持读一遍, Controller 会收到空 body →
+ * HttpMessageNotReadableException "Required request body is missing"。
+ */
+class TenantApiKeyAuthFilterTest {
+
+    @Test
+    void cachedBodyCanBeReadByFilterThenByController() throws IOException {
+        String json = "{\"amount\":\"0.10\",\"remark\":\"完整签名复测\"}";
+        MockHttpServletRequest req = new MockHttpServletRequest("POST",
+                "/api/v1/payment/openapi/account/transfer");
+        req.setContentType("application/json;charset=UTF-8");
+        req.setContent(json.getBytes(StandardCharsets.UTF_8));
+
+        TenantApiKeyAuthFilter.CachedBodyRequestWrapper wrapped =
+                new TenantApiKeyAuthFilter.CachedBodyRequestWrapper(req);
+
+        // 第一次读: 模拟 filter 验签消费 body
+        assertEquals(json, new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+
+        // 第二次读: 模拟 Controller 的 @RequestBody (Jackson 通过 getInputStream 读)
+        assertEquals(json, new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+
+        // getReader 路径也应可读 (部分 MVC 配置走 reader)
+        assertEquals(json, new String(wrapped.getReader().readLine()));
+    }
+}