Skip to content
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

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions yudao-framework/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

<module>yudao-spring-boot-starter-monitor</module>
<module>yudao-spring-boot-starter-protection</module>
<module>yudao-spring-boot-starter-signature</module>
<module>yudao-spring-boot-starter-job</module>
<module>yudao-spring-boot-starter-mq</module>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public interface GlobalErrorCodeConstants {
ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确");
ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试"); // 并发请求,不允许
ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试");
ErrorCode SIGNATURE_REQUESTS = new ErrorCode(430, "签名失败");
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议使用 400 Bad Request,提示可以使用,签名不正确


// ========== 服务端错误段 ==========

Expand Down
40 changes: 40 additions & 0 deletions yudao-framework/yudao-spring-boot-starter-signature/pom.xml
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>
Copy link
Owner

Choose a reason for hiding this comment

The 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;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

建议改成 Redis 读取好了;

默认 redis 读取;类似有个 map 集合;里面 key 是 appid、value 是具体 secret;

如果读取不到,通过 配置文件里面有个 default 的 secret

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

考虑到开放平台会对多个应用提供服务,如果把 appId 和 secret 缓存到 redis,是否要提供一个数据预加载的方法


/**
* 同一个请求多长时间内有效 默认10分钟
Copy link
Owner

Choose a reason for hiding this comment

The 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 错误提示

Copy link
Owner

Choose a reason for hiding this comment

The 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;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

一般带 url 的多么?
我瞅了几个平台,貌似带的不多。

所以在想,是不是默认不要这个属性哈?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

类名,和变量之间,要有个空行


public SignatureAspect(SignatureRedisDAO signatureRedisDAO) {
Copy link
Owner

Choose a reason for hiding this comment

The 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
Copy link
Owner

Choose a reason for hiding this comment

The 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());
Copy link
Owner

Choose a reason for hiding this comment

The 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)) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StrUtil.notEquals

建议上,逻辑尽量避免 if (!xxx)

人对取反要多一层思考;

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

body 的话,建议是简单点;直接拿整个 requestBody;里面不需要再排序了;

可以在调研下其他做开放平台的,他们是怎么考虑的哈。

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
}
Loading