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);