防重放攻击

This commit is contained in:
cwchen 2025-09-04 16:59:12 +08:00
parent 6f67f1b6e9
commit e5179ac441
9 changed files with 312 additions and 3 deletions

View File

@ -84,6 +84,7 @@ public class SysLoginController
tokenService.refreshToken(loginUser);
}
AjaxResult ajax = AjaxResult.success();
user.setPassword("");
ajax.put("user", user);
ajax.put("roles", roles);
ajax.put("permissions", permissions);

View File

@ -92,6 +92,9 @@ public class SysUser extends BaseEntity
/** 角色ID */
private Long roleId;
/**用户密钥*/
private String secret;
public SysUser()
{
@ -310,6 +313,14 @@ public class SysUser extends BaseEntity
this.roleId = roleId;
}
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
@Override
public String toString() {
return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE)

View File

@ -265,4 +265,10 @@ public class RedisCache
{
return redisTemplate.keys(pattern);
}
//添加分布式锁
public <T> Boolean setNxCacheObject(final String key, final T value,long lt,TimeUnit tu)
{
return redisTemplate.opsForValue().setIfAbsent(key,value,lt,tu);
}
}

View File

@ -4,6 +4,7 @@ import com.bonus.common.exception.CaptchaException;
import com.bonus.common.utils.encryption.Sm3Util;
import com.bonus.common.utils.encryption.Sm4Utils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
@ -27,7 +28,7 @@ import java.util.*;
* @author bonus
*/
@Component
@Configuration
@Slf4j
public class RequestCoverFilter implements Filter {

View File

@ -0,0 +1,25 @@
package com.bonus.common.utils;
import java.security.SecureRandom;
import java.util.Base64;
/**
* @className:GenerateUtil
* @author:cwchen
* @date:2025-09-04-14:22
* @version:1.0
* @description:生成密钥等工具类
*/
public class GenerateUtil {
private static String generateRandomSecret(int length) {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[length];
random.nextBytes(bytes);
return Base64.getEncoder().encodeToString(bytes);
}
public static void main(String[] args) {
System.err.println(generateRandomSecret(32));;
}
}

View File

@ -6,6 +6,7 @@ import javax.servlet.DispatcherType;
import com.bonus.common.filter.RequestCoverFilter;
import com.bonus.common.filter.ResponseEncryptFilter;
import com.bonus.framework.filter.ReplayAttackFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
@ -82,4 +83,14 @@ public class FilterConfig
return registration;
}
/*@Bean
public FilterRegistrationBean<ReplayAttackFilter> replayAttackFilter() {
FilterRegistrationBean<ReplayAttackFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new ReplayAttackFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
registration.setName("replayAttackFilter");
return registration;
}*/
}

View File

@ -0,0 +1,253 @@
package com.bonus.framework.filter;
import javax.servlet.*;
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.Optional;
import java.util.concurrent.TimeUnit;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
/**
* @className:ReplayAttackFilter
* @author:cwchen
* @date:2025-09-04-11:19
* @version:1.0
* @description:防重放攻击过滤器
*/
@Configuration
@Slf4j
public class ReplayAttackFilter implements Filter {
static List<String> ignoreUrlPatterns = new ArrayList<>();
static {
ignoreUrlPatterns.add("/captchaImage");
ignoreUrlPatterns.add("/login");
ignoreUrlPatterns.add("/loginOut");
ignoreUrlPatterns.add("/getInfo");
ignoreUrlPatterns.add("/getRouters");
}
@Autowired
private RedisCache redisUtil;
@Autowired
private TokenService tokenService;
// 时间戳允许的误差范围毫秒
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:";
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 跳过OPTIONS预检请求
if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
chain.doFilter(request, response);
return;
}
String requestURI = httpRequest.getRequestURI();
boolean shouldIgnore = ignoreUrlPatterns.contains(requestURI);
if (shouldIgnore) {
// 忽略该请求的处理
System.out.println("忽略请求: " + requestURI);
chain.doFilter(request, response);
return;
}
// 获取当前登录人
LoginUser loginUser = tokenService.getLoginUser(httpRequest);
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 = httpRequest.getHeader("timestamp");
String signature = httpRequest.getHeader("X-Signature");
// 2. 检查必要参数是否存在
if (timestampStr == null || secret == null || userId == null || signature == null) {
sendErrorResponse(httpResponse, "Missing required headers", HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 3. 验证时间戳格式和有效性
long timestamp;
try {
timestamp = Long.parseLong(timestampStr);
} catch (NumberFormatException e) {
sendErrorResponse(httpResponse, "Invalid timestamp format", HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 4. 检查时间戳是否在允许范围内
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - timestamp) > TIMESTAMP_TOLERANCE) {
sendErrorResponse(httpResponse, "Timestamp expired or invalid", HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 5. 获取请求URL
String requestUrl = httpRequest.getRequestURI();
String queryString = httpRequest.getQueryString();
if (queryString != null) {
requestUrl += "?" + queryString;
}
// 6. 生成请求的唯一签名标识
String requestSignature = generateRequestSignature(String.valueOf(userId), timestamp, requestUrl, httpRequest.getMethod());
// 7. 检查签名是否已使用防重放
String signatureKey = SIGNATURE_KEY_PREFIX + requestSignature;
if (redisUtil.hasKey(signatureKey)) {
sendErrorResponse(httpResponse, "Request replay detected", HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 8. 验证请求签名防止篡改
if (!validateSignature(httpRequest, String.valueOf(userId),secret, timestamp, requestUrl, signature)) {
sendErrorResponse(httpResponse, "Invalid signature", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
// 9. 将签名存储到Redis设置过期时间
Boolean stored = redisUtil.setNxCacheObject(signatureKey,
String.valueOf(currentTime),
(long) SIGNATURE_EXPIRE_SECONDS,
TimeUnit.SECONDS);
if (stored == null || !stored) {
sendErrorResponse(httpResponse, "Failed to store signature", HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
return;
}
// 验证通过继续处理请求
chain.doFilter(request, response);
} catch (Exception e) {
sendErrorResponse(httpResponse, "Server error: " + e.getMessage(), HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
/**
* 生成请求的唯一签名标识用于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);
// 构建待签名字符串
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 init(FilterConfig filterConfig) throws ServletException {
System.out.println("ReplayAttackFilter initialized");
}
@Override
public void destroy() {
System.out.println("ReplayAttackFilter destroyed");
}
}

View File

@ -12,7 +12,7 @@ import java.util.Map;
/**
* 包装和增强原始的 HttpServletRequest 对象允许多次读取请求体body的数据特别是在处理请求时使用流读取数据的情况下
* @author 黑子
* @author
*/
public class ReadHttpRequestWrapper extends HttpServletRequestWrapper {

View File

@ -24,6 +24,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="updateBy" column="update_by" />
<result property="updateTime" column="update_time" />
<result property="remark" column="remark" />
<result property="secret" column="secret" />
<association property="dept" javaType="SysDept" resultMap="deptResult" />
<collection property="roles" javaType="java.util.List" resultMap="RoleResult" />
</resultMap>
@ -50,7 +51,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectUserVo">
select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark,
d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status,
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status
r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status,u.secret
from da_ky_sys_user u
left join da_ky_sys_dept d on u.dept_id = d.dept_id
left join da_ky_sys_user_role ur on u.user_id = ur.user_id