Commit 9eb62fb5a5c32331b78f5a01b0f01af613d55d4f

Authored by 芋道源码
Committed by GitHub
2 parents aced20bd 5f278ac2

Merge pull request #541 from slipper4j/dev-0519-framework-signature

【优化】根据给出的建议优化部分代码逻辑、风格
yudao-framework/yudao-spring-boot-starter-protection/pom.xml
@@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
12 <packaging>jar</packaging> 12 <packaging>jar</packaging>
13 13
14 <name>${project.artifactId}</name> 14 <name>${project.artifactId}</name>
15 - <description>服务保证,提供分布式锁、幂等、限流、熔断等等功能</description> 15 + <description>服务保证,提供分布式锁、幂等、限流、熔断、API 签名等等功能</description>
16 <url>https://github.com/YunaiV/ruoyi-vue-pro</url> 16 <url>https://github.com/YunaiV/ruoyi-vue-pro</url>
17 17
18 <dependencies> 18 <dependencies>
@@ -35,6 +35,13 @@ @@ -35,6 +35,13 @@
35 <artifactId>lock4j-redisson-spring-boot-starter</artifactId> 35 <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
36 <optional>true</optional> 36 <optional>true</optional>
37 </dependency> 37 </dependency>
  38 +
  39 + <!-- Test 测试相关 -->
  40 + <dependency>
  41 + <groupId>org.springframework.boot</groupId>
  42 + <artifactId>spring-boot-starter-test</artifactId>
  43 + <scope>test</scope>
  44 + </dependency>
38 </dependencies> 45 </dependencies>
39 46
40 </project> 47 </project>
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/config/YudaoSignatureAutoConfiguration.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.signature.config;
  2 +
  3 +import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
  4 +import cn.iocoder.yudao.framework.signature.core.aop.SignatureAspect;
  5 +import cn.iocoder.yudao.framework.signature.core.redis.SignatureRedisDAO;
  6 +import org.springframework.boot.autoconfigure.AutoConfiguration;
  7 +import org.springframework.context.annotation.Bean;
  8 +import org.springframework.data.redis.core.StringRedisTemplate;
  9 +
  10 +/**
  11 + * @author Zhougang
  12 + */
  13 +@AutoConfiguration(after = YudaoRedisAutoConfiguration.class)
  14 +public class YudaoSignatureAutoConfiguration {
  15 +
  16 + @Bean
  17 + public SignatureAspect signatureAspect(SignatureRedisDAO signatureRedisDAO) {
  18 + return new SignatureAspect(signatureRedisDAO);
  19 + }
  20 +
  21 + @Bean
  22 + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
  23 + public SignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
  24 + return new SignatureRedisDAO(stringRedisTemplate);
  25 + }
  26 +
  27 +}
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/ApiSignature.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.signature.core.annotation;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
  4 +
  5 +import java.lang.annotation.*;
  6 +import java.util.concurrent.TimeUnit;
  7 +
  8 +
  9 +/**
  10 + * 签名注解
  11 + *
  12 + * @author Zhougang
  13 + */
  14 +@Inherited
  15 +@Documented
  16 +@Target({ElementType.METHOD, ElementType.TYPE})
  17 +@Retention(RetentionPolicy.RUNTIME)
  18 +public @interface ApiSignature {
  19 +
  20 + /**
  21 + * 同一个请求多长时间内有效 默认 60 秒
  22 + */
  23 + int timeout() default 60;
  24 +
  25 + /**
  26 + * 时间单位,默认为 SECONDS 秒
  27 + */
  28 + TimeUnit timeUnit() default TimeUnit.SECONDS;
  29 +
  30 + // ========================== 签名参数 ==========================
  31 +
  32 + /**
  33 + * 提示信息,签名失败的提示
  34 + *
  35 + * @see GlobalErrorCodeConstants#BAD_REQUEST
  36 + */
  37 + String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
  38 +
  39 + /**
  40 + * 签名字段:appId 应用ID
  41 + */
  42 + String appId() default "appId";
  43 +
  44 + /**
  45 + * 签名字段:timestamp 时间戳
  46 + */
  47 + String timestamp() default "timestamp";
  48 +
  49 + /**
  50 + * 签名字段:nonce 随机数,10 位以上
  51 + */
  52 + String nonce() default "nonce";
  53 +
  54 + /**
  55 + * sign 客户端签名
  56 + */
  57 + String sign() default "sign";
  58 +
  59 +}
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/aop/SignatureAspect.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.signature.core.aop;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.lang.Assert;
  5 +import cn.hutool.core.util.StrUtil;
  6 +import cn.hutool.crypto.SignUtil;
  7 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
  8 +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
  9 +import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
  10 +import cn.iocoder.yudao.framework.signature.core.annotation.ApiSignature;
  11 +import cn.iocoder.yudao.framework.signature.core.redis.SignatureRedisDAO;
  12 +import cn.iocoder.yudao.framework.web.core.filter.CacheRequestBodyWrapper;
  13 +import jakarta.servlet.http.HttpServletRequest;
  14 +import lombok.AllArgsConstructor;
  15 +import lombok.extern.slf4j.Slf4j;
  16 +import org.aspectj.lang.JoinPoint;
  17 +import org.aspectj.lang.annotation.Aspect;
  18 +import org.aspectj.lang.annotation.Before;
  19 +
  20 +import java.nio.charset.StandardCharsets;
  21 +import java.util.Map;
  22 +import java.util.Objects;
  23 +import java.util.SortedMap;
  24 +import java.util.TreeMap;
  25 +import java.util.concurrent.TimeUnit;
  26 +
  27 +/**
  28 + * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
  29 + *
  30 + * @author Zhougang
  31 + */
  32 +@Aspect
  33 +@Slf4j
  34 +@AllArgsConstructor
  35 +public class SignatureAspect {
  36 +
  37 + private final SignatureRedisDAO signatureRedisDAO;
  38 +
  39 + @Before("@annotation(signature)")
  40 + public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
  41 + if (!verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
  42 + log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
  43 + joinPoint.getArgs());
  44 + String message = StrUtil.blankToDefault(signature.message(),
  45 + GlobalErrorCodeConstants.BAD_REQUEST.getMsg());
  46 + throw new ServiceException(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), message);
  47 + }
  48 + }
  49 +
  50 + private boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
  51 + if (!verifyHeaders(signature, request)) {
  52 + return false;
  53 + }
  54 + // 校验 appId 是否能获取到对应的 appSecret
  55 + String appId = request.getHeader(signature.appId());
  56 + String appSecret = signatureRedisDAO.getAppSecret(appId);
  57 + Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
  58 + // 请求头
  59 + SortedMap<String, String> headersMap = getRequestHeaders(signature, request);
  60 + // 请求参数
  61 + String requestParams = getRequestParams(request);
  62 + // 请求体
  63 + String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : "";
  64 + // 生成服务端签名
  65 + String serverSignature = SignUtil.signParamsSha256(headersMap, requestParams + requestBody + appSecret);
  66 + // 客户端签名
  67 + String clientSignature = request.getHeader(signature.sign());
  68 + if (!StrUtil.equals(clientSignature, serverSignature)) {
  69 + return false;
  70 + }
  71 + String nonce = headersMap.get(signature.nonce());
  72 + // 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
  73 + signatureRedisDAO.setNonce(nonce, signature.timeout() * 2L, signature.timeUnit());
  74 + return true;
  75 + }
  76 +
  77 + /**
  78 + * 校验请求头加签参数
  79 + * 1.appId 是否为空
  80 + * 2.timestamp 是否为空,请求是否已经超时,默认 10 分钟
  81 + * 3.nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
  82 + * 4.sign 是否为空
  83 + *
  84 + * @param signature signature
  85 + * @param request request
  86 + */
  87 + private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
  88 + String appId = request.getHeader(signature.appId());
  89 + if (StrUtil.isBlank(appId)) {
  90 + return false;
  91 + }
  92 + String timestamp = request.getHeader(signature.timestamp());
  93 + if (StrUtil.isBlank(timestamp)) {
  94 + return false;
  95 + }
  96 + String nonce = request.getHeader(signature.nonce());
  97 + if (StrUtil.isBlank(nonce) || StrUtil.length(nonce) < 10) {
  98 + return false;
  99 + }
  100 + String sign = request.getHeader(signature.sign());
  101 + if (StrUtil.isBlank(sign)) {
  102 + return false;
  103 + }
  104 + // 其他合法性校验
  105 + long expireTime = signature.timeUnit().toMillis(signature.timeout());
  106 + long requestTimestamp = Long.parseLong(timestamp);
  107 + // 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
  108 + long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
  109 + if (timestampDisparity > expireTime) {
  110 + return false;
  111 + }
  112 + String cacheNonce = signatureRedisDAO.getNonce(nonce);
  113 + return StrUtil.isBlank(cacheNonce);
  114 + }
  115 +
  116 + /**
  117 + * 获取请求头加签参数
  118 + *
  119 + * @param request request
  120 + * @return signature params
  121 + */
  122 + private SortedMap<String, String> getRequestHeaders(ApiSignature signature, HttpServletRequest request) {
  123 + SortedMap<String, String> sortedMap = new TreeMap<>();
  124 + sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
  125 + sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
  126 + sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
  127 + return sortedMap;
  128 + }
  129 +
  130 + /**
  131 + * 获取 URL 参数
  132 + *
  133 + * @param request request
  134 + * @return queryParams
  135 + */
  136 + private String getRequestParams(HttpServletRequest request) {
  137 + if (CollUtil.isEmpty(request.getParameterMap())) {
  138 + return "";
  139 + }
  140 + Map<String, String[]> requestParams = request.getParameterMap();
  141 + // 获取 URL 请求参数
  142 + SortedMap<String, String> sortParamsMap = new TreeMap<>();
  143 + for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
  144 + sortParamsMap.put(entry.getKey(), entry.getValue()[0]);
  145 + }
  146 + // 按 key 排序
  147 + StringBuilder queryString = new StringBuilder();
  148 + for (Map.Entry<String, String> entry : sortParamsMap.entrySet()) {
  149 + queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue());
  150 + }
  151 + return queryString.substring(1);
  152 + }
  153 +
  154 +}
  155 +
yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/redis/SignatureRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.signature.core.redis;
  2 +
  3 +import lombok.AllArgsConstructor;
  4 +import org.springframework.data.redis.core.StringRedisTemplate;
  5 +
  6 +import java.util.concurrent.TimeUnit;
  7 +
  8 +/**
  9 + * API 签名 Redis DAO
  10 + *
  11 + * @author Zhougang
  12 + */
  13 +@AllArgsConstructor
  14 +public class SignatureRedisDAO {
  15 +
  16 + private final StringRedisTemplate stringRedisTemplate;
  17 +
  18 + /**
  19 + * 验签随机数
  20 + * <p>
  21 + * KEY 格式:signature_nonce:%s // 参数为 随机数
  22 + * VALUE 格式:String
  23 + * 过期时间:不固定
  24 + */
  25 + private static final String SIGNATURE_NONCE = "signature_nonce:%s";
  26 +
  27 + /**
  28 + * 签名密钥
  29 + * <p>
  30 + * KEY 格式:signature_appid:%s // 参数为 appid
  31 + * VALUE 格式:String
  32 + * 过期时间:预加载到 redis 永不过期
  33 + */
  34 + private static final String SIGNATURE_APPID = "signature_appid:%s";
  35 +
  36 + public String getAppSecret(String appId) {
  37 + return stringRedisTemplate.opsForValue().get(formatAppIdKey(appId));
  38 + }
  39 +
  40 + public String getNonce(String nonce) {
  41 + return stringRedisTemplate.opsForValue().get(formatNonceKey(nonce));
  42 + }
  43 +
  44 + public void setNonce(String nonce, long time, TimeUnit timeUnit) {
  45 + stringRedisTemplate.opsForValue().set(formatNonceKey(nonce), nonce, time, timeUnit);
  46 + }
  47 +
  48 + private static String formatAppIdKey(String key) {
  49 + return String.format(SIGNATURE_APPID, key);
  50 + }
  51 +
  52 + private static String formatNonceKey(String key) {
  53 + return String.format(SIGNATURE_NONCE, key);
  54 + }
  55 +}
yudao-framework/yudao-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
1 cn.iocoder.yudao.framework.idempotent.config.YudaoIdempotentConfiguration 1 cn.iocoder.yudao.framework.idempotent.config.YudaoIdempotentConfiguration
2 cn.iocoder.yudao.framework.lock4j.config.YudaoLock4jConfiguration 2 cn.iocoder.yudao.framework.lock4j.config.YudaoLock4jConfiguration
3 -cn.iocoder.yudao.framework.ratelimiter.config.YudaoRateLimiterConfiguration  
4 \ No newline at end of file 3 \ No newline at end of file
  4 +cn.iocoder.yudao.framework.ratelimiter.config.YudaoRateLimiterConfiguration
  5 +cn.iocoder.yudao.framework.signature.config.YudaoSignatureAutoConfiguration
5 \ No newline at end of file 6 \ No newline at end of file
yudao-framework/yudao-spring-boot-starter-protection/src/test/java/cn/iocoder/yudao/framework/signature/core/SignatureTest.java 0 → 100644
  1 +package cn.iocoder.yudao.framework.signature.core;
  2 +
  3 +import cn.hutool.core.lang.Snowflake;
  4 +import cn.hutool.crypto.digest.DigestUtil;
  5 +import cn.hutool.http.HttpRequest;
  6 +import cn.hutool.http.HttpResponse;
  7 +import cn.hutool.http.HttpUtil;
  8 +import org.junit.jupiter.api.Test;
  9 +
  10 +import java.util.Map;
  11 +import java.util.SortedMap;
  12 +import java.util.TreeMap;
  13 +
  14 +/**
  15 + * {@link SignatureTest} 的单元测试
  16 + */
  17 +public class SignatureTest {
  18 +
  19 + @Test
  20 + public void testSignatureGet() {
  21 + String appId = "xxxxxx";
  22 + Snowflake snowflake = new Snowflake();
  23 +
  24 + // 验签请求头前端需传入字段
  25 + SortedMap<String, String> headersMap = new TreeMap<>();
  26 + headersMap.put("appId", appId);
  27 + headersMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
  28 + headersMap.put("nonce", String.valueOf(snowflake.nextId()));
  29 +
  30 + // 客户端加签内容
  31 + StringBuilder clientSignatureContent = new StringBuilder();
  32 + // 请求头
  33 + for (Map.Entry<String, String> entry : headersMap.entrySet()) {
  34 + clientSignatureContent.append(entry.getKey()).append(entry.getValue());
  35 + }
  36 + // 请求 url
  37 + clientSignatureContent.append("/admin-api/infra/demo01-contact/get");
  38 + // 请求参数
  39 + SortedMap<String, String> paramsMap = new TreeMap<>();
  40 + paramsMap.put("id", "100");
  41 + paramsMap.put("name", "张三");
  42 + StringBuilder queryString = new StringBuilder();
  43 + for (Map.Entry<String, String> entry : paramsMap.entrySet()) {
  44 + queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue());
  45 + }
  46 + clientSignatureContent.append(queryString.substring(1));
  47 + // 密钥
  48 + clientSignatureContent.append("d3cbeed9baf4e68673a1f69a2445359a20022b7c28ea2933dd9db9f3a29f902b");
  49 + System.out.println("加签内容:" + clientSignatureContent);
  50 + // 加签
  51 + headersMap.put("sign", DigestUtil.sha256Hex(clientSignatureContent.toString()));
  52 + headersMap.put("Authorization", "Bearer xxx");
  53 +
  54 + HttpRequest get = HttpUtil.createGet("http://localhost:48080/admin-api/infra/demo01-contact/get?id=100&name=张三");
  55 + get.addHeaders(headersMap);
  56 + System.out.println("执行结果==" + get.execute());
  57 + }
  58 +
  59 + @Test
  60 + public void testSignaturePost() {
  61 + String appId = "xxxxxx";
  62 + Snowflake snowflake = new Snowflake();
  63 +
  64 + // 验签请求头前端需传入字段
  65 + SortedMap<String, String> headersMap = new TreeMap<>();
  66 + headersMap.put("appId", appId);
  67 + headersMap.put("timestamp", String.valueOf(System.currentTimeMillis()));
  68 + headersMap.put("nonce", String.valueOf(snowflake.nextId()));
  69 +
  70 + // 客户端加签内容
  71 + StringBuilder clientSignatureContent = new StringBuilder();
  72 + // 请求头
  73 + for (Map.Entry<String, String> entry : headersMap.entrySet()) {
  74 + clientSignatureContent.append(entry.getKey()).append(entry.getValue());
  75 + }
  76 + // 请求 url
  77 + clientSignatureContent.append("/admin-api/infra/demo01-contact/create");
  78 + // 请求体
  79 + String body = "{\n" +
  80 + " \"password\": \"1\",\n" +
  81 + " \"date\": \"2024-04-24 16:28:00\",\n" +
  82 + " \"user\": {\n" +
  83 + " \"area\": \"浦东新区\",\n" +
  84 + " \"1\": \"xx\",\n" +
  85 + " \"2\": \"xx\",\n" +
  86 + " \"province\": \"上海市\",\n" +
  87 + " \"data\": {\n" +
  88 + " \"99\": \"xx\",\n" +
  89 + " \"1\": \"xx\",\n" +
  90 + " \"100\": \"xx\",\n" +
  91 + " \"2\": \"xx\",\n" +
  92 + " \"3\": \"xx\",\n" +
  93 + " \"array\": [\n" +
  94 + " {\n" +
  95 + " \"3\": \"aa\",\n" +
  96 + " \"4\": \"aa\",\n" +
  97 + " \"2\": \"aa\",\n" +
  98 + " \"1\": \"aa\"\n" +
  99 + " },\n" +
  100 + " {\n" +
  101 + " \"99\": \"aa\",\n" +
  102 + " \"100\": \"aa\",\n" +
  103 + " \"88\": \"aa\",\n" +
  104 + " \"120\": \"aa\"\n" +
  105 + " }\n" +
  106 + " ]\n" +
  107 + " },\n" +
  108 + " \"sex\": \"男\",\n" +
  109 + " \"name\": \"张三\",\n" +
  110 + " \"array\": [\n" +
  111 + " \"1\",\n" +
  112 + " \"3\",\n" +
  113 + " \"5\",\n" +
  114 + " \"2\"\n" +
  115 + " ]\n" +
  116 + " },\n" +
  117 + " \"username\": \"xiaoming\"\n" +
  118 + "}";
  119 + clientSignatureContent.append(body);
  120 +
  121 + // 密钥
  122 + clientSignatureContent.append("d3cbeed9baf4e68673a1f69a2445359a20022b7c28ea2933dd9db9f3a29f902b");
  123 + System.out.println("加签内容:" + clientSignatureContent);
  124 + // 加签
  125 + headersMap.put("sign", DigestUtil.sha256Hex(clientSignatureContent.toString()));
  126 + headersMap.put("Authorization", "Bearer xxx");
  127 +
  128 + HttpRequest post = HttpUtil.createPost("http://localhost:48080/admin-api/infra/demo01-contact/create");
  129 + post.addHeaders(headersMap);
  130 + post.body(body);
  131 + try (HttpResponse execute = post.execute()) {
  132 + System.out.println("执行结果==" + execute);
  133 + }
  134 + }
  135 +
  136 +}