From 8e5584821e722498b21523a38efe22eef57ae638 Mon Sep 17 00:00:00 2001 From: zhougang <921366807@qq.com> Date: Sun, 19 May 2024 15:34:45 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=90=E6=96=B0=E5=A2=9E=E3=80=91=20protecti?= =?UTF-8?q?on=20=E6=A8=A1=E5=9D=97=E6=96=B0=E5=A2=9E=20signature=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20API=20=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pom.xml | 9 +- .../YudaoSignatureAutoConfiguration.java | 27 +++ .../signature/core/annotation/Signature.java | 57 ++++++ .../signature/core/aop/SignatureAspect.java | 170 ++++++++++++++++++ .../core/redis/SignatureRedisDAO.java | 56 ++++++ .../signature/core/SignatureTest.java | 136 ++++++++++++++ .../core/filter/CacheRequestBodyWrapper.java | 4 + 7 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/config/YudaoSignatureAutoConfiguration.java create mode 100644 yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/Signature.java create mode 100644 yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/aop/SignatureAspect.java create mode 100644 yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/redis/SignatureRedisDAO.java create mode 100644 yudao-framework/yudao-spring-boot-starter-protection/src/test/java/cn/iocoder/yudao/framework/signature/core/SignatureTest.java diff --git a/yudao-framework/yudao-spring-boot-starter-protection/pom.xml b/yudao-framework/yudao-spring-boot-starter-protection/pom.xml index bbb5b12ebe..025bb75910 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 0000000000..5b4b8e43e5 --- /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/Signature.java b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/Signature.java new file mode 100644 index 0000000000..1b7e127860 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-protection/src/main/java/cn/iocoder/yudao/framework/signature/core/annotation/Signature.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.framework.signature.core.annotation; + +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; + +import java.lang.annotation.*; + + +/** + * 签名注解 + * + * @author Zhougang + */ +@Inherited +@Documented +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Signature { + + /** + * 同一个请求多长时间内有效 默认 10分钟 + */ + long expireTime() default 600000L; + + // ========================== 签名参数 ========================== + + /** + * 提示信息,签名失败的提示 + * + * @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"; + + /** + * url 客户端不需要传递,但是可以用来加签(如: /{id} 带有动态参数的 url ,如果没有动态参数可设置为 false 不进行加签) + */ + boolean urlEnable() default true; +} 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 0000000000..dc15104656 --- /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,170 @@ +package cn.iocoder.yudao.framework.signature.core.aop; + +import cn.hutool.core.collection.CollUtil; +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.Signature; +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 org.springframework.util.Assert; + +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 Signature} 注解的方法,实现签名 + * + * @author Zhougang + */ +@Aspect +@Slf4j +@AllArgsConstructor +public class SignatureAspect { + + private final SignatureRedisDAO signatureRedisDAO; + + @Before("@annotation(signature)") + public void beforePointCut(JoinPoint joinPoint, Signature signature) { + if (!verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) { + log.info("[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(Signature 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, "找不到对应的 appSecret"); + // 请求头 + SortedMap headersMap = getRequestHeaders(signature, request); + // 如:/user/{id} url 带有动态参数的情况 + String urlParams = signature.urlEnable() ? request.getServletPath() : ""; + // 请求参数 + String requestParams = getRequestParams(request); + // 请求体 + String requestBody = getRequestBody(request); + // 生成服务端签名 + String serverSignature = SignUtil.signParamsSha256(headersMap, + urlParams + 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.expireTime(), TimeUnit.MILLISECONDS); + return true; + } + + /** + * 校验请求头加签参数 + * 1.appId 是否为空 + * 2.timestamp 是否为空,请求是否已经超时,默认 10 分钟 + * 3.nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了 + * 4.sign 是否为空 + * + * @param signature signature + * @param request request + */ + private boolean verifyHeaders(Signature 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) || nonce.length() < 10) { + return false; + } + String sign = request.getHeader(signature.sign()); + if (StrUtil.isBlank(sign)) { + return false; + } + // 其他合法性校验 + long expireTime = signature.expireTime(); + 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(Signature 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); + } + + /** + * 获取请求体参数 + * + * @param request request + * @return body + */ + private String getRequestBody(HttpServletRequest request) { + CacheRequestBodyWrapper requestWrapper = new CacheRequestBodyWrapper(request); + // 获取 body + return new String(requestWrapper.getBody(), StandardCharsets.UTF_8); + } + +} + 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 0000000000..d00fe7f8d1 --- /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,56 @@ +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) { + // 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 ) + stringRedisTemplate.opsForValue().set(formatNonceKey(nonce), nonce, time * 2, 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/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 0000000000..a253cd51a2 --- /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); + } + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java index 8e80fa591f..e181edeb4f 100644 --- a/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java +++ b/yudao-framework/yudao-spring-boot-starter-web/src/main/java/cn/iocoder/yudao/framework/web/core/filter/CacheRequestBodyWrapper.java @@ -23,6 +23,10 @@ public class CacheRequestBodyWrapper extends HttpServletRequestWrapper { */ private final byte[] body; + public byte[] getBody() { + return body; + } + public CacheRequestBodyWrapper(HttpServletRequest request) { super(request); body = ServletUtils.getBodyBytes(request);