diff --git a/bonus-framework/src/main/java/com/bonus/framework/config/FilterConfig.java b/bonus-framework/src/main/java/com/bonus/framework/config/FilterConfig.java index 22e3282..ab9e414 100644 --- a/bonus-framework/src/main/java/com/bonus/framework/config/FilterConfig.java +++ b/bonus-framework/src/main/java/com/bonus/framework/config/FilterConfig.java @@ -16,7 +16,6 @@ import org.springframework.context.annotation.Configuration; import com.bonus.common.filter.RepeatableFilter; import com.bonus.common.filter.XssFilter; import com.bonus.common.utils.StringUtils; -import org.springframework.core.Ordered; /** * Filter配置 @@ -91,16 +90,6 @@ public class FilterConfig return registration; } - /*@Bean - public FilterRegistrationBean replayAttackFilter() { - FilterRegistrationBean registration = new FilterRegistrationBean<>(); - registration.setFilter(new ReplayAttackFilter()); - registration.addUrlPatterns("/*"); - registration.setOrder(1); - registration.setName("replayAttackFilter"); - return registration; - }*/ - @Bean public FilterRegistrationBean ipWhitelistFilterRegistration(IpWhitelistFilter filter) { FilterRegistrationBean registration = new FilterRegistrationBean<>(); diff --git a/bonus-framework/src/main/java/com/bonus/framework/config/ResourcesConfig.java b/bonus-framework/src/main/java/com/bonus/framework/config/ResourcesConfig.java index 80698c8..55b51ac 100644 --- a/bonus-framework/src/main/java/com/bonus/framework/config/ResourcesConfig.java +++ b/bonus-framework/src/main/java/com/bonus/framework/config/ResourcesConfig.java @@ -3,6 +3,7 @@ package com.bonus.framework.config; import java.util.concurrent.TimeUnit; import com.bonus.framework.interceptor.ParamSecureHandler; +import com.bonus.framework.interceptor.ReplayAttackInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -31,6 +32,9 @@ public class ResourcesConfig implements WebMvcConfigurer @Autowired private ParamSecureHandler paramSecureHandler; + @Autowired + private ReplayAttackInterceptor replayAttackInterceptor; + /** 不需要拦截地址 "/logout",*/ public static final String[] EXCLUDEURLS = { "/login", "/refresh" }; @@ -42,9 +46,9 @@ public class ResourcesConfig implements WebMvcConfigurer .addResourceLocations("file:" + BonusConfig.getProfile() + "/"); /** swagger配置 */ - /*registry.addResourceHandler("/swagger-ui/**") + registry.addResourceHandler("/swagger-ui/**") .addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/") - .setCacheControl(CacheControl.maxAge(5, TimeUnit.HOURS).cachePublic());*/ + .setCacheControl(CacheControl.maxAge(5, TimeUnit.HOURS).cachePublic()); } /** @@ -54,11 +58,23 @@ public class ResourcesConfig implements WebMvcConfigurer public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**"); - //自定义拦截器 + // 参数校验拦截器 registry.addInterceptor(paramSecureHandler) .addPathPatterns("/**") .excludePathPatterns(EXCLUDEURLS) .order(-10); + // 防重放拦截器 + registry.addInterceptor(replayAttackInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/smartArchives/captchaImage") + .excludePathPatterns("/smartArchives/login") + .excludePathPatterns("/smartArchives/logout") + .excludePathPatterns("/smartArchives/getInfo") + .excludePathPatterns("/smartArchives/getRouters") + .excludePathPatterns("/smartArchives/session/check") + .excludePathPatterns("/smartArchives/sys/config/getConfig") + .excludePathPatterns(EXCLUDEURLS) + .order(-15); } /** diff --git a/bonus-framework/src/main/java/com/bonus/framework/filter/ReplayAttackFilter.java b/bonus-framework/src/main/java/com/bonus/framework/filter/ReplayAttackFilter.java index 5cc01fc..f8b9508 100644 --- a/bonus-framework/src/main/java/com/bonus/framework/filter/ReplayAttackFilter.java +++ b/bonus-framework/src/main/java/com/bonus/framework/filter/ReplayAttackFilter.java @@ -1,3 +1,4 @@ +/* package com.bonus.framework.filter; import javax.annotation.Resource; @@ -30,13 +31,15 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; +*/ /** * @className:ReplayAttackFilter * @author:cwchen * @date:2025-09-04-11:19 * @version:1.0 * @description:防重放攻击过滤器 - */ + *//* + //@Configuration @Slf4j @Component @@ -215,17 +218,21 @@ public class ReplayAttackFilter implements Filter { } } - /** + */ +/** * 生成请求的唯一签名标识(用于Redis键) - */ + *//* + private String generateRequestSignature(String userId, long timestamp, String requestUrl, String method) { String rawString = userId + ":" + timestamp + ":" + method + ":" + requestUrl; return sha256Hash(rawString); } - /** + */ +/** * 验证请求签名 - */ + *//* + private boolean validateSignature(HttpServletRequest request, String userId,String secret, long timestamp, String requestUrl, String receivedSignature) { String encryptUserId = Sm4Utils.encrypt(userId); @@ -239,9 +246,11 @@ public class ReplayAttackFilter implements Filter { return calculatedSignature.equals(receivedSignature); } - /** + */ +/** * 构建待签名字符串 - */ + *//* + private String buildSignString(String userId, long timestamp, String requestUrl, String method) { StringBuilder sb = new StringBuilder(); sb.append(userId) @@ -253,9 +262,11 @@ public class ReplayAttackFilter implements Filter { } - /** + */ +/** * 计算HMAC-SHA256签名 - */ + *//* + private String calculateHMAC(String data, String key) { try { javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); @@ -269,9 +280,11 @@ public class ReplayAttackFilter implements Filter { } } - /** + */ +/** * SHA256哈希 - */ + *//* + private String sha256Hash(String input) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -310,4 +323,4 @@ public class ReplayAttackFilter implements Filter { public void destroy() { System.out.println("ReplayAttackFilter destroyed"); } -} \ No newline at end of file +}*/ diff --git a/bonus-framework/src/main/java/com/bonus/framework/interceptor/ReplayAttackInterceptor.java b/bonus-framework/src/main/java/com/bonus/framework/interceptor/ReplayAttackInterceptor.java new file mode 100644 index 0000000..754444e --- /dev/null +++ b/bonus-framework/src/main/java/com/bonus/framework/interceptor/ReplayAttackInterceptor.java @@ -0,0 +1,304 @@ +package com.bonus.framework.interceptor; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.bonus.common.constant.CacheConstants; +import com.bonus.common.core.domain.entity.SysUser; +import com.bonus.common.core.domain.model.LoginUser; +import com.bonus.common.core.redis.RedisCache; +import com.bonus.common.utils.encryption.Sm4Utils; +import com.bonus.framework.web.service.TokenService; +import com.bonus.system.domain.vo.SystemConfigVo; +import com.bonus.system.service.ISystemConfigService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * @className: ReplayAttackInterceptor + * @author: cwchen + * @date: 2025-09-04-11:19 + * @version: 1.0 + * @description: 防重放攻击拦截器 + */ +@Slf4j +@Component +public class ReplayAttackInterceptor implements HandlerInterceptor { + + static List ignoreUrlPatterns = new ArrayList<>(); + + static { + ignoreUrlPatterns.add("/smartArchives/captchaImage"); + ignoreUrlPatterns.add("/smartArchives/login"); + ignoreUrlPatterns.add("/smartArchives/logout"); + ignoreUrlPatterns.add("/smartArchives/getInfo"); + ignoreUrlPatterns.add("/smartArchives/getRouters"); + ignoreUrlPatterns.add("/smartArchives/session/check"); + ignoreUrlPatterns.add("/smartArchives/sys/config/getConfig"); + } + + private final RedisCache redisUtil; + + @Autowired + private TokenService tokenService; + + @Autowired + private ISystemConfigService configService; + + // 时间戳允许的误差范围(毫秒) + private static final long TIMESTAMP_TOLERANCE = 5 * 60 * 1000; // 5分钟 + // 请求签名在Redis中的过期时间(秒) + private static final int SIGNATURE_EXPIRE_SECONDS = (int) (TIMESTAMP_TOLERANCE * 2 / 1000); + // Redis键前缀 + private static final String SIGNATURE_KEY_PREFIX = "replay:signature:"; + + private static final long TIMESTAMP_TOLERANCE2 = 15 * 60 * 1000; // 15分钟 + // 请求签名在Redis中的过期时间(秒) + private static final int SIGNATURE_EXPIRE_SECONDS2 = (int) (TIMESTAMP_TOLERANCE2 * 2 / 1000); + + public ReplayAttackInterceptor(RedisCache redisUtil) { + this.redisUtil = redisUtil; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // 跳过OPTIONS预检请求 + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + + String requestURI = request.getRequestURI(); + boolean shouldIgnore = ignoreUrlPatterns.contains(requestURI); + if (shouldIgnore) { + // 忽略该请求的处理 + return true; + } + + // 检查系统配置 + if (isReplayAttackDisabled()) { + return true; + } + + // 获取当前登录人 + LoginUser loginUser = tokenService.getLoginUser(request); + Long userId = Optional.ofNullable(loginUser) + .map(LoginUser::getUserId) + .orElse(null); + // 密钥 + String secret = Optional.ofNullable(loginUser) + .map(LoginUser::getUser) + .map(SysUser::getSecret) + .orElse(null); + + try { + // 1. 获取必要请求头 + String timestampStr = request.getHeader("timestamp"); + String signature = request.getHeader("X-Signature"); + + // 2. 检查必要参数是否存在 + if (timestampStr == null || secret == null || userId == null || signature == null) { + sendErrorResponse(response, "Missing required headers", HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 3. 验证时间戳格式和有效性 + long timestamp; + try { + timestamp = Long.parseLong(timestampStr); + } catch (NumberFormatException e) { + sendErrorResponse(response, "Invalid timestamp format", HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 4. 检查时间戳是否在允许范围内 + long currentTime = System.currentTimeMillis(); + if (Math.abs(currentTime - timestamp) > TIMESTAMP_TOLERANCE) { + sendErrorResponse(response, "Timestamp expired or invalid", HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 5. 获取请求URL + String requestUrl = request.getRequestURI(); + String queryString = request.getQueryString(); + if (queryString != null) { + requestUrl += "?" + queryString; + } + + // 6. 生成请求的唯一签名标识 + String requestSignature = generateRequestSignature(String.valueOf(userId), timestamp, requestUrl, request.getMethod()); + + // 7. 检查签名是否已使用(防重放) + String signatureKey = SIGNATURE_KEY_PREFIX + requestSignature; + if (redisUtil.hasKey(signatureKey)) { + sendErrorResponse(response, "Request replay detected", HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + // 8. 验证请求签名(防止篡改) + if (!validateSignature(request, String.valueOf(userId), secret, timestamp, requestUrl, signature)) { + sendErrorResponse(response, "Invalid signature", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return false; + } + + // 9. 将签名存储到Redis(设置过期时间) + Boolean stored = redisUtil.setNxCacheObject(signatureKey, + String.valueOf(currentTime), + (long) SIGNATURE_EXPIRE_SECONDS, + TimeUnit.SECONDS); + + if (stored == null || !stored) { + sendErrorResponse(response, "Failed to store signature", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return false; + } + + // 验证通过,继续处理请求 + return true; + + } catch (Exception e) { + sendErrorResponse(response, "Server error: " + e.getMessage(), HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + return false; + } + } + + /** + * 检查重放攻击防护是否被禁用 + */ + private boolean isReplayAttackDisabled() { + Object cacheObject = redisUtil.getCacheObject(CacheConstants.SYSTEM_CONFIG_VOS); + if (Objects.isNull(cacheObject)) { + List systemConfigVos = configService.listConfig(); + Boolean stored = redisUtil.setNxCacheObject(CacheConstants.SYSTEM_CONFIG_VOS, + JSON.toJSONString(systemConfigVos), + (long) SIGNATURE_EXPIRE_SECONDS, + TimeUnit.SECONDS); + if (CollectionUtils.isNotEmpty(systemConfigVos)) { + SystemConfigVo config = systemConfigVos.stream() + .filter(item -> CacheConstants.REPLAY_ATTACK.equals(item.getConfigCode())) + .findFirst() + .orElse(null); + if (Objects.nonNull(config)) { + String useStatus = config.getUseStatus(); + return Objects.equals("1", useStatus); + } + } + } else { + String cacheStr = (String) cacheObject; + List systemConfigVos = JSON.parseArray(cacheStr, SystemConfigVo.class); + if (CollectionUtils.isNotEmpty(systemConfigVos)) { + SystemConfigVo config = systemConfigVos.stream() + .filter(item -> CacheConstants.REPLAY_ATTACK.equals(item.getConfigCode())) + .findFirst() + .orElse(null); + if (Objects.nonNull(config)) { + String useStatus = config.getUseStatus(); + return Objects.equals("1", useStatus); + } + } + } + return false; + } + + /** + * 生成请求的唯一签名标识(用于Redis键) + */ + private String generateRequestSignature(String userId, long timestamp, String requestUrl, String method) { + String rawString = userId + ":" + timestamp + ":" + method + ":" + requestUrl; + return sha256Hash(rawString); + } + + /** + * 验证请求签名 + */ + private boolean validateSignature(HttpServletRequest request, String userId, String secret, + long timestamp, String requestUrl, String receivedSignature) { + String encryptUserId = Sm4Utils.encrypt(userId); + String encryptSecret = Sm4Utils.encrypt(secret); + log.debug("Request URL: {}", requestUrl); + + // 构建待签名字符串 + String signString = buildSignString(encryptUserId, timestamp, requestUrl, request.getMethod()); + // 使用HMAC-SHA256计算签名 + String calculatedSignature = calculateHMAC(signString, encryptSecret); + + return calculatedSignature.equals(receivedSignature); + } + + /** + * 构建待签名字符串 + */ + private String buildSignString(String userId, long timestamp, String requestUrl, String method) { + StringBuilder sb = new StringBuilder(); + sb.append(userId) + .append(timestamp) + .append(method.toUpperCase()) + .append(requestUrl); + + return sb.toString(); + } + + /** + * 计算HMAC-SHA256签名 + */ + private String calculateHMAC(String data, String key) { + try { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + javax.crypto.spec.SecretKeySpec secretKey = + new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(secretKey); + byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate HMAC", e); + } + } + + /** + * SHA256哈希 + */ + private String sha256Hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(hash); + } catch (Exception e) { + throw new RuntimeException("Failed to calculate SHA256", e); + } + } + + private String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + private void sendErrorResponse(HttpServletResponse response, String message, int status) + throws IOException { + response.setStatus(status); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(String.format( + "{\"error\": \"%s\", \"code\": %d, \"timestamp\": %d}", + message, status, System.currentTimeMillis() + )); + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { + // 可选的清理操作 + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 64eeac9..d4aaa4b 100644 --- a/pom.xml +++ b/pom.xml @@ -35,7 +35,7 @@ 1.2.13 5.7.12 5.3.39 - test + dev @@ -302,9 +302,9 @@ dev - + @@ -313,9 +313,9 @@ test - - true - + + +