Skip to content

Commit

Permalink
Merge pull request #541 from slipper4j/dev-0519-framework-signature
Browse files Browse the repository at this point in the history
【优化】根据给出的建议优化部分代码逻辑、风格
  • Loading branch information
YunaiV authored Jun 3, 2024
2 parents aced20b + 5f278ac commit 9eb62fb
Show file tree
Hide file tree
Showing 7 changed files with 442 additions and 2 deletions.
9 changes: 8 additions & 1 deletion yudao-framework/yudao-spring-boot-starter-protection/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -35,6 +35,13 @@
<artifactId>lock4j-redisson-spring-boot-starter</artifactId>
<optional>true</optional>
</dependency>

<!-- Test 测试相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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";

}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<String, String> getRequestHeaders(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;
}

/**
* 获取 URL 参数
*
* @param request request
* @return queryParams
*/
private String getRequestParams(HttpServletRequest request) {
if (CollUtil.isEmpty(request.getParameterMap())) {
return "";
}
Map<String, String[]> requestParams = request.getParameterMap();
// 获取 URL 请求参数
SortedMap<String, String> sortParamsMap = new TreeMap<>();
for (Map.Entry<String, String[]> entry : requestParams.entrySet()) {
sortParamsMap.put(entry.getKey(), entry.getValue()[0]);
}
// 按 key 排序
StringBuilder queryString = new StringBuilder();
for (Map.Entry<String, String> entry : sortParamsMap.entrySet()) {
queryString.append("&").append(entry.getKey()).append("=").append(entry.getValue());
}
return queryString.substring(1);
}

}

Original file line number Diff line number Diff line change
@@ -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;

/**
* 验签随机数
* <p>
* KEY 格式:signature_nonce:%s // 参数为 随机数
* VALUE 格式:String
* 过期时间:不固定
*/
private static final String SIGNATURE_NONCE = "signature_nonce:%s";

/**
* 签名密钥
* <p>
* 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.YudaoSignatureAutoConfiguration
Loading

0 comments on commit 9eb62fb

Please sign in to comment.