Browse Source

Merge branch 'master-jdk17' of https://github.com/YunaiV/ruoyi-vue-pro into master-jdk17

YunaiV 10 months ago
parent
commit
1e391d626e

+ 8 - 1
yudao-framework/yudao-spring-boot-starter-protection/pom.xml

@@ -12,7 +12,7 @@
     <packaging>jar</packaging>
 
     <name>${project.artifactId}</name>
-    <description>服务保证,提供分布式锁、幂等、限流、熔断等等功能</description>
+    <description>服务保证,提供分布式锁、幂等、限流、熔断、API 签名等等功能</description>
     <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
 
     <dependencies>
@@ -35,6 +35,13 @@
             <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
             <optional>true</optional>
         </dependency>
+
+        <!-- Test 测试相关 -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>

+ 27 - 0
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/config/YudaoSignatureAutoConfiguration.java

@@ -0,0 +1,27 @@
+package cn.iocoder.yudao.framework.signature.config;
+
+import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
+import cn.iocoder.yudao.framework.signature.core.aop.SignatureAspect;
+import cn.iocoder.yudao.framework.signature.core.redis.SignatureRedisDAO;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * @author Zhougang
+ */
+@AutoConfiguration(after = YudaoRedisAutoConfiguration.class)
+public class YudaoSignatureAutoConfiguration {
+
+    @Bean
+    public SignatureAspect signatureAspect(SignatureRedisDAO signatureRedisDAO) {
+        return new SignatureAspect(signatureRedisDAO);
+    }
+
+    @Bean
+    @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
+    public SignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
+        return new SignatureRedisDAO(stringRedisTemplate);
+    }
+
+}

+ 59 - 0
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/ApiSignature.java

@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.framework.signature.core.annotation;
+
+import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
+
+import java.lang.annotation.*;
+import java.util.concurrent.TimeUnit;
+
+
+/**
+ * 签名注解
+ *
+ * @author Zhougang
+ */
+@Inherited
+@Documented
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ApiSignature {
+
+    /**
+     * 同一个请求多长时间内有效 默认 60 秒
+     */
+    int timeout() default 60;
+
+    /**
+     * 时间单位,默认为 SECONDS 秒
+     */
+    TimeUnit timeUnit() default TimeUnit.SECONDS;
+
+    // ========================== 签名参数 ==========================
+
+    /**
+     * 提示信息,签名失败的提示
+     *
+     * @see GlobalErrorCodeConstants#BAD_REQUEST
+     */
+    String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
+
+    /**
+     * 签名字段:appId 应用ID
+     */
+    String appId() default "appId";
+
+    /**
+     * 签名字段:timestamp 时间戳
+     */
+    String timestamp() default "timestamp";
+
+    /**
+     * 签名字段:nonce 随机数,10 位以上
+     */
+    String nonce() default "nonce";
+
+    /**
+     * sign 客户端签名
+     */
+    String sign() default "sign";
+
+}

+ 155 - 0
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/aop/SignatureAspect.java

@@ -0,0 +1,155 @@
+package cn.iocoder.yudao.framework.signature.core.aop;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.SignUtil;
+import cn.iocoder.yudao.framework.common.exception.ServiceException;
+import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
+import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
+import cn.iocoder.yudao.framework.signature.core.annotation.ApiSignature;
+import cn.iocoder.yudao.framework.signature.core.redis.SignatureRedisDAO;
+import cn.iocoder.yudao.framework.web.core.filter.CacheRequestBodyWrapper;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Objects;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
+ *
+ * @author Zhougang
+ */
+@Aspect
+@Slf4j
+@AllArgsConstructor
+public class SignatureAspect {
+
+    private final SignatureRedisDAO signatureRedisDAO;
+
+    @Before("@annotation(signature)")
+    public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
+        if (!verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
+            log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
+                    joinPoint.getArgs());
+            String message = StrUtil.blankToDefault(signature.message(),
+                    GlobalErrorCodeConstants.BAD_REQUEST.getMsg());
+            throw new ServiceException(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), message);
+        }
+    }
+
+    private boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
+        if (!verifyHeaders(signature, request)) {
+            return false;
+        }
+        // 校验 appId 是否能获取到对应的 appSecret
+        String appId = request.getHeader(signature.appId());
+        String appSecret = signatureRedisDAO.getAppSecret(appId);
+        Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
+        // 请求头
+        SortedMap<String, String> headersMap = getRequestHeaders(signature, request);
+        // 请求参数
+        String requestParams = getRequestParams(request);
+        // 请求体
+        String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : "";
+        // 生成服务端签名
+        String serverSignature = SignUtil.signParamsSha256(headersMap, requestParams + requestBody + appSecret);
+        // 客户端签名
+        String clientSignature = request.getHeader(signature.sign());
+        if (!StrUtil.equals(clientSignature, serverSignature)) {
+            return false;
+        }
+        String nonce = headersMap.get(signature.nonce());
+        // 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
+        signatureRedisDAO.setNonce(nonce, signature.timeout() * 2L, signature.timeUnit());
+        return true;
+    }
+
+    /**
+     * 校验请求头加签参数
+     * 1.appId 是否为空
+     * 2.timestamp 是否为空,请求是否已经超时,默认 10 分钟
+     * 3.nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
+     * 4.sign 是否为空
+     *
+     * @param signature signature
+     * @param request   request
+     */
+    private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
+        String appId = request.getHeader(signature.appId());
+        if (StrUtil.isBlank(appId)) {
+            return false;
+        }
+        String timestamp = request.getHeader(signature.timestamp());
+        if (StrUtil.isBlank(timestamp)) {
+            return false;
+        }
+        String nonce = request.getHeader(signature.nonce());
+        if (StrUtil.isBlank(nonce) || StrUtil.length(nonce) < 10) {
+            return false;
+        }
+        String sign = request.getHeader(signature.sign());
+        if (StrUtil.isBlank(sign)) {
+            return false;
+        }
+        // 其他合法性校验
+        long expireTime = signature.timeUnit().toMillis(signature.timeout());
+        long requestTimestamp = Long.parseLong(timestamp);
+        // 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
+        long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
+        if (timestampDisparity > expireTime) {
+            return false;
+        }
+        String cacheNonce = signatureRedisDAO.getNonce(nonce);
+        return StrUtil.isBlank(cacheNonce);
+    }
+
+    /**
+     * 获取请求头加签参数
+     *
+     * @param request request
+     * @return signature params
+     */
+    private SortedMap<String, String> getRequestHeaders(ApiSignature signature, HttpServletRequest request) {
+        SortedMap<String, String> sortedMap = new TreeMap<>();
+        sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
+        sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
+        sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
+        return sortedMap;
+    }
+
+    /**
+     * 获取 URL 参数
+     *
+     * @param request request
+     * @return queryParams
+     */
+    private String getRequestParams(HttpServletRequest request) {
+        if (CollUtil.isEmpty(request.getParameterMap())) {
+            return "";
+        }
+        Map<String, String[]> requestParams = request.getParameterMap();
+        // 获取 URL 请求参数
+        SortedMap<String, String> sortParamsMap = new TreeMap<>();
+        for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
+            sortParamsMap.put(entry.getKey(), entry.getValue()[0]);
+        }
+        // 按 key 排序
+        StringBuilder queryString = new StringBuilder();
+        for (Map.Entry<String, String> entry : sortParamsMap.entrySet()) {
+            queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue());
+        }
+        return queryString.substring(1);
+    }
+
+}
+

+ 55 - 0
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/redis/SignatureRedisDAO.java

@@ -0,0 +1,55 @@
+package cn.iocoder.yudao.framework.signature.core.redis;
+
+import lombok.AllArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * API 签名 Redis DAO
+ *
+ * @author Zhougang
+ */
+@AllArgsConstructor
+public class SignatureRedisDAO {
+
+    private final StringRedisTemplate stringRedisTemplate;
+
+    /**
+     * 验签随机数
+     * <p>
+     * KEY 格式:signature_nonce:%s // 参数为 随机数
+     * VALUE 格式:String
+     * 过期时间:不固定
+     */
+    private static final String SIGNATURE_NONCE = "signature_nonce:%s";
+
+    /**
+     * 签名密钥
+     * <p>
+     * KEY 格式:signature_appid:%s // 参数为 appid
+     * VALUE 格式:String
+     * 过期时间:预加载到 redis 永不过期
+     */
+    private static final String SIGNATURE_APPID = "signature_appid:%s";
+
+    public String getAppSecret(String appId) {
+        return stringRedisTemplate.opsForValue().get(formatAppIdKey(appId));
+    }
+
+    public String getNonce(String nonce) {
+        return stringRedisTemplate.opsForValue().get(formatNonceKey(nonce));
+    }
+
+    public void setNonce(String nonce, long time, TimeUnit timeUnit) {
+        stringRedisTemplate.opsForValue().set(formatNonceKey(nonce), nonce, time, timeUnit);
+    }
+
+    private static String formatAppIdKey(String key) {
+        return String.format(SIGNATURE_APPID, key);
+    }
+
+    private static String formatNonceKey(String key) {
+        return String.format(SIGNATURE_NONCE, key);
+    }
+}

+ 2 - 1
yudao-framework/yudao-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

@@ -1,3 +1,4 @@
 cn.iocoder.yudao.framework.idempotent.config.YudaoIdempotentConfiguration
 cn.iocoder.yudao.framework.lock4j.config.YudaoLock4jConfiguration
-cn.iocoder.yudao.framework.ratelimiter.config.YudaoRateLimiterConfiguration
+cn.iocoder.yudao.framework.ratelimiter.config.YudaoRateLimiterConfiguration
+cn.iocoder.yudao.framework.signature.config.YudaoSignatureAutoConfiguration

+ 136 - 0
yudao-framework/yudao-spring-boot-starter-protection/src/test/java/cn/iocoder/yudao/framework/signature/core/SignatureTest.java

@@ -0,0 +1,136 @@
+package cn.iocoder.yudao.framework.signature.core;
+
+import cn.hutool.core.lang.Snowflake;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.http.HttpUtil;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * {@link SignatureTest} 的单元测试
+ */
+public class SignatureTest {
+
+    @Test
+    public void testSignatureGet() {
+        String appId = "xxxxxx";
+        Snowflake snowflake = new Snowflake();
+
+        // 验签请求头前端需传入字段
+        SortedMap<String, String> headersMap = new TreeMap<>();
+        headersMap.put("appId", appId);
+        headersMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+        headersMap.put("nonce", String.valueOf(snowflake.nextId()));
+
+        // 客户端加签内容
+        StringBuilder clientSignatureContent = new StringBuilder();
+        // 请求头
+        for (Map.Entry<String, String> entry : headersMap.entrySet()) {
+            clientSignatureContent.append(entry.getKey()).append(entry.getValue());
+        }
+        // 请求 url
+        clientSignatureContent.append("/admin-api/infra/demo01-contact/get");
+        // 请求参数
+        SortedMap<String, String> paramsMap = new TreeMap<>();
+        paramsMap.put("id", "100");
+        paramsMap.put("name", "张三");
+        StringBuilder queryString = new StringBuilder();
+        for (Map.Entry<String, String> entry : paramsMap.entrySet()) {
+            queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue());
+        }
+        clientSignatureContent.append(queryString.substring(1));
+        // 密钥
+        clientSignatureContent.append("d3cbeed9baf4e68673a1f69a2445359a20022b7c28ea2933dd9db9f3a29f902b");
+        System.out.println("加签内容:" + clientSignatureContent);
+        // 加签
+        headersMap.put("sign", DigestUtil.sha256Hex(clientSignatureContent.toString()));
+        headersMap.put("Authorization", "Bearer xxx");
+
+        HttpRequest get = HttpUtil.createGet("http://localhost:48080/admin-api/infra/demo01-contact/get?id=100&name=张三");
+        get.addHeaders(headersMap);
+        System.out.println("执行结果==" + get.execute());
+    }
+
+    @Test
+    public void testSignaturePost() {
+        String appId = "xxxxxx";
+        Snowflake snowflake = new Snowflake();
+
+        // 验签请求头前端需传入字段
+        SortedMap<String, String> headersMap = new TreeMap<>();
+        headersMap.put("appId", appId);
+        headersMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
+        headersMap.put("nonce", String.valueOf(snowflake.nextId()));
+
+        // 客户端加签内容
+        StringBuilder clientSignatureContent = new StringBuilder();
+        // 请求头
+        for (Map.Entry<String, String> entry : headersMap.entrySet()) {
+            clientSignatureContent.append(entry.getKey()).append(entry.getValue());
+        }
+        // 请求 url
+        clientSignatureContent.append("/admin-api/infra/demo01-contact/create");
+        // 请求体
+        String body = "{\n" +
+                "    \"password\": \"1\",\n" +
+                "    \"date\": \"2024-04-24 16:28:00\",\n" +
+                "    \"user\": {\n" +
+                "        \"area\": \"浦东新区\",\n" +
+                "        \"1\": \"xx\",\n" +
+                "        \"2\": \"xx\",\n" +
+                "        \"province\": \"上海市\",\n" +
+                "        \"data\": {\n" +
+                "            \"99\": \"xx\",\n" +
+                "            \"1\": \"xx\",\n" +
+                "            \"100\": \"xx\",\n" +
+                "            \"2\": \"xx\",\n" +
+                "            \"3\": \"xx\",\n" +
+                "            \"array\": [\n" +
+                "                {\n" +
+                "                    \"3\": \"aa\",\n" +
+                "                    \"4\": \"aa\",\n" +
+                "                    \"2\": \"aa\",\n" +
+                "                    \"1\": \"aa\"\n" +
+                "                },\n" +
+                "                {\n" +
+                "                    \"99\": \"aa\",\n" +
+                "                    \"100\": \"aa\",\n" +
+                "                    \"88\": \"aa\",\n" +
+                "                    \"120\": \"aa\"\n" +
+                "                }\n" +
+                "            ]\n" +
+                "        },\n" +
+                "        \"sex\": \"男\",\n" +
+                "        \"name\": \"张三\",\n" +
+                "        \"array\": [\n" +
+                "            \"1\",\n" +
+                "            \"3\",\n" +
+                "            \"5\",\n" +
+                "            \"2\"\n" +
+                "        ]\n" +
+                "    },\n" +
+                "    \"username\": \"xiaoming\"\n" +
+                "}";
+        clientSignatureContent.append(body);
+
+        // 密钥
+        clientSignatureContent.append("d3cbeed9baf4e68673a1f69a2445359a20022b7c28ea2933dd9db9f3a29f902b");
+        System.out.println("加签内容:" + clientSignatureContent);
+        // 加签
+        headersMap.put("sign", DigestUtil.sha256Hex(clientSignatureContent.toString()));
+        headersMap.put("Authorization", "Bearer xxx");
+
+        HttpRequest post = HttpUtil.createPost("http://localhost:48080/admin-api/infra/demo01-contact/create");
+        post.addHeaders(headersMap);
+        post.body(body);
+        try (HttpResponse execute = post.execute()) {
+            System.out.println("执行结果==" + execute);
+        }
+    }
+
+}

+ 2 - 2
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/file/FileContentDO.java

@@ -28,8 +28,8 @@ public class FileContentDO extends BaseDO {
     /**
      * 编号,数据库自增
      */
-    @TableId(type = IdType.INPUT)
-    private String id;
+    @TableId
+    private Long id;
     /**
      * 配置编号
      *