在项目开发中,重放攻击(Replay Attack)是一种常见的攻击手段,
攻击者截获有效请求后重新发送给服务器,以达到未授权访问、重复交易或身份冒充等目的。
本文将介绍五种在SpringBoot应用中实现防重放攻击的方案。
一、重放攻击基本概念
1.1 什么是重放攻击
重放攻击是一种网络攻击手段,攻击者截获一个有效的数据传输,然后在稍后的时间重新发送相同的数据,以实现欺骗系统的目的。在Web应用中,这通常表现为重复提交相同的请求,比如:
- 重复提交订单付款请求
- 重复使用过期的访问令牌
- 重复提交表单数据
- 重新发送包含认证信息的请求
1.2 重放攻击的危害
重放攻击可能导致以下安全问题:
- 资金损失:重复执行支付交易
- 资源耗尽:大量重复请求导致系统资源枯竭
- 数据不一致:重复提交导致数据重复或状态混乱
- 业务逻辑被绕过:绕过设计中的业务规则
- 权限提升:复用他人有效的认证信息
二、时间戳+请求超时机制
2.1 基本原理
这种方案要求客户端在每个请求中附带当前时间戳,服务器收到请求后,检查时间戳是否在允许的时间窗口内(通常为几分钟)。
如果请求的时间戳超出时间窗口,则认为是过期请求或潜在的重放攻击,拒绝处理该请求。
2.2 SpringBoot实现
首先,创建一个请求包装类,包含时间戳字段:
kotlin
@Data
public class ApiRequest<T> {
private Long timestamp; // 请求时间戳,毫秒级
private T data; // 实际请求数据
}
然后,创建一个拦截器来检查请求时间戳:
java
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestBodyCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (isRequestBodyEligible(request)) {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
filterChain.doFilter(wrappedRequest, response);
} else {
filterChain.doFilter(request, response);
}
}
private boolean isRequestBodyEligible(HttpServletRequest request) {
String method = request.getMethod();
return "POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method) || "DELETE".equals(method);
}
}
@Component
@Slf4j
public class TimestampInterceptor implements HandlerInterceptor {
private static final long ALLOWED_TIME_WINDOW = 5 * 60 * 1000; // 5分钟时间窗口
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 检查是否需要进行时间戳验证
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (!handlerMethod.getMethod().isAnnotationPresent(CheckTimestamp.class)) {
return true;
}
// 获取请求体
String requestBody = getRequestBody(request);
try {
// 解析请求体,获取时间戳
ApiRequest<?> apiRequest = new ObjectMapper().readValue(requestBody, ApiRequest.class);
Long timestamp = apiRequest.getTimestamp();
if (timestamp == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Missing timestamp");
return false;
}
// 检查时间戳是否在允许的时间窗口内
long currentTime = System.currentTimeMillis();
if (currentTime - timestamp > ALLOWED_TIME_WINDOW || timestamp > currentTime) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Request expired or invalid timestamp");
return false;
}
return true;
} catch (Exception e) {
log.error("Error processing timestamp", e);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid request format");
return false;
}
}
private String getRequestBody(HttpServletRequest request) throws IOException {
// 针对ContentCachingRequestWrapper的处理
if (request instanceof ContentCachingRequestWrapper) {
ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
// 读取缓存的内容
byte[] buf = wrapper.getContentAsByteArray();
if (buf.length > 0) {
return new String(buf, wrapper.getCharacterEncoding());
}
}
// 针对MultiReadHttpServletRequest的处理
try (BufferedReader reader = request.getReader()) {
return reader.lines().collect(Collectors.joining(System.lineSeparator()));
}
}
}
创建一个注解,用于标记需要进行时间戳验证的接口:
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckTimestamp {
}
配置拦截器:
typescript
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TimestampInterceptor timestampInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timestampInterceptor)
.addPathPatterns("/api/**");
}
}
在需要防止重放攻击的接口上添加注解:
less
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
@CheckTimestamp
public ResponseEntity<?> createOrder(@RequestBody ApiRequest<OrderCreateRequest> request) {
// 处理订单创建逻辑
return ResponseEntity.ok().build();
}
}
2.3 优缺点分析
优点:
- 实现简单,无需额外存储
- 不依赖会话状态,适合分布式系统
- 客户端实现简单,只需添加时间戳
缺点:
- 需要客户端和服务器时间同步
- 时间窗口存在权衡:太短影响用户体验,太长降低安全性
- 无法防止时间窗口内的重放攻击
- 不适合时间敏感的高安全场景
三、Nonce随机数+Redis缓存
3.1 基本原理
Nonce(Number used once)是一个只使用一次的随机数。在此方案中,客户端每次请求都生成一个唯一的随机数,并发送给服务器。
服务器将使用过的Nonce存储在Redis缓存中一段时间,拒绝任何使用重复Nonce的请求。这种方式可以有效防止重放攻击,因为每个有效请求都需要一个从未使用过的Nonce。
3.2 SpringBoot实现
首先,扩展请求包装类,添加Nonce字段:
kotlin
@Data
public class ApiRequest<T> {
private Long timestamp; // 请求时间戳,毫秒级
private String nonce; // 随机数,每次请求唯一
private T data; // 实际请求数据
}
添加Redis配置:
arduino
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
创建Nonce检查拦截器:
java
@Component
@Slf4j
public class NonceInterceptor implements HandlerInterceptor {
private static final long NONCE_EXPIRE_SECONDS = 3600; // Nonce有效期1小时
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (!handlerMethod.getMethod().isAnnotationPresent(CheckNonce.class)) {
return true;
}
String requestBody = getRequestBody(request);
try {
ApiRequest<?> apiRequest = new ObjectMapper().readValue(requestBody, ApiRequest.class);
String nonce = apiRequest.getNonce();
if (StringUtils.isEmpty(nonce)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Missing nonce");
return false;
}
// 检查时间戳
Long timestamp = apiRequest.getTimestamp();
if (timestamp == null || System.currentTimeMillis() - timestamp > 5 * 60 * 1000) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Request expired or invalid timestamp");
return false;
}
// 检查Nonce是否已使用
String nonceKey = "nonce:" + nonce;
Boolean isFirstUse = redisTemplate.opsForValue().setIfAbsent(nonceKey, timestamp.toString(), NONCE_EXPIRE_SECONDS, TimeUnit.SECONDS);
if (isFirstUse == null || !isFirstUse) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Duplicate request or replay attack detected");
log.warn("Duplicate nonce detected: {}", nonce);
return false;
}
return true;
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid request format");
return false;
}
}
// getRequestBody方法同上
}
创建注解,标记需要进行Nonce验证的接口:
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckNonce {
}
在需要防止重放攻击的接口上添加注解:
less
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@PostMapping
@CheckNonce
public ResponseEntity<?> processPayment(@RequestBody ApiRequest<PaymentRequest> request) {
// 处理支付逻辑
return ResponseEntity.ok().build();
}
}
3.3 客户端生成Nonce示例
vbscript
public class ApiClient {
public static ApiRequest<T> createRequest(T data) {
ApiRequest<T> request = new ApiRequest<>();
request.setTimestamp(System.currentTimeMillis());
request.setNonce(UUID.randomUUID().toString());
request.setData(data);
return request;
}
}
3.4 优缺点分析
优点:
- 安全性高,每个请求都必须使用唯一的Nonce
- 可以有效防止在任何时间窗口内的重放攻击
- 结合时间戳可以双重保障
缺点:
- 需要存储使用过的Nonce,增加系统复杂性
- 在分布式系统中需要共享Nonce存储
- 对Redis等存储系统有依赖
- 客户端需要生成唯一Nonce,实现相对复杂
四、幂等性令牌机制
4.1 基本原理
幂等性令牌机制是一种专门针对非幂等操作(如创建订单、支付等)设计的防重放方案。
服务器先生成一个一次性的令牌并提供给客户端,客户端在执行操作时必须提交这个令牌,服务器验证令牌有效后执行操作并立即使令牌失效,从而保证操作不会重复执行。
4.2 SpringBoot实现
首先,创建令牌服务:
typescript
@Service
@Slf4j
public class IdempotencyTokenService {
private static final String TOKEN_PREFIX = "idempotency_token:";
private static final long TOKEN_EXPIRE_MINUTES = 30; // 令牌有效期30分钟
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 生成幂等性令牌
*/
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
TOKEN_PREFIX + token,
"UNUSED",
TOKEN_EXPIRE_MINUTES,
TimeUnit.MINUTES
);
return token;
}
/**
* 验证并消费令牌
* @return true表示令牌有效且已成功消费,false表示令牌无效或已被消费
*/
public boolean validateAndConsumeToken(String token) {
if (StringUtils.isEmpty(token)) {
return false;
}
String key = TOKEN_PREFIX + token;
// 使用Redis的原子操作验证并更新令牌状态
String script = "if redis.call('get', KEYS[1]) == 'UNUSED' then "
+ "redis.call('set', KEYS[1], 'USED') "
+ "return 1 "
+ "else "
+ "return 0 "
+ "end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key)
);
return result != null && result == 1;
}
}
创建获取令牌的API:
typescript
@RestController
@RequestMapping("/api/tokens")
public class TokenController {
@Autowired
private IdempotencyTokenService tokenService;
@PostMapping
public ResponseEntity<Map<String, String>> generateToken() {
String token = tokenService.generateToken();
Map<String, String> response = Collections.singletonMap("token", token);
return ResponseEntity.ok(response);
}
}
创建幂等性检查拦截器:
java
@Component
@Slf4j
public class IdempotencyInterceptor implements HandlerInterceptor {
@Autowired
private IdempotencyTokenService tokenService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
IdempotentOperation annotation = handlerMethod.getMethod().getAnnotation(IdempotentOperation.class);
if (annotation == null) {
return true;
}
// 从请求头获取幂等性令牌
String token = request.getHeader("Idempotency-Token");
if (StringUtils.isEmpty(token)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Missing idempotency token");
return false;
}
// 验证并消费令牌
boolean isValid = tokenService.validateAndConsumeToken(token);
if (!isValid) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid or already used idempotency token");
log.warn("Attempt to reuse idempotency token: {}", token);
return false;
}
return true;
}
}
创建幂等性操作注解:
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface IdempotentOperation {
}
在需要保证幂等性的API上使用注解:
less
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@PostMapping
@IdempotentOperation
public ResponseEntity<?> createOrder(@RequestBody OrderCreateRequest request) {
// 创建订单
OrderDTO order = orderService.createOrder(request);
return ResponseEntity.ok(order);
}
}
4.3 客户端使用示例
css
// 第一步: 获取幂等性令牌
const tokenResponse = await fetch('/api/tokens', { method: 'POST' });
const { token } = await tokenResponse.json();
// 第二步: 使用令牌提交请求
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Token': token
},
body: JSON.stringify({
// 订单数据
})
});
4.4 优缺点分析
优点:
- 专为非幂等操作设计,安全性高
- 客户端必须先获取令牌,可以有效防止未授权请求
- 服务端控制令牌生成和验证,安全可控
- 可以与业务逻辑完美结合
缺点:
- 需要额外的获取令牌请求,增加交互复杂性
- 依赖外部存储系统保存令牌状态
- 对客户端有特定要求,实现相对复杂
- 令牌有效期管理需要权衡
五、请求签名认证
5.1 基本原理
请求签名认证方案通过对请求参数、时间戳、随机数等信息进行加密签名,确保请求在传输过程中不被篡改,同时结合时间戳和随机数防止重放攻击。
该方案通常用于API安全性要求较高的场景,如支付、金融等领域。
5.2 SpringBoot实现
首先,创建请求签名工具类:
typescript
@Component
public class SignatureUtils {
/**
* 生成签名
* @param params 参与签名的参数
* @param timestamp 时间戳
* @param nonce 随机数
* @param secretKey 密钥
* @return 签名
*/
public String generateSignature(Map<String, String> params, long timestamp, String nonce, String secretKey) {
// 1. 按参数名ASCII码排序
Map<String, String> sortedParams = new TreeMap<>(params);
// 2. 构建签名字符串
StringBuilder builder = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (StringUtils.isNotEmpty(entry.getValue())) {
builder.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
// 3. 添加时间戳和随机数
builder.append("timestamp=").append(timestamp).append("&");
builder.append("nonce=").append(nonce).append("&");
builder.append("key=").append(secretKey);
// 4. 进行MD5签名
return DigestUtils.md5DigestAsHex(builder.toString().getBytes(StandardCharsets.UTF_8));
}
/**
* 验证签名
*/
public boolean verifySignature(Map<String, String> params, long timestamp, String nonce, String signature, String secretKey) {
String calculatedSignature = generateSignature(params, timestamp, nonce, secretKey);
return calculatedSignature.equals(signature);
}
}
创建签名验证拦截器:
java
@Component
@Slf4j
public class SignatureInterceptor implements HandlerInterceptor {
private static final long ALLOWED_TIME_WINDOW = 5 * 60 * 1000; // 5分钟时间窗口
@Autowired
private SignatureUtils signatureUtils;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Value("${api.secret-key}")
private String secretKey;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (!handlerMethod.getMethod().isAnnotationPresent(CheckSignature.class)) {
return true;
}
try {
// 1. 获取请求头中的签名信息
String signature = request.getHeader("X-Signature");
String timestampStr = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String appId = request.getHeader("X-App-Id");
if (StringUtils.isEmpty(signature) || StringUtils.isEmpty(timestampStr) ||
StringUtils.isEmpty(nonce) || StringUtils.isEmpty(appId)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Missing signature parameters");
return false;
}
// 2. 检查时间戳是否在允许的时间窗口内
long timestamp = Long.parseLong(timestampStr);
long currentTime = System.currentTimeMillis();
if (currentTime - timestamp > ALLOWED_TIME_WINDOW || timestamp > currentTime) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Request expired or invalid timestamp");
return false;
}
// 3. 检查nonce是否已使用
String nonceKey = "signature_nonce:" + nonce;
Boolean isFirstUse = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", ALLOWED_TIME_WINDOW, TimeUnit.MILLISECONDS);
if (isFirstUse == null || !isFirstUse) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Duplicate request or replay attack detected");
log.warn("Duplicate nonce detected: {}", nonce);
return false;
}
// 4. 获取请求参数
Map<String, String> params = new HashMap<>();
// 从请求体或URL参数中获取参数...
if (request.getMethod().equals("GET")) {
request.getParameterMap().forEach((key, values) -> {
if (values != null && values.length > 0) {
params.put(key, values[0]);
}
});
} else {
// 解析请求体,这里简化处理
String requestBody = getRequestBody(request);
if (StringUtils.isNotEmpty(requestBody)) {
try {
Map<String, Object> bodyMap = new ObjectMapper().readValue(requestBody, Map.class);
bodyMap.forEach((key, value) -> {
if (value != null) {
params.put(key, value.toString());
}
});
} catch (Exception e) {
log.error("Failed to parse request body", e);
}
}
}
// 5. 验证签名
boolean isValid = signatureUtils.verifySignature(params, timestamp, nonce, signature, secretKey);
if (!isValid) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Invalid signature");
log.warn("Invalid signature detected for appId: {}", appId);
return false;
}
return true;
} catch (Exception e) {
log.error("Signature verification error", e);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Signature verification failed");
return false;
}
}
// getRequestBody方法同上
}
创建签名验证注解:
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckSignature {
}
在需要签名验证的API上使用注解:
less
@RestController
@RequestMapping("/api/secure")
public class SecureApiController {
@GetMapping("/data")
@CheckSignature
public ResponseEntity<?> getSecureData() {
// 处理安全数据
return ResponseEntity.ok(Map.of("data", "Secure data"));
}
@PostMapping("/transaction")
@CheckSignature
public ResponseEntity<?> processTransaction(@RequestBody TransactionRequest request) {
// 处理交易
return ResponseEntity.ok(Map.of("result", "success"));
}
}
5.3 客户端签名示例
arduino
public class ApiClient {
private static final String APP_ID = "your-app-id";
private static final String SECRET_KEY = "your-secret-key";
public static <T> String callSecureApi(String url, T requestBody, HttpMethod method) throws Exception {
// 1. 准备签名参数
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString();
// 2. 请求参数转换为Map
Map<String, String> params = new HashMap<>();
if (requestBody != null) {
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(requestBody);
Map<String, Object> map = mapper.readValue(json, Map.class);
map.forEach((key, value) -> {
if (value != null) {
params.put(key, value.toString());
}
});
}
// 3. 生成签名
String signature = generateSignature(params, timestamp, nonce, SECRET_KEY);
// 4. 发起请求
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestMethod(method.name());
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("X-App-Id", APP_ID);
connection.setRequestProperty("X-Timestamp", String.valueOf(timestamp));
connection.setRequestProperty("X-Nonce", nonce);
connection.setRequestProperty("X-Signature", signature);
// 设置请求体...
// 获取响应...
return "Response";
}
private static String generateSignature(Map<String, String> params, long timestamp, String nonce, String secretKey) {
// 实现与服务端相同的签名算法
// ...
}
}
5.4 优缺点分析
优点:
- 安全性高,可以同时防止重放攻击和请求篡改
- 客户端无需事先获取token,减少交互
- 支持多种请求方式(GET/POST等)
- 适合第三方API调用场景
缺点:
- 实现复杂,客户端和服务端需要一致的签名算法
- 调试困难,签名错误不易排查
- 需要安全地管理密钥
- 计算签名有一定性能开销
六、分布式锁防重复提交
6.1 基本原理
分布式锁是一种常用的并发控制机制,可以用来防止重复提交。
当收到请求时,系统尝试获取一个基于请求特征(如用户ID+操作类型)的分布式锁,如果获取成功则处理请求,否则拒绝请求。
这种方式特别适合防止用户在短时间内多次点击提交按钮导致的重复提交问题。
6.2 SpringBoot实现
首先,添加Redis依赖并配置Redisson客户端:
scss
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient(RedisProperties redisProperties) {
Config config = new Config();
String redisUrl = String.format("redis://%s:%d", redisProperties.getHost(), redisProperties.getPort());
config.useSingleServer()
.setAddress(redisUrl)
.setPassword(redisProperties.getPassword())
.setDatabase(redisProperties.getDatabase());
return Redisson.create(config);
}
}
创建分布式锁服务:
java
@Service
@Slf4j
public class DistributedLockService {
@Autowired
private RedissonClient redissonClient;
/**
* 尝试获取锁
* @param lockKey 锁的键
* @param waitTime 等待获取锁的最长时间
* @param leaseTime 持有锁的时间
* @return 是否获取到锁
*/
public boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) {
RLock lock = redissonClient.getLock(lockKey);
try {
return lock.tryLock(waitTime, leaseTime, unit);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("Thread interrupted while trying to acquire lock", e);
return false;
}
}
/**
* 释放锁
*/
public void unlock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
创建防重复提交注解:
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
/**
* 锁的前缀
*/
String prefix() default "duplicate_check:";
/**
* 等待获取锁的时间(毫秒)
*/
long waitTime() default 0;
/**
* 持有锁的时间(毫秒)
*/
long leaseTime() default 5000;
/**
* 锁的Key的SpEL表达式
*/
String key() default "";
}
创建防重复提交切面:
ini
@Aspect
@Component
@Slf4j
public class DuplicateSubmitAspect {
@Autowired
private DistributedLockService lockService;
@Around("@annotation(preventDuplicateSubmit)")
public Object around(ProceedingJoinPoint point, PreventDuplicateSubmit preventDuplicateSubmit) throws Throwable {
// 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return point.proceed();
}
HttpServletRequest request = attributes.getRequest();
String requestURI = request.getRequestURI();
String requestMethod = request.getMethod();
// 获取当前用户ID(实际项目中应从认证信息获取)
String userId = getUserId(request);
// 构建锁的key
String lockKey;
if (StringUtils.isNotEmpty(preventDuplicateSubmit.key())) {
// 使用SpEL表达式解析key
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariable("request", request);
// 添加方法参数到上下文
MethodSignature signature = (MethodSignature) point.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = point.getArgs();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
Expression expression = new SpelExpressionParser().parseExpression(preventDuplicateSubmit.key());
lockKey = preventDuplicateSubmit.prefix() + expression.getValue(context, String.class);
} else {
// 默认使用用户ID + URI + 方法作为锁key
lockKey = preventDuplicateSubmit.prefix() + userId + ":" + requestURI + ":" + requestMethod;
}
// 尝试获取锁
boolean locked = lockService.tryLock(lockKey, preventDuplicateSubmit.waitTime(),
preventDuplicateSubmit.leaseTime(), TimeUnit.MILLISECONDS);
if (!locked) {
log.warn("Duplicate submit detected. userId: {}, uri: {}", userId, requestURI);
throw new DuplicateSubmitException("请勿重复提交");
}
try {
// 执行实际方法
return point.proceed();
} finally {
// 释放锁
lockService.unlock(lockKey);
}
}
private String getUserId(HttpServletRequest request) {
// 实际项目中应从认证信息获取用户ID
// 这里简化处理,从请求头或会话中获取
String userId = request.getHeader("X-User-Id");
if (StringUtils.isEmpty(userId)) {
// 如果请求头中没有,尝试从会话中获取
HttpSession session = request.getSession(false);
if (session != null) {
Object userObj = session.getAttribute("user");
if (userObj != null) {
// 假设user对象有getId方法
// userId = ((User) userObj).getId();
userId = "demo-user";
}
}
}
// 如果仍然没有用户ID,使用IP地址作为标识
if (StringUtils.isEmpty(userId)) {
userId = request.getRemoteAddr();
}
return userId;
}
}
创建异常类:
scala
public class DuplicateSubmitException extends RuntimeException {
public DuplicateSubmitException(String message) {
super(message);
}
}
创建全局异常处理:
typescript
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateSubmitException.class)
public ResponseEntity<Map<String, String>> handleDuplicateSubmitException(DuplicateSubmitException e) {
Map<String, String> error = new HashMap<>();
error.put("error", "duplicate_submit");
error.put("message", e.getMessage());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(error);
}
}
在需要防重复提交的接口上使用注解:
less
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
@PreventDuplicateSubmit(leaseTime = 5000)
public ResponseEntity<?> submitOrder(@RequestBody OrderRequest request) {
// 处理订单提交
return ResponseEntity.ok(Map.of("orderId", "123456"));
}
@PostMapping("/complex")
@PreventDuplicateSubmit(key = "#request.productId + ':' + #request.userId")
public ResponseEntity<?> submitComplexOrder(@RequestBody OrderRequest request) {
// 处理复杂订单提交
return ResponseEntity.ok(Map.of("orderId", "123456"));
}
}
6.3 优缺点分析
优点:
- 可以有效防止短时间内的重复提交
- 支持基于业务属性的锁定,灵活性高
- 使用简单,只需添加注解
- 可以与业务逻辑解耦
缺点:
- 依赖外部分布式锁系统
- 锁的粒度和超时时间需要仔细设计
- 可能导致正常请求被误判为重复提交
- 需要正确处理锁的释放,避免死锁
七、方案对比
防重放方案 | 安全级别 | 实现复杂度 | 性能影响 | 分布式支持 | 客户端配合 | 适用场景 |
---|---|---|---|---|---|---|
时间戳+超时机制 | 低 | 简单 | 低 | 好 | 简单 | 一般API,低安全需求 |
Nonce+Redis缓存 | 高 | 中等 | 中 | 好 | 中等 | 安全敏感API |
幂等性令牌机制 | 高 | 中等 | 中 | 好 | 复杂 | 非幂等操作,如支付 |
请求签名认证 | 极高 | 复杂 | 中高 | 好 | 复杂 | 第三方API,金融接口 |
分布式锁防重复提交 | 中 | 中等 | 中 | 好 | 无需 | 表单提交,用户操作 |
八、总结
在实际应用中,往往需要组合使用多种防重放策略,实施分层防护,并与业务逻辑紧密结合,才能构建出既安全又易用的系统。
防重放攻击只是Web安全的一个方面,还应关注其他安全威胁,如XSS、CSRF、SQL注入等,综合提升系统的安全性。