|
|
@@ -0,0 +1,282 @@
|
|
|
+package com.fs.core.filter;
|
|
|
+
|
|
|
+import cn.hutool.core.util.ObjectUtil;
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
+import com.fs.common.core.domain.R;
|
|
|
+import com.fs.common.core.redis.RedisCache;
|
|
|
+import com.fs.core.wrapper.CachedBodyHttpServletRequestWrapper;
|
|
|
+import com.fs.core.wrapper.CachedBodyHttpServletResponseWrapper;
|
|
|
+import com.fs.core.wrapper.DecryptedParameterRequestWrapper;
|
|
|
+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.util.CollectionUtils;
|
|
|
+import org.springframework.util.StreamUtils;
|
|
|
+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;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
+
|
|
|
+@Component
|
|
|
+@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
|
|
+public class TransmissionWrapperFilter extends OncePerRequestFilter {
|
|
|
+
|
|
|
+ private static final long TIMESTAMP_THRESHOLD_MS = 10 * 1000;
|
|
|
+
|
|
|
+ private static final String NONCE_CACHE_PREFIX = "api:nonce:";
|
|
|
+
|
|
|
+ private static final String DEVICE_NONCE_CACHE_PREFIX = "api:device:nonce:";
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private RedisCache redisCache;
|
|
|
+
|
|
|
+ @Autowired
|
|
|
+ private TransmissionWrapperProperties wrapperProperties;
|
|
|
+
|
|
|
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ protected boolean shouldNotFilter(HttpServletRequest request) {
|
|
|
+ String uri = request.getRequestURI();
|
|
|
+ List<String> excludePatterns = wrapperProperties.getExcludePatterns();
|
|
|
+ if (!CollectionUtils.isEmpty(excludePatterns)) {
|
|
|
+ for (String pattern : excludePatterns) {
|
|
|
+ if (pathMatcher.match(pattern, uri)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ String contentType = request.getContentType();
|
|
|
+ return contentType != null && contentType.toLowerCase().contains("multipart/form-data");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
|
|
+ throws ServletException, IOException {
|
|
|
+ //版本(旧版不会传递)
|
|
|
+ String version = request.getHeader("AppVersion");
|
|
|
+ if (ObjectUtil.isEmpty(version)) {
|
|
|
+ filterChain.doFilter(request, response);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String signHeader = request.getHeader("X-Api-Sign");
|
|
|
+ if (signHeader == null || signHeader.isEmpty()) {
|
|
|
+ writeError(response, 401, "非法请求");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ String timestampHeader = request.getHeader("X-Api-Timestamp");
|
|
|
+ if (timestampHeader == null || timestampHeader.isEmpty()) {
|
|
|
+ writeError(response, 401, "非法请求");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ long headerTimestamp;
|
|
|
+ try {
|
|
|
+ headerTimestamp = Long.parseLong(timestampHeader);
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ writeError(response, 401, "非法请求");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+// long now = System.currentTimeMillis();
|
|
|
+// if (Math.abs(now - headerTimestamp) > TIMESTAMP_THRESHOLD_MS) {
|
|
|
+// writeError(response, 401, "请求已过期");
|
|
|
+// return;
|
|
|
+// }
|
|
|
+
|
|
|
+ String nonce = request.getHeader("X-Api-Nonce");
|
|
|
+ if (nonce != null && !nonce.isEmpty()) {
|
|
|
+ String nonceKey = NONCE_CACHE_PREFIX + nonce;
|
|
|
+ if (redisCache.getCacheObject(nonceKey) != null) {
|
|
|
+ writeError(response, 401, "重复请求");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ redisCache.setCacheObject(nonceKey, "1", 15, TimeUnit.SECONDS);
|
|
|
+ }
|
|
|
+
|
|
|
+ String deviceId = request.getHeader("X-Api-DeviceId");
|
|
|
+ if (deviceId != null && !deviceId.isEmpty() && nonce != null && !nonce.isEmpty()) {
|
|
|
+ String deviceNonceKey = DEVICE_NONCE_CACHE_PREFIX + deviceId + ":" + nonce;
|
|
|
+ if (redisCache.getCacheObject(deviceNonceKey) != null) {
|
|
|
+ writeError(response, 401, "设备重复请求");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ redisCache.setCacheObject(deviceNonceKey, "1", 15, TimeUnit.SECONDS);
|
|
|
+ }
|
|
|
+
|
|
|
+ String decryptedSign = RsaCryptoUtil.rsaDecryptSign(signHeader);
|
|
|
+ long signTimestamp = 0;
|
|
|
+ String aesKey = null;
|
|
|
+ String[] pairs = decryptedSign.split("&");
|
|
|
+ for (String pair : pairs) {
|
|
|
+ String[] kv = pair.split("=", 2);
|
|
|
+ if (kv.length == 2) {
|
|
|
+ if ("timestamp".equals(kv[0])) {
|
|
|
+ signTimestamp = Long.parseLong(kv[1]);
|
|
|
+ } else if ("aesKey".equals(kv[0])) {
|
|
|
+ aesKey = kv[1];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (aesKey == null) {
|
|
|
+ writeError(response, 401, "签名格式错误");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (signTimestamp != 0 && signTimestamp != headerTimestamp) {
|
|
|
+ writeError(response, 401, "时间戳不一致");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ System.out.println(request.getRequestURI());
|
|
|
+ if (request.getRequestURI().contains("/app/integral/withdrawal")) {
|
|
|
+ System.out.println(request.getRequestURI());
|
|
|
+ }
|
|
|
+ HttpServletRequest requestToUse = request;
|
|
|
+ String method = request.getMethod();
|
|
|
+ if ("POST".equalsIgnoreCase(method)
|
|
|
+ || "PUT".equalsIgnoreCase(method)
|
|
|
+ || "PATCH".equalsIgnoreCase(method)
|
|
|
+ || "DELETE".equalsIgnoreCase(method)) {
|
|
|
+ byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
|
|
|
+ if (bodyBytes.length > 0) {
|
|
|
+ String body = new String(bodyBytes, StandardCharsets.UTF_8).trim();
|
|
|
+ // 支持:纯 Base64 密文,或 {"encryptedData":"..."} / {"data":"..."}
|
|
|
+ String cipherText = extractCipherText(body);
|
|
|
+ try {
|
|
|
+ String plainJson = RsaCryptoUtil.aesDecrypt(cipherText, aesKey);
|
|
|
+ requestToUse = new CachedBodyHttpServletRequestWrapper(
|
|
|
+ request, plainJson.getBytes(StandardCharsets.UTF_8));
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ writeError(response, 401, "请求体解密失败");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)) {
|
|
|
+ try {
|
|
|
+ requestToUse = wrapGetWithDecryptedParams(request, aesKey);
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ writeError(response, 401, "请求参数解密失败");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ CachedBodyHttpServletResponseWrapper responseWrapper = new CachedBodyHttpServletResponseWrapper(response);
|
|
|
+ filterChain.doFilter(requestToUse, responseWrapper);
|
|
|
+
|
|
|
+ byte[] respBytes = responseWrapper.getCachedBody();
|
|
|
+ if (respBytes.length == 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String plainResp = new String(respBytes, StandardCharsets.UTF_8);
|
|
|
+ String responseAesKey = RsaCryptoUtil.generateAesKey();
|
|
|
+ String encryptedData = RsaCryptoUtil.aesEncrypt(plainResp, responseAesKey);
|
|
|
+ String encryptedKey = RsaCryptoUtil.rsaEncryptAesKey(responseAesKey);
|
|
|
+ JSONObject out = new JSONObject();
|
|
|
+ out.put("encryptedKey", encryptedKey);
|
|
|
+ out.put("encryptedData", encryptedData);
|
|
|
+ if (request.getRequestURI().startsWith("/h5/userAgreementNew")) {
|
|
|
+ System.out.println("key数据:"+encryptedKey);
|
|
|
+ System.out.println("Data数据:"+encryptedData);
|
|
|
+ }
|
|
|
+ byte[] outBytes = out.toJSONString().getBytes(StandardCharsets.UTF_8);
|
|
|
+ response.setContentType("application/json;charset=UTF-8");
|
|
|
+ response.setContentLength(outBytes.length);
|
|
|
+ response.getOutputStream().write(outBytes);
|
|
|
+ response.getOutputStream().flush();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * GET:从 query 的 data/encryptedData,或 request body 密文中解密出 JSON,并注入为 request 参数。
|
|
|
+ * 若无密文载体则原样返回(兼容明文 query)。
|
|
|
+ */
|
|
|
+ private HttpServletRequest wrapGetWithDecryptedParams(HttpServletRequest request, String aesKey) throws IOException {
|
|
|
+ String cipherText = request.getParameter("encryptedData");
|
|
|
+ if (cipherText == null || cipherText.isEmpty()) {
|
|
|
+ cipherText = request.getParameter("data");
|
|
|
+ }
|
|
|
+
|
|
|
+ byte[] bodyBytes = StreamUtils.copyToByteArray(request.getInputStream());
|
|
|
+ if ((cipherText == null || cipherText.isEmpty()) && bodyBytes.length > 0) {
|
|
|
+ String body = new String(bodyBytes, StandardCharsets.UTF_8).trim();
|
|
|
+ if (!body.isEmpty()) {
|
|
|
+ cipherText = extractCipherText(body);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (cipherText == null || cipherText.isEmpty()) {
|
|
|
+ // 未加密:若已读空 body,无需特殊处理
|
|
|
+ if (bodyBytes.length > 0) {
|
|
|
+ return new CachedBodyHttpServletRequestWrapper(request, bodyBytes, request.getContentType());
|
|
|
+ }
|
|
|
+ return request;
|
|
|
+ }
|
|
|
+
|
|
|
+ String plainJson = RsaCryptoUtil.aesDecrypt(cipherText.trim(), aesKey);
|
|
|
+ Map<String, String> params = jsonToParamMap(plainJson);
|
|
|
+ HttpServletRequest paramWrapped = new DecryptedParameterRequestWrapper(request, params);
|
|
|
+ // body 已被消费时用空/明文 JSON 补回,避免下游再读流异常;GET 主要靠参数绑定
|
|
|
+ return new CachedBodyHttpServletRequestWrapper(
|
|
|
+ paramWrapped, plainJson.getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+
|
|
|
+ private Map<String, String> jsonToParamMap(String plainJson) {
|
|
|
+ Map<String, String> result = new LinkedHashMap<>();
|
|
|
+ if (plainJson == null || plainJson.isEmpty()) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ JSONObject json = JSON.parseObject(plainJson);
|
|
|
+ if (json == null) {
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ for (String key : json.keySet()) {
|
|
|
+ Object value = json.get(key);
|
|
|
+ if (value == null || value instanceof JSONObject || value instanceof com.alibaba.fastjson.JSONArray) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ result.put(key, String.valueOf(value));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 请求体可为纯 Base64,或 JSON 包装中的 encryptedData / data 字段。
|
|
|
+ */
|
|
|
+ private String extractCipherText(String body) {
|
|
|
+ if (body.startsWith("{")) {
|
|
|
+ JSONObject json = JSON.parseObject(body);
|
|
|
+ String cipher = json.getString("encryptedData");
|
|
|
+ if (cipher == null || cipher.isEmpty()) {
|
|
|
+ cipher = json.getString("data");
|
|
|
+ }
|
|
|
+ if (cipher == null || cipher.isEmpty()) {
|
|
|
+ throw new IllegalArgumentException("密文字段缺失");
|
|
|
+ }
|
|
|
+ return cipher.trim();
|
|
|
+ }
|
|
|
+ // 去掉可能的首尾引号
|
|
|
+ if (body.length() >= 2 && body.startsWith("\"") && body.endsWith("\"")) {
|
|
|
+ return body.substring(1, body.length() - 1);
|
|
|
+ }
|
|
|
+ return body;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void writeError(HttpServletResponse response, int status, String message) throws IOException {
|
|
|
+ response.setStatus(status);
|
|
|
+ response.setContentType("application/json;charset=UTF-8");
|
|
|
+ response.getWriter().write(JSON.toJSONString(R.error(status, message)));
|
|
|
+ }
|
|
|
+}
|