Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/ruoyi-vue-pro
# Conflicts: # yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/config/YudaoWebSecurityConfigurerAdapter.java # yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/logger/ApiAccessLogServiceImpl.java # yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/service/logger/ApiErrorLogServiceImpl.java # yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/permission/RoleServiceImpl.java
This commit is contained in:
@@ -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>cn.iocoder.boot</groupId>
|
||||
<artifactId>yudao-spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.framework.signature.config;
|
||||
|
||||
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
|
||||
import cn.iocoder.yudao.framework.signature.core.aop.ApiSignatureAspect;
|
||||
import cn.iocoder.yudao.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
/**
|
||||
* HTTP API 签名的自动配置类
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@AutoConfiguration(after = YudaoRedisAutoConfiguration.class)
|
||||
public class YudaoApiSignatureAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
|
||||
return new ApiSignatureAspect(signatureRedisDAO);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
|
||||
return new ApiSignatureRedisDAO(stringRedisTemplate);
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
|
||||
|
||||
/**
|
||||
* HTTP API 签名注解
|
||||
*
|
||||
* @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";
|
||||
|
||||
}
|
@@ -0,0 +1,169 @@
|
||||
package cn.iocoder.yudao.framework.signature.core.aop;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
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.ApiSignatureRedisDAO;
|
||||
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.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
|
||||
/**
|
||||
* 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@Aspect
|
||||
@Slf4j
|
||||
@AllArgsConstructor
|
||||
public class ApiSignatureAspect {
|
||||
|
||||
private final ApiSignatureRedisDAO signatureRedisDAO;
|
||||
|
||||
@Before("@annotation(signature)")
|
||||
public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
|
||||
// 1. 验证通过,直接结束
|
||||
if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 验证不通过,抛出异常
|
||||
log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
|
||||
joinPoint.getArgs());
|
||||
throw new ServiceException(BAD_REQUEST.getCode(),
|
||||
StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
|
||||
}
|
||||
|
||||
public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1.1 校验 Header
|
||||
if (!verifyHeaders(signature, request)) {
|
||||
return false;
|
||||
}
|
||||
// 1.2 校验 appId 是否能获取到对应的 appSecret
|
||||
String appId = request.getHeader(signature.appId());
|
||||
String appSecret = signatureRedisDAO.getAppSecret(appId);
|
||||
Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
|
||||
|
||||
// 2. 校验签名【重要!】
|
||||
String clientSignature = request.getHeader(signature.sign()); // 客户端签名
|
||||
String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
|
||||
String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
|
||||
if (ObjUtil.notEqual(clientSignature, serverSignature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
|
||||
String nonce = request.getHeader(signature.nonce());
|
||||
signatureRedisDAO.setNonce(nonce, signature.timeout() * 2, signature.timeUnit());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求头加签参数
|
||||
*
|
||||
* 1. appId 是否为空
|
||||
* 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
|
||||
* 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
|
||||
* 4. sign 是否为空
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @return 是否校验 Header 通过
|
||||
*/
|
||||
private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
|
||||
// 1. 非空校验
|
||||
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.length(nonce) < 10) {
|
||||
return false;
|
||||
}
|
||||
String sign = request.getHeader(signature.sign());
|
||||
if (StrUtil.isBlank(sign)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
|
||||
long expireTime = signature.timeUnit().toMillis(signature.timeout());
|
||||
long requestTimestamp = Long.parseLong(timestamp);
|
||||
long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
|
||||
if (timestampDisparity > expireTime) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 检查 nonce 是否存在,有且仅能使用一次
|
||||
return signatureRedisDAO.getNonce(nonce) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名字符串
|
||||
*
|
||||
* 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
|
||||
*
|
||||
* @param signature signature
|
||||
* @param request request
|
||||
* @param appSecret appSecret
|
||||
* @return 签名字符串
|
||||
*/
|
||||
private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
|
||||
SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
|
||||
SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
|
||||
String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
|
||||
return MapUtil.join(parameterMap, "&", "=")
|
||||
+ requestBody
|
||||
+ MapUtil.join(headerMap, "&", "=")
|
||||
+ appSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求头加签参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @param signature 签名注解
|
||||
* @return signature params
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestHeaderMap(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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求参数 Map
|
||||
*
|
||||
* @param request 请求
|
||||
* @return queryParams
|
||||
*/
|
||||
private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
|
||||
SortedMap<String, String> sortedMap = new TreeMap<>();
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
sortedMap.put(entry.getKey(), entry.getValue()[0]);
|
||||
}
|
||||
return sortedMap;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -0,0 +1,57 @@
|
||||
package cn.iocoder.yudao.framework.signature.core.redis;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* HTTP API 签名 Redis DAO
|
||||
*
|
||||
* @author Zhougang
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
public class ApiSignatureRedisDAO {
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 验签随机数
|
||||
*
|
||||
* KEY 格式:signature_nonce:%s // 参数为 随机数
|
||||
* VALUE 格式:String
|
||||
* 过期时间:不固定
|
||||
*/
|
||||
private static final String SIGNATURE_NONCE = "api_signature_nonce:%s";
|
||||
|
||||
/**
|
||||
* 签名密钥
|
||||
*
|
||||
* HASH 结构
|
||||
* KEY 格式:%s // 参数为 appid
|
||||
* VALUE 格式:String
|
||||
* 过期时间:永不过期(预加载到 Redis)
|
||||
*/
|
||||
private static final String SIGNATURE_APPID = "api_signature_app";
|
||||
|
||||
// ========== 验签随机数 ==========
|
||||
|
||||
public String getNonce(String nonce) {
|
||||
return stringRedisTemplate.opsForValue().get(formatNonceKey(nonce));
|
||||
}
|
||||
|
||||
public void setNonce(String nonce, int time, TimeUnit timeUnit) {
|
||||
stringRedisTemplate.opsForValue().set(formatNonceKey(nonce), "", time, timeUnit);
|
||||
}
|
||||
|
||||
private static String formatNonceKey(String key) {
|
||||
return String.format(SIGNATURE_NONCE, key);
|
||||
}
|
||||
|
||||
// ========== 签名密钥 ==========
|
||||
|
||||
public String getAppSecret(String appId) {
|
||||
return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* HTTP API 签名,校验安全性
|
||||
*
|
||||
* @see <a href="https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3>微信支付 —— 安全规范</a>
|
||||
*/
|
||||
package cn.iocoder.yudao.framework.signature;
|
@@ -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.YudaoApiSignatureAutoConfiguration
|
@@ -0,0 +1,75 @@
|
||||
package cn.iocoder.yudao.framework.signature.core;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.signature.core.annotation.ApiSignature;
|
||||
import cn.iocoder.yudao.framework.signature.core.aop.ApiSignatureAspect;
|
||||
import cn.iocoder.yudao.framework.signature.core.redis.ApiSignatureRedisDAO;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link ApiSignatureTest} 的单元测试
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class ApiSignatureTest {
|
||||
|
||||
@InjectMocks
|
||||
private ApiSignatureAspect apiSignatureAspect;
|
||||
|
||||
@Mock
|
||||
private ApiSignatureRedisDAO signatureRedisDAO;
|
||||
|
||||
@Test
|
||||
public void testSignatureGet() throws IOException {
|
||||
// 搞一个签名
|
||||
Long timestamp = System.currentTimeMillis();
|
||||
String nonce = IdUtil.randomUUID();
|
||||
String appId = "xxxxxx";
|
||||
String appSecret = "yyyyyy";
|
||||
String signString = "k1=v1&v1=k1testappId=xxxxxx&nonce=" + nonce + "×tamp=" + timestamp + "yyyyyy";
|
||||
String sign = DigestUtil.sha256Hex(signString);
|
||||
|
||||
// 准备参数
|
||||
ApiSignature apiSignature = mock(ApiSignature.class);
|
||||
when(apiSignature.appId()).thenReturn("appId");
|
||||
when(apiSignature.timestamp()).thenReturn("timestamp");
|
||||
when(apiSignature.nonce()).thenReturn("nonce");
|
||||
when(apiSignature.sign()).thenReturn("sign");
|
||||
when(apiSignature.timeout()).thenReturn(60);
|
||||
when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
when(request.getHeader(eq("appId"))).thenReturn(appId);
|
||||
when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
|
||||
when(request.getHeader(eq("nonce"))).thenReturn(nonce);
|
||||
when(request.getHeader(eq("sign"))).thenReturn(sign);
|
||||
when(request.getParameterMap()).thenReturn(MapUtil.<String, String[]>builder()
|
||||
.put("v1", new String[]{"k1"}).put("k1", new String[]{"v1"}).build());
|
||||
when(request.getContentType()).thenReturn("application/json");
|
||||
when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test")));
|
||||
// mock 方法
|
||||
when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
|
||||
|
||||
// 调用
|
||||
boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
|
||||
// 断言结果
|
||||
assertTrue(result);
|
||||
// 断言调用
|
||||
verify(signatureRedisDAO).setNonce(eq(nonce), eq(120), eq(TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user