diff --git a/yudao-framework/yudao-spring-boot-starter-protection/pom.xml b/yudao-framework/yudao-spring-boot-starter-protection/pom.xml index bbb5b12eb..46326c63a 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/pom.xml +++ b/yudao-framework/yudao-spring-boot-starter-protection/pom.xml @@ -12,7 +12,7 @@ jar ${project.artifactId} - 服务保证,提供分布式锁、幂等、限流、熔断等等功能 + 服务保证,提供分布式锁、幂等、限流、熔断、API 签名等等功能 https://github.com/YunaiV/ruoyi-vue-pro @@ -35,6 +35,13 @@ lock4j-redisson-spring-boot-starter true + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/config/YudaoSignatureAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/config/YudaoSignatureAutoConfiguration.java new file mode 100644 index 000000000..5b4b8e43e --- /dev/null +++ b/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); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/ApiSignature.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/ApiSignature.java new file mode 100644 index 000000000..e338ae709 --- /dev/null +++ b/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"; + +} diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/aop/SignatureAspect.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/aop/SignatureAspect.java new file mode 100644 index 000000000..a001419f8 --- /dev/null +++ b/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 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 getRequestHeaders(ApiSignature signature, HttpServletRequest request) { + SortedMap 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 requestParams = request.getParameterMap(); + // 获取 URL 请求参数 + SortedMap sortParamsMap = new TreeMap<>(); + for (Map.Entry entry : requestParams.entrySet()) { + sortParamsMap.put(entry.getKey(), entry.getValue()[0]); + } + // 按 key 排序 + StringBuilder queryString = new StringBuilder(); + for (Map.Entry entry : sortParamsMap.entrySet()) { + queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue()); + } + return queryString.substring(1); + } + +} + diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/redis/SignatureRedisDAO.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/redis/SignatureRedisDAO.java new file mode 100644 index 000000000..326e238ee --- /dev/null +++ b/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; + + /** + * 验签随机数 + *

+ * KEY 格式:signature_nonce:%s // 参数为 随机数 + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String SIGNATURE_NONCE = "signature_nonce:%s"; + + /** + * 签名密钥 + *

+ * 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); + } +} diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/yudao-framework/yudao-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d7cd3a883..6cab74e75 100644 --- a/yudao-framework/yudao-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/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 \ No newline at end of file +cn.iocoder.yudao.framework.ratelimiter.config.YudaoRateLimiterConfiguration +cn.iocoder.yudao.framework.signature.config.YudaoSignatureAutoConfiguration \ No newline at end of file diff --git a/yudao-framework/yudao-spring-boot-starter-protection/src/test/java/cn/iocoder/yudao/framework/signature/core/SignatureTest.java b/yudao-framework/yudao-spring-boot-starter-protection/src/test/java/cn/iocoder/yudao/framework/signature/core/SignatureTest.java new file mode 100644 index 000000000..a253cd51a --- /dev/null +++ b/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 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 entry : headersMap.entrySet()) { + clientSignatureContent.append(entry.getKey()).append(entry.getValue()); + } + // 请求 url + clientSignatureContent.append("/admin-api/infra/demo01-contact/get"); + // 请求参数 + SortedMap paramsMap = new TreeMap<>(); + paramsMap.put("id", "100"); + paramsMap.put("name", "张三"); + StringBuilder queryString = new StringBuilder(); + for (Map.Entry 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 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 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); + } + } + +}