-
Notifications
You must be signed in to change notification settings - Fork 6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
【新增】yudao-spring-boot-starter-signature接口签名模块 #526
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
<?xml version="1.0" encoding="UTF-8"?> | ||
<project xmlns="http://maven.apache.org/POM/4.0.0" | ||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | ||
<parent> | ||
<groupId>cn.iocoder.boot</groupId> | ||
<artifactId>yudao-framework</artifactId> | ||
<version>${revision}</version> | ||
</parent> | ||
<modelVersion>4.0.0</modelVersion> | ||
<artifactId>yudao-spring-boot-starter-signature</artifactId> | ||
<packaging>jar</packaging> | ||
|
||
<name>${project.artifactId}</name> | ||
<description>提供接口验签功能</description> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 建议放到 protection 模块下 |
||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url> | ||
|
||
<dependencies> | ||
<!-- Web 相关 --> | ||
<dependency> | ||
<groupId>cn.iocoder.boot</groupId> | ||
<artifactId>yudao-spring-boot-starter-web</artifactId> | ||
<scope>provided</scope> <!-- 设置为 provided,只有限流、幂等使用到 --> | ||
</dependency> | ||
|
||
<!-- Spring 核心 --> | ||
<dependency> | ||
<groupId>org.springframework.boot</groupId> | ||
<artifactId>spring-boot-starter-aop</artifactId> | ||
</dependency> | ||
|
||
<!-- DB 相关 --> | ||
<dependency> | ||
<groupId>cn.iocoder.boot</groupId> | ||
<artifactId>yudao-spring-boot-starter-redis</artifactId> | ||
</dependency> | ||
|
||
</dependencies> | ||
|
||
</project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package cn.iocoder.yudao.framework.signature.config; | ||
|
||
import cn.iocoder.yudao.framework.signature.core.aop.SignatureAspect; | ||
import cn.iocoder.yudao.framework.signature.core.redis.SignatureRedisDAO; | ||
import cn.iocoder.yudao.framework.signature.core.service.impl.DefaultSignatureApiImpl; | ||
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration; | ||
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 SignatureAutoConfiguration { | ||
|
||
@Bean | ||
public SignatureAspect signatureAspect(SignatureRedisDAO signatureRedisDAO) { | ||
return new SignatureAspect(signatureRedisDAO); | ||
} | ||
|
||
@Bean | ||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") | ||
public SignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { | ||
return new SignatureRedisDAO(stringRedisTemplate); | ||
} | ||
|
||
@Bean | ||
public DefaultSignatureApiImpl defaultSignatureApiImpl() { | ||
return new DefaultSignatureApiImpl(); | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package cn.iocoder.yudao.framework.signature.core.annotation; | ||
|
||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; | ||
import cn.iocoder.yudao.framework.signature.core.service.SignatureApi; | ||
import cn.iocoder.yudao.framework.signature.core.service.impl.DefaultSignatureApiImpl; | ||
|
||
import java.lang.annotation.*; | ||
|
||
|
||
/** | ||
* 签名注解 | ||
* | ||
* @author Zhougang | ||
*/ | ||
@Inherited | ||
@Documented | ||
@Target({ElementType.METHOD, ElementType.TYPE}) | ||
@Retention(RetentionPolicy.RUNTIME) | ||
public @interface Signature { | ||
|
||
/** | ||
* 使用的 签名算法 | ||
* | ||
* @see DefaultSignatureApiImpl 默认 | ||
*/ | ||
Class<? extends SignatureApi> signatureApi() default DefaultSignatureApiImpl.class; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 建议改成 Redis 读取好了; 默认 redis 读取;类似有个 map 集合;里面 key 是 appid、value 是具体 secret; 如果读取不到,通过 配置文件里面有个 default 的 secret There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 考虑到开放平台会对多个应用提供服务,如果把 appId 和 secret 缓存到 redis,是否要提供一个数据预加载的方法 |
||
|
||
/** | ||
* 同一个请求多长时间内有效 默认10分钟 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 同一个请求多长时间内有效 默认 10 分钟 改成这个。中文写作习惯,中英文之间要有空格 |
||
*/ | ||
long expireTime() default 600000L; | ||
|
||
/** | ||
* 提示信息,签名失败的提示 | ||
* | ||
* @see GlobalErrorCodeConstants#SIGNATURE_REQUESTS | ||
*/ | ||
String message() default ""; // 为空时,使用 SIGNATURE_REQUEST 错误提示 | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这里上面加个 // ========== 参数名 ========== 主要和上面的关键字段,分隔一下噢 |
||
/** | ||
* 签名字段:appId 应用ID | ||
*/ | ||
String appId() default "appId"; | ||
|
||
/** | ||
* 签名字段:appId 时间戳 | ||
*/ | ||
String timestamp() default "timestamp"; | ||
|
||
/** | ||
* 签名字段:nonce 随机数,10位以上 | ||
*/ | ||
String nonce() default "nonce"; | ||
|
||
/** | ||
* sign 客户端签名 | ||
*/ | ||
String sign() default "sign"; | ||
|
||
/** | ||
* url客户端不需要传递,但是可以用来加签(如:/{id}带有动态参数的url,如果没有动态参数可设置为false,不进行加签) | ||
*/ | ||
boolean urlEnable() default true; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 一般带 url 的多么? 所以在想,是不是默认不要这个属性哈? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 项目中基本没有带动态参数的,不过出于扩展性,或者以后考虑把framework做为组件发布至中央仓库供其他项目使用时,是有必要的 |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
package cn.iocoder.yudao.framework.signature.core.aop; | ||
|
||
import cn.hutool.core.util.StrUtil; | ||
import cn.hutool.extra.spring.SpringUtil; | ||
import cn.iocoder.yudao.framework.common.exception.ServiceException; | ||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; | ||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | ||
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.signature.core.service.SignatureApi; | ||
import cn.iocoder.yudao.framework.signature.core.util.SignatureUtils; | ||
import cn.iocoder.yudao.framework.web.core.filter.CacheRequestBodyWrapper; | ||
import com.fasterxml.jackson.databind.JsonNode; | ||
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 org.springframework.util.CollectionUtils; | ||
|
||
import javax.servlet.http.HttpServletRequest; | ||
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 | ||
public class SignatureAspect { | ||
private final SignatureRedisDAO signatureRedisDAO; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 类名,和变量之间,要有个空行 |
||
|
||
public SignatureAspect(SignatureRedisDAO signatureRedisDAO) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这个使用 lombok 简化下,构造参数那个注解哈 |
||
this.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.SIGNATURE_REQUESTS.getMsg()); | ||
throw new ServiceException(GlobalErrorCodeConstants.SIGNATURE_REQUESTS.getCode(), message); | ||
} | ||
} | ||
|
||
private boolean verifySignature(Signature signature, HttpServletRequest request) { | ||
// 根据request 中 header值生成SignatureHeaders实体 | ||
if (!verifyHeaders(signature, request)) { | ||
return false; | ||
} | ||
SignatureApi signatureApi = SpringUtil.getBean(signature.signatureApi()); | ||
Assert.notNull(signatureApi, "找不到对应的 SignatureApi 实现"); | ||
// 校验appId是否能获取到对应的appSecret | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 注释,注意下中英文之间的空行 |
||
String appId = request.getHeader(signature.appId()); | ||
if (signatureApi.getAppSecret(appId) == null) { | ||
return false; | ||
} | ||
String appSecret = signatureApi.getAppSecret(appId); | ||
// 获取全部参数(包括URL和Body上的) | ||
SortedMap<String, String> requestParams = getRequestParams(signature, request); | ||
// 组装需要加签的文本 | ||
StringBuilder signContent = new StringBuilder(); | ||
for (Map.Entry<String, String> entry : requestParams.entrySet()) { | ||
signContent.append(entry.getKey()).append(entry.getValue()); | ||
} | ||
// 如:/user/{id} url带有动态参数的情况 | ||
if (signature.urlEnable()) { | ||
signContent.append(request.getServletPath()); | ||
} | ||
// 结尾拼接应用密钥 appSecret | ||
signContent.append(appSecret); | ||
|
||
// 生成服务端签名 | ||
String serverSign = signatureApi.digestEncoder(signContent.toString()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 这个签名,抽一个小方法在这个逻辑里。 第一期,就不要搞太复杂的哈。HMAC-SHA256 之类的就 ok 啦 |
||
// 客户端签名 | ||
String clientSign = request.getHeader(signature.sign()); | ||
if (!StrUtil.equals(clientSign, serverSign)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. StrUtil.notEquals 建议上,逻辑尽量避免 if (!xxx) 人对取反要多一层思考; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. StrUtil 中没有 notEquals 方法 |
||
return false; | ||
} | ||
String nonce = requestParams.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是否为空 | ||
*/ | ||
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 SortedMap | ||
*/ | ||
private SortedMap<String, String> getRequestParams(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())); | ||
// 获取parameters(对应@RequestParam) | ||
if (!CollectionUtils.isEmpty(request.getParameterMap())) { | ||
Map<String, String[]> requestParams = request.getParameterMap(); | ||
//获取GET请求参数,以键值对形式保存 | ||
SortedMap<String, String> paramMap = new TreeMap<>(); | ||
for (Map.Entry<String, String[]> entry : requestParams.entrySet()) { | ||
paramMap.put(entry.getKey(), entry.getValue()[0]); | ||
} | ||
sortedMap.put("param", JsonUtils.toJsonString(paramMap)); | ||
} | ||
|
||
CacheRequestBodyWrapper requestWrapper = new CacheRequestBodyWrapper(request); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. body 的话,建议是简单点;直接拿整个 requestBody;里面不需要再排序了; 可以在调研下其他做开放平台的,他们是怎么考虑的哈。 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 好的,这个之前确实有考虑不需要排序。 |
||
// 分别获取了request input stream中的body信息、parameter信息 | ||
String body = new String(requestWrapper.getBody(), StandardCharsets.UTF_8); | ||
if (StrUtil.isNotBlank(body)) { | ||
// body可能为JSON对象或JSON数组 | ||
// Object parsedObject = JSON.parse(body); | ||
JsonNode jsonNode = JsonUtils.parseTree(body); | ||
if (jsonNode.isObject()) { | ||
// 获取POST请求的JSON参数,以键值对形式保存 | ||
sortedMap.put("body", JsonUtils.toJsonString(SignatureUtils.traverseMap(jsonNode))); | ||
} else if (jsonNode.isArray()) { | ||
sortedMap.put("body", JsonUtils.toJsonString(SignatureUtils.traverseList(jsonNode))); | ||
} else { | ||
sortedMap.put("body", body); | ||
} | ||
} | ||
return sortedMap; | ||
} | ||
|
||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
package cn.iocoder.yudao.framework.signature.core.redis; | ||
|
||
import lombok.AllArgsConstructor; | ||
import org.springframework.data.redis.core.StringRedisTemplate; | ||
|
||
import java.util.concurrent.TimeUnit; | ||
|
||
/** | ||
* 验签 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"; | ||
|
||
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 formatNonceKey(String key) { | ||
return String.format(SIGNATURE_NONCE, key); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
package cn.iocoder.yudao.framework.signature.core.service; | ||
|
||
/** | ||
* 签名 API 接口 | ||
* | ||
* @author Zhougang | ||
*/ | ||
public interface SignatureApi { | ||
|
||
/** | ||
* 获取appSecret | ||
* | ||
* @return appSecret | ||
*/ | ||
String getAppSecret(String appId); | ||
|
||
/** | ||
* 加密算法, md5, sha256 | ||
* | ||
* @param plainText 需要加签的内容 | ||
* @return sign 签名 | ||
*/ | ||
String digestEncoder(String plainText); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
建议使用 400 Bad Request,提示可以使用,签名不正确