mirror of
https://gitee.com/hhyykk/ipms-sjy.git
synced 2025-08-08 23:31:53 +08:00
【新增】 protection 模块新增 signature 实现 API 签名
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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<String, String> 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<String, String> getRequestHeaders(Signature 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取请求体参数
|
||||
*
|
||||
* @param request request
|
||||
* @return body
|
||||
*/
|
||||
private String getRequestBody(HttpServletRequest request) {
|
||||
CacheRequestBodyWrapper requestWrapper = new CacheRequestBodyWrapper(request);
|
||||
// 获取 body
|
||||
return new String(requestWrapper.getBody(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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;
|
||||
|
||||
/**
|
||||
* 验签随机数
|
||||
* <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) {
|
||||
// 将 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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user