# 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:
YunaiV
2024-06-04 21:20:26 +08:00
33 changed files with 5711 additions and 316 deletions

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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 + "&timestamp=" + 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));
}
}

View File

@@ -5,15 +5,20 @@ import cn.iocoder.yudao.framework.security.core.filter.TokenAuthenticationFilter
import cn.iocoder.yudao.framework.web.config.WebProperties;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
@@ -23,13 +28,15 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;
import javax.annotation.Resource;
import javax.annotation.security.PermitAll;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
/**
* 自定义的 Spring Security 配置适配器实现
*
@@ -37,7 +44,7 @@ import java.util.Set;
*/
@AutoConfiguration
@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置避免一键改包后org.* 基础包无法生效
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@EnableMethodSecurity(securedEnabled = true)
public class YudaoWebSecurityConfigurerAdapter {
@Resource
@@ -103,15 +110,15 @@ public class YudaoWebSecurityConfigurerAdapter {
// 登出
httpSecurity
// 开启跨域
.cors().and()
.cors(Customizer.withDefaults())
// CSRF 禁用,因为不使用 Session
.csrf().disable()
.csrf(AbstractHttpConfigurer::disable)
// 基于 token 机制,所以不需要 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.headers().frameOptions().disable().and()
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
// 一堆自定义的 Spring Security 处理器
.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
.exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler));
// 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高
// 获得 @PermitAll 带来的 URL 列表,免登录
@@ -119,27 +126,25 @@ public class YudaoWebSecurityConfigurerAdapter {
// 设置每个请求的权限
httpSecurity
// ①:全局共享规则
.authorizeRequests()
// 1.1 静态资源,可匿名访问
.antMatchers(HttpMethod.GET, "/*.html", "/**/*.html", "/**/*.css", "/**/*.js").permitAll()
// 1.2 设置 @PermitAll 无需认证
.antMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll()
.antMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll()
.antMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll()
.antMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll()
// 1.3 基于 yudao.security.permit-all-urls 无需认证
.antMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll()
// 1.4 设置 App API 无需认证
.antMatchers(buildAppApi("/**")).permitAll()
// 1.5 验证码captcha 允许匿名访问
.antMatchers("/captcha/get", "/captcha/check").permitAll()
.authorizeHttpRequests(c -> c
// 1.1 静态资源,可匿名访问
.requestMatchers(HttpMethod.GET, "/*.html", "/*.html", "/*.css", "/*.js").permitAll()
// 1.1 设置 @PermitAll 无需认证
.requestMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.HEAD, permitAllUrls.get(HttpMethod.HEAD).toArray(new String[0])).permitAll()
.requestMatchers(HttpMethod.PATCH, permitAllUrls.get(HttpMethod.PATCH).toArray(new String[0])).permitAll()
// 1.2 基于 yudao.security.permit-all-urls 无需认证
.requestMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll()
// 1.3 设置 App API 无需认证
.requestMatchers(buildAppApi("/**")).permitAll()
)
// ②:每个项目的自定义规则
.and().authorizeRequests(registry -> // 下面,循环设置自定义规则
authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(registry)))
.authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c)))
// ③:兜底规则,必须认证
.authorizeRequests()
.anyRequest().authenticated()
;
.authorizeHttpRequests(c -> c.anyRequest().authenticated());
// 添加 Token Filter
httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
@@ -162,17 +167,26 @@ public class YudaoWebSecurityConfigurerAdapter {
if (!handlerMethod.hasMethodAnnotation(PermitAll.class)) {
continue;
}
if (entry.getKey().getPatternsCondition() == null) {
Set<String> urls = new HashSet<>();
if (entry.getKey().getPatternsCondition() != null) {
urls.addAll(entry.getKey().getPatternsCondition().getPatterns());
}
if (entry.getKey().getPathPatternsCondition() != null) {
urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
}
if (urls.isEmpty()) {
continue;
}
Set<String> urls = entry.getKey().getPatternsCondition().getPatterns();
// 特殊:使用 @RequestMapping 注解,并且未写 method 属性,此时认为都需要免登录
Set<RequestMethod> methods = entry.getKey().getMethodsCondition().getMethods();
if (CollUtil.isEmpty(methods)) { //
if (CollUtil.isEmpty(methods)) {
result.putAll(HttpMethod.GET, urls);
result.putAll(HttpMethod.POST, urls);
result.putAll(HttpMethod.PUT, urls);
result.putAll(HttpMethod.DELETE, urls);
result.putAll(HttpMethod.HEAD, urls);
result.putAll(HttpMethod.PATCH, urls);
continue;
}
// 根据请求方法,添加到 result 结果
@@ -190,6 +204,12 @@ public class YudaoWebSecurityConfigurerAdapter {
case DELETE:
result.putAll(HttpMethod.DELETE, urls);
break;
case HEAD:
result.putAll(HttpMethod.HEAD, urls);
break;
case PATCH:
result.putAll(HttpMethod.PATCH, urls);
break;
}
});
}