feat(安全): 添加验证码和登录安全增强功能
新增验证码功能用于敏感操作,包括删除账号、修改密码等 添加登录失败锁定机制和限流策略 实现防重放攻击和XSS防护增强 重构XSS拦截器使用请求包装器
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
package com.test.bijihoudaun.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记需要验证码验证的接口
|
||||||
|
* 用于敏感操作(如删除账号、修改密码等)
|
||||||
|
*/
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface RequireCaptcha {
|
||||||
|
/**
|
||||||
|
* 验证码用途描述
|
||||||
|
*/
|
||||||
|
String value() default "";
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.test.bijihoudaun.config;
|
package com.test.bijihoudaun.config;
|
||||||
|
|
||||||
|
import com.test.bijihoudaun.interceptor.CaptchaValidationInterceptor;
|
||||||
import com.test.bijihoudaun.interceptor.RateLimitInterceptor;
|
import com.test.bijihoudaun.interceptor.RateLimitInterceptor;
|
||||||
|
import com.test.bijihoudaun.interceptor.ReplayAttackInterceptor;
|
||||||
import com.test.bijihoudaun.interceptor.XSSInterceptor;
|
import com.test.bijihoudaun.interceptor.XSSInterceptor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
@@ -11,6 +14,9 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private CaptchaValidationInterceptor captchaValidationInterceptor;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
@@ -29,13 +35,26 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// 限流拦截器(最先执行)
|
||||||
registry.addInterceptor(new RateLimitInterceptor())
|
registry.addInterceptor(new RateLimitInterceptor())
|
||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.order(1);
|
.order(1);
|
||||||
|
|
||||||
|
// 防重放攻击拦截器
|
||||||
|
registry.addInterceptor(new ReplayAttackInterceptor())
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns("/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||||
|
.order(2);
|
||||||
|
|
||||||
|
// XSS 过滤拦截器(不再排除 Markdown 接口,但图片上传不过滤)
|
||||||
registry.addInterceptor(new XSSInterceptor())
|
registry.addInterceptor(new XSSInterceptor())
|
||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns("/api/markdown/**", "/api/images/**", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
.excludePathPatterns("/api/images/upload", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||||
.order(2);
|
.order(3);
|
||||||
|
|
||||||
|
// 验证码验证拦截器
|
||||||
|
registry.addInterceptor(captchaValidationInterceptor)
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.order(4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.test.bijihoudaun.controller;
|
||||||
|
|
||||||
|
import com.test.bijihoudaun.common.response.R;
|
||||||
|
import com.test.bijihoudaun.util.CaptchaUtil;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码接口
|
||||||
|
*/
|
||||||
|
@Tag(name = "验证码接口")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/captcha")
|
||||||
|
public class CaptchaController {
|
||||||
|
|
||||||
|
@Operation(summary = "获取图形验证码")
|
||||||
|
@GetMapping("/generate")
|
||||||
|
public R<Map<String, String>> generateCaptcha() {
|
||||||
|
CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha();
|
||||||
|
|
||||||
|
Map<String, String> data = new HashMap<>();
|
||||||
|
data.put("captchaId", result.getCaptchaId());
|
||||||
|
data.put("captchaImage", result.getBase64Image());
|
||||||
|
|
||||||
|
return R.success(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.test.bijihoudaun.controller;
|
package com.test.bijihoudaun.controller;
|
||||||
|
|
||||||
|
import com.test.bijihoudaun.annotation.RequireCaptcha;
|
||||||
import com.test.bijihoudaun.common.response.R;
|
import com.test.bijihoudaun.common.response.R;
|
||||||
import com.test.bijihoudaun.entity.TrashItemVo;
|
import com.test.bijihoudaun.entity.TrashItemVo;
|
||||||
import com.test.bijihoudaun.service.TrashService;
|
import com.test.bijihoudaun.service.TrashService;
|
||||||
@@ -32,6 +33,7 @@ public class TrashController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/permanently/{type}/{id}")
|
@DeleteMapping("/permanently/{type}/{id}")
|
||||||
|
@RequireCaptcha("永久删除")
|
||||||
@Operation(summary = "永久删除项目")
|
@Operation(summary = "永久删除项目")
|
||||||
public R<Void> permanentlyDeleteItem(@PathVariable String type, @PathVariable String id) {
|
public R<Void> permanentlyDeleteItem(@PathVariable String type, @PathVariable String id) {
|
||||||
trashService.permanentlyDeleteItem(id, type);
|
trashService.permanentlyDeleteItem(id, type);
|
||||||
@@ -39,6 +41,7 @@ public class TrashController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/clean")
|
@DeleteMapping("/clean")
|
||||||
|
@RequireCaptcha("清空回收站")
|
||||||
@Operation(summary = "清空回收站")
|
@Operation(summary = "清空回收站")
|
||||||
public R<Void> cleanTrash() {
|
public R<Void> cleanTrash() {
|
||||||
trashService.cleanTrash();
|
trashService.cleanTrash();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.test.bijihoudaun.controller;
|
package com.test.bijihoudaun.controller;
|
||||||
|
|
||||||
|
import com.test.bijihoudaun.annotation.RequireCaptcha;
|
||||||
import com.test.bijihoudaun.bo.UpdatePasswordBo;
|
import com.test.bijihoudaun.bo.UpdatePasswordBo;
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import com.test.bijihoudaun.common.response.R;
|
import com.test.bijihoudaun.common.response.R;
|
||||||
@@ -84,6 +85,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除当前登录的用户")
|
@Operation(summary = "删除当前登录的用户")
|
||||||
|
@RequireCaptcha("删除账号")
|
||||||
@DeleteMapping("/deleteUser")
|
@DeleteMapping("/deleteUser")
|
||||||
public R<String> deleteUser(){
|
public R<String> deleteUser(){
|
||||||
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
@@ -105,6 +107,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新用户密码")
|
@Operation(summary = "更新用户密码")
|
||||||
|
@RequireCaptcha("修改密码")
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
|
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
|
||||||
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.test.bijihoudaun.interceptor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.test.bijihoudaun.annotation.RequireCaptcha;
|
||||||
|
import com.test.bijihoudaun.common.response.R;
|
||||||
|
import com.test.bijihoudaun.common.response.ResultCode;
|
||||||
|
import com.test.bijihoudaun.util.CaptchaUtil;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码验证拦截器
|
||||||
|
* 拦截标记了 @RequireCaptcha 的方法,验证验证码
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CaptchaValidationInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler) throws Exception {
|
||||||
|
// 只处理方法请求
|
||||||
|
if (!(handler instanceof HandlerMethod)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
HandlerMethod handlerMethod = (HandlerMethod) handler;
|
||||||
|
RequireCaptcha requireCaptcha = handlerMethod.getMethodAnnotation(RequireCaptcha.class);
|
||||||
|
|
||||||
|
// 没有注解,不需要验证
|
||||||
|
if (requireCaptcha == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取验证码ID和用户输入
|
||||||
|
String captchaId = request.getHeader("X-Captcha-Id");
|
||||||
|
String captchaCode = request.getHeader("X-Captcha-Code");
|
||||||
|
|
||||||
|
// 检查参数是否存在
|
||||||
|
if (captchaId == null || captchaId.isEmpty() || captchaCode == null || captchaCode.isEmpty()) {
|
||||||
|
writeErrorResponse(response, "请提供验证码");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证验证码
|
||||||
|
if (!CaptchaUtil.validateCaptcha(captchaId, captchaCode)) {
|
||||||
|
writeErrorResponse(response, "验证码错误或已过期");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入错误响应
|
||||||
|
*/
|
||||||
|
private void writeErrorResponse(HttpServletResponse response, String message) throws Exception {
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.setStatus(400);
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
response.getWriter().write(mapper.writeValueAsString(
|
||||||
|
R.fail(ResultCode.FAILED.getCode(), message)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,34 @@ import com.test.bijihoudaun.common.response.R;
|
|||||||
import com.test.bijihoudaun.common.response.ResultCode;
|
import com.test.bijihoudaun.common.response.ResultCode;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限流拦截器 - 支持按 IP 和按用户双重限流
|
||||||
|
*/
|
||||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
// 普通接口限流配置
|
||||||
private static final int MAX_REQUESTS_PER_MINUTE = 60;
|
private static final int MAX_REQUESTS_PER_MINUTE = 60;
|
||||||
|
private static final int MAX_REQUESTS_PER_MINUTE_USER = 100;
|
||||||
|
// 登录接口限流配置
|
||||||
private static final int MAX_LOGIN_REQUESTS_PER_MINUTE = 5;
|
private static final int MAX_LOGIN_REQUESTS_PER_MINUTE = 5;
|
||||||
|
private static final int MAX_LOGIN_REQUESTS_PER_MINUTE_USER = 10;
|
||||||
|
// 时间窗口(毫秒)
|
||||||
private static final long WINDOW_SIZE_MS = 60_000;
|
private static final long WINDOW_SIZE_MS = 60_000;
|
||||||
|
|
||||||
private final Map<String, RequestCounter> requestCounters = new ConcurrentHashMap<>();
|
// IP 级别限流
|
||||||
private final Map<String, RequestCounter> loginCounters = new ConcurrentHashMap<>();
|
private final Map<String, RequestCounter> ipCounters = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, RequestCounter> ipLoginCounters = new ConcurrentHashMap<>();
|
||||||
|
// 用户级别限流
|
||||||
|
private final Map<String, RequestCounter> userCounters = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, RequestCounter> userLoginCounters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private static class RequestCounter {
|
private static class RequestCounter {
|
||||||
AtomicInteger count;
|
AtomicInteger count;
|
||||||
@@ -48,24 +62,79 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
String clientIp = getClientIp(request);
|
String clientIp = getClientIp(request);
|
||||||
String requestUri = request.getRequestURI();
|
String requestUri = request.getRequestURI();
|
||||||
|
String username = getCurrentUsername();
|
||||||
|
|
||||||
boolean isLoginRequest = "/api/user/login".equals(requestUri);
|
boolean isLoginRequest = "/api/user/login".equals(requestUri);
|
||||||
Map<String, RequestCounter> counters = isLoginRequest ? loginCounters : requestCounters;
|
|
||||||
|
// 1. 检查 IP 限流
|
||||||
|
if (!checkIpLimit(clientIp, isLoginRequest, response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查用户限流(仅对已登录用户)
|
||||||
|
if (username != null && !checkUserLimit(username, isLoginRequest, response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 IP 级别限流
|
||||||
|
*/
|
||||||
|
private boolean checkIpLimit(String clientIp, boolean isLoginRequest,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
Map<String, RequestCounter> counters = isLoginRequest ? ipLoginCounters : ipCounters;
|
||||||
int maxRequests = isLoginRequest ? MAX_LOGIN_REQUESTS_PER_MINUTE : MAX_REQUESTS_PER_MINUTE;
|
int maxRequests = isLoginRequest ? MAX_LOGIN_REQUESTS_PER_MINUTE : MAX_REQUESTS_PER_MINUTE;
|
||||||
|
|
||||||
RequestCounter counter = counters.computeIfAbsent(clientIp, k -> new RequestCounter());
|
RequestCounter counter = counters.computeIfAbsent(clientIp, k -> new RequestCounter());
|
||||||
|
|
||||||
if (!counter.incrementAndCheck(maxRequests)) {
|
if (!counter.incrementAndCheck(maxRequests)) {
|
||||||
|
writeRateLimitResponse(response, "请求过于频繁,请稍后再试");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户级别限流
|
||||||
|
*/
|
||||||
|
private boolean checkUserLimit(String username, boolean isLoginRequest,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
Map<String, RequestCounter> counters = isLoginRequest ? userLoginCounters : userCounters;
|
||||||
|
int maxRequests = isLoginRequest ? MAX_LOGIN_REQUESTS_PER_MINUTE_USER : MAX_REQUESTS_PER_MINUTE_USER;
|
||||||
|
|
||||||
|
RequestCounter counter = counters.computeIfAbsent(username, k -> new RequestCounter());
|
||||||
|
|
||||||
|
if (!counter.incrementAndCheck(maxRequests)) {
|
||||||
|
writeRateLimitResponse(response, "您的操作过于频繁,请稍后再试");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户名
|
||||||
|
*/
|
||||||
|
private String getCurrentUsername() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication != null && authentication.isAuthenticated()
|
||||||
|
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||||
|
return authentication.getName();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入限流响应
|
||||||
|
*/
|
||||||
|
private void writeRateLimitResponse(HttpServletResponse response, String message) throws Exception {
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
response.setStatus(429);
|
response.setStatus(429);
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
response.getWriter().write(mapper.writeValueAsString(
|
response.getWriter().write(mapper.writeValueAsString(
|
||||||
R.fail(ResultCode.FAILED.getCode(), isLoginRequest ? "登录请求过于频繁,请稍后再试" : "请求过于频繁,请稍后再试")
|
R.fail(ResultCode.FAILED.getCode(), message)
|
||||||
));
|
));
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getClientIp(HttpServletRequest request) {
|
private String getClientIp(HttpServletRequest request) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.test.bijihoudaun.interceptor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.test.bijihoudaun.common.response.R;
|
||||||
|
import com.test.bijihoudaun.common.response.ResultCode;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防重放攻击拦截器
|
||||||
|
* 通过时间戳和 nonce 机制防止请求被截获重放
|
||||||
|
*/
|
||||||
|
public class ReplayAttackInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
// 请求时间戳有效期(毫秒):5分钟
|
||||||
|
private static final long TIMESTAMP_VALIDITY = 5 * 60 * 1000;
|
||||||
|
// nonce 有效期(毫秒):10分钟
|
||||||
|
private static final long NONCE_EXPIRE_TIME = 10 * 60 * 1000;
|
||||||
|
// 用于不需要验证的路径
|
||||||
|
private static final Set<String> EXCLUDE_PATHS = new HashSet<>(Arrays.asList(
|
||||||
|
"/api/user/login",
|
||||||
|
"/api/user/register",
|
||||||
|
"/api/system/registration/status"
|
||||||
|
));
|
||||||
|
|
||||||
|
// 存储已使用的 nonce:key=nonce,value=使用时间戳
|
||||||
|
private static final Map<String, Long> usedNonces = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Object handler) throws Exception {
|
||||||
|
String requestUri = request.getRequestURI();
|
||||||
|
|
||||||
|
// 排除不需要验证的路径
|
||||||
|
if (EXCLUDE_PATHS.contains(requestUri)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 请求不需要验证(幂等操作)
|
||||||
|
if ("GET".equalsIgnoreCase(request.getMethod())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期的 nonce
|
||||||
|
cleanupExpiredNonces();
|
||||||
|
|
||||||
|
// 获取请求头中的时间戳和 nonce
|
||||||
|
String timestampStr = request.getHeader("X-Timestamp");
|
||||||
|
String nonce = request.getHeader("X-Nonce");
|
||||||
|
|
||||||
|
// 检查参数是否存在
|
||||||
|
if (timestampStr == null || timestampStr.isEmpty() || nonce == null || nonce.isEmpty()) {
|
||||||
|
writeErrorResponse(response, "缺少安全验证参数");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证时间戳
|
||||||
|
long timestamp;
|
||||||
|
try {
|
||||||
|
timestamp = Long.parseLong(timestampStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
writeErrorResponse(response, "无效的时间戳参数");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (Math.abs(now - timestamp) > TIMESTAMP_VALIDITY) {
|
||||||
|
writeErrorResponse(response, "请求已过期,请重新发起请求");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 nonce 是否已被使用
|
||||||
|
if (usedNonces.containsKey(nonce)) {
|
||||||
|
writeErrorResponse(response, "检测到重放攻击,请求已被拒绝");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录 nonce
|
||||||
|
usedNonces.put(nonce, now);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的 nonce
|
||||||
|
*/
|
||||||
|
private void cleanupExpiredNonces() {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
usedNonces.entrySet().removeIf(entry ->
|
||||||
|
(now - entry.getValue()) > NONCE_EXPIRE_TIME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入错误响应
|
||||||
|
*/
|
||||||
|
private void writeErrorResponse(HttpServletResponse response, String message) throws Exception {
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.setStatus(400);
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
response.getWriter().write(mapper.writeValueAsString(
|
||||||
|
R.fail(ResultCode.FAILED.getCode(), message)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,82 @@ package com.test.bijihoudaun.interceptor;
|
|||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.http.HtmlUtil;
|
import cn.hutool.http.HtmlUtil;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.web.servlet.HandlerInterceptor;
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XSS 过滤拦截器
|
||||||
|
* 使用 HttpServletRequestWrapper 真正替换请求参数中的 XSS 内容
|
||||||
|
*/
|
||||||
public class XSSInterceptor implements HandlerInterceptor {
|
public class XSSInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
// 清理请求参数中的XSS内容
|
// 包装请求,过滤 XSS
|
||||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request);
|
||||||
parameterMap.forEach((key, values) -> {
|
// 将包装后的请求设置到属性中,供后续使用
|
||||||
String[] newValues = Arrays.stream(values)
|
request.setAttribute("XSS_FILTERED_REQUEST", wrappedRequest);
|
||||||
.map(v -> StrUtil.isBlank(v) ? v : HtmlUtil.filter(v))
|
|
||||||
.toArray(String[]::new);
|
|
||||||
request.setAttribute("filtered_" + key, newValues);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XSS 请求包装器
|
||||||
|
*/
|
||||||
|
public static class XSSRequestWrapper extends HttpServletRequestWrapper {
|
||||||
|
|
||||||
|
public XSSRequestWrapper(HttpServletRequest request) {
|
||||||
|
super(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getParameter(String name) {
|
||||||
|
String value = super.getParameter(name);
|
||||||
|
return filterXSS(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getParameterValues(String name) {
|
||||||
|
String[] values = super.getParameterValues(name);
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Arrays.stream(values)
|
||||||
|
.map(this::filterXSS)
|
||||||
|
.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, String[]> getParameterMap() {
|
||||||
|
Map<String, String[]> originalMap = super.getParameterMap();
|
||||||
|
Map<String, String[]> filteredMap = new HashMap<>();
|
||||||
|
for (Map.Entry<String, String[]> entry : originalMap.entrySet()) {
|
||||||
|
String[] filteredValues = Arrays.stream(entry.getValue())
|
||||||
|
.map(this::filterXSS)
|
||||||
|
.toArray(String[]::new);
|
||||||
|
filteredMap.put(entry.getKey(), filteredValues);
|
||||||
|
}
|
||||||
|
return filteredMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getHeader(String name) {
|
||||||
|
String value = super.getHeader(name);
|
||||||
|
return filterXSS(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤 XSS 内容
|
||||||
|
*/
|
||||||
|
private String filterXSS(String value) {
|
||||||
|
if (StrUtil.isBlank(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return HtmlUtil.filter(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.test.bijihoudaun.entity.User;
|
|||||||
import com.test.bijihoudaun.mapper.UserMapper;
|
import com.test.bijihoudaun.mapper.UserMapper;
|
||||||
import com.test.bijihoudaun.service.UserService;
|
import com.test.bijihoudaun.service.UserService;
|
||||||
import com.test.bijihoudaun.util.JwtTokenUtil;
|
import com.test.bijihoudaun.util.JwtTokenUtil;
|
||||||
|
import com.test.bijihoudaun.util.LoginLockUtil;
|
||||||
import com.test.bijihoudaun.util.PasswordUtils;
|
import com.test.bijihoudaun.util.PasswordUtils;
|
||||||
import com.test.bijihoudaun.util.UuidV7;
|
import com.test.bijihoudaun.util.UuidV7;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -86,11 +87,32 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String login(String username, String password) {
|
public String login(String username, String password) {
|
||||||
|
// 检查账号是否被锁定
|
||||||
|
if (LoginLockUtil.isLocked(username)) {
|
||||||
|
long remainingSeconds = LoginLockUtil.getRemainingLockTime(username);
|
||||||
|
throw new BadCredentialsException("账号已被锁定,请" + (remainingSeconds / 60 + 1) + "分钟后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
UserDetails userDetails = loadUserByUsername(username);
|
UserDetails userDetails = loadUserByUsername(username);
|
||||||
if (!PasswordUtils.verify(password, userDetails.getPassword())) {
|
if (!PasswordUtils.verify(password, userDetails.getPassword())) {
|
||||||
|
// 记录登录失败
|
||||||
|
LoginLockUtil.recordFailedAttempt(username);
|
||||||
|
int remainingAttempts = LoginLockUtil.getRemainingAttempts(username);
|
||||||
|
if (remainingAttempts <= 0) {
|
||||||
|
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
|
||||||
|
}
|
||||||
|
throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功,清除失败记录
|
||||||
|
LoginLockUtil.recordSuccess(username);
|
||||||
|
return jwtTokenUtil.generateToken(userDetails);
|
||||||
|
} catch (UsernameNotFoundException e) {
|
||||||
|
// 用户名不存在也记录失败(防止用户名枚举攻击)
|
||||||
|
LoginLockUtil.recordFailedAttempt(username);
|
||||||
throw new BadCredentialsException("用户名或密码错误");
|
throw new BadCredentialsException("用户名或密码错误");
|
||||||
}
|
}
|
||||||
return jwtTokenUtil.generateToken(userDetails);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package com.test.bijihoudaun.util;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图形验证码工具类
|
||||||
|
* 使用本地内存存储验证码
|
||||||
|
*/
|
||||||
|
public class CaptchaUtil {
|
||||||
|
|
||||||
|
// 验证码有效期(分钟)
|
||||||
|
private static final int CAPTCHA_EXPIRE_MINUTES = 5;
|
||||||
|
// 验证码长度
|
||||||
|
private static final int CAPTCHA_LENGTH = 4;
|
||||||
|
// 图片宽度
|
||||||
|
private static final int IMAGE_WIDTH = 120;
|
||||||
|
// 图片高度
|
||||||
|
private static final int IMAGE_HEIGHT = 40;
|
||||||
|
|
||||||
|
// 存储验证码:key=验证码ID,value=验证码记录
|
||||||
|
private static final Map<String, CaptchaRecord> captchaStore = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
// 安全随机数生成器
|
||||||
|
private static final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
private static class CaptchaRecord {
|
||||||
|
String code;
|
||||||
|
LocalDateTime createTime;
|
||||||
|
|
||||||
|
CaptchaRecord(String code) {
|
||||||
|
this.code = code;
|
||||||
|
this.createTime = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isExpired() {
|
||||||
|
return ChronoUnit.MINUTES.between(createTime, LocalDateTime.now()) > CAPTCHA_EXPIRE_MINUTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证码结果
|
||||||
|
*/
|
||||||
|
public static class CaptchaResult {
|
||||||
|
private String captchaId;
|
||||||
|
private String base64Image;
|
||||||
|
|
||||||
|
public CaptchaResult(String captchaId, String base64Image) {
|
||||||
|
this.captchaId = captchaId;
|
||||||
|
this.base64Image = base64Image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCaptchaId() {
|
||||||
|
return captchaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBase64Image() {
|
||||||
|
return base64Image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成验证码
|
||||||
|
* @return 包含验证码ID和Base64图片的结果
|
||||||
|
*/
|
||||||
|
public static CaptchaResult generateCaptcha() {
|
||||||
|
// 清理过期验证码
|
||||||
|
cleanupExpiredCaptchas();
|
||||||
|
|
||||||
|
// 生成验证码ID
|
||||||
|
String captchaId = UuidV7.generate().toString();
|
||||||
|
|
||||||
|
// 生成验证码字符
|
||||||
|
String code = generateCode();
|
||||||
|
|
||||||
|
// 生成图片
|
||||||
|
String base64Image = generateImage(code);
|
||||||
|
|
||||||
|
// 存储验证码
|
||||||
|
captchaStore.put(captchaId, new CaptchaRecord(code));
|
||||||
|
|
||||||
|
return new CaptchaResult(captchaId, base64Image);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证验证码
|
||||||
|
* @param captchaId 验证码ID
|
||||||
|
* @param code 用户输入的验证码
|
||||||
|
* @return 验证成功返回true,失败返回false
|
||||||
|
*/
|
||||||
|
public static boolean validateCaptcha(String captchaId, String code) {
|
||||||
|
if (captchaId == null || code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptchaRecord record = captchaStore.get(captchaId);
|
||||||
|
if (record == null || record.isExpired()) {
|
||||||
|
// 验证码不存在或已过期,移除
|
||||||
|
if (record != null) {
|
||||||
|
captchaStore.remove(captchaId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证码比对(不区分大小写)
|
||||||
|
boolean success = record.code.equalsIgnoreCase(code);
|
||||||
|
|
||||||
|
// 验证成功后立即删除(一次性使用)
|
||||||
|
if (success) {
|
||||||
|
captchaStore.remove(captchaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机验证码
|
||||||
|
*/
|
||||||
|
private static String generateCode() {
|
||||||
|
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
|
||||||
|
sb.append(chars.charAt(random.nextInt(chars.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成验证码图片(Base64格式)
|
||||||
|
*/
|
||||||
|
private static String generateImage(String code) {
|
||||||
|
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g = image.createGraphics();
|
||||||
|
|
||||||
|
// 设置背景色
|
||||||
|
g.setColor(Color.WHITE);
|
||||||
|
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||||
|
|
||||||
|
// 绘制干扰线
|
||||||
|
g.setColor(Color.LIGHT_GRAY);
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
int x1 = random.nextInt(IMAGE_WIDTH);
|
||||||
|
int y1 = random.nextInt(IMAGE_HEIGHT);
|
||||||
|
int x2 = random.nextInt(IMAGE_WIDTH);
|
||||||
|
int y2 = random.nextInt(IMAGE_HEIGHT);
|
||||||
|
g.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制验证码字符
|
||||||
|
g.setFont(new Font("Arial", Font.BOLD, 24));
|
||||||
|
int x = 15;
|
||||||
|
for (int i = 0; i < code.length(); i++) {
|
||||||
|
// 随机颜色
|
||||||
|
g.setColor(new Color(
|
||||||
|
random.nextInt(100),
|
||||||
|
random.nextInt(100),
|
||||||
|
random.nextInt(100)
|
||||||
|
));
|
||||||
|
// 随机旋转角度
|
||||||
|
int angle = random.nextInt(30) - 15;
|
||||||
|
g.rotate(Math.toRadians(angle), x + 10, 25);
|
||||||
|
g.drawString(String.valueOf(code.charAt(i)), x, 28);
|
||||||
|
g.rotate(-Math.toRadians(angle), x + 10, 25);
|
||||||
|
x += 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加噪点
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
int x1 = random.nextInt(IMAGE_WIDTH);
|
||||||
|
int y1 = random.nextInt(IMAGE_HEIGHT);
|
||||||
|
image.setRGB(x1, y1, Color.GRAY.getRGB());
|
||||||
|
}
|
||||||
|
|
||||||
|
g.dispose();
|
||||||
|
|
||||||
|
// 转为 Base64
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||||
|
ImageIO.write(image, "png", baos);
|
||||||
|
byte[] imageBytes = baos.toByteArray();
|
||||||
|
return "data:image/png;base64," + Base64.getEncoder().encodeToString(imageBytes);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("生成验证码图片失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期验证码
|
||||||
|
*/
|
||||||
|
private static void cleanupExpiredCaptchas() {
|
||||||
|
captchaStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.test.bijihoudaun.util;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录锁定工具类
|
||||||
|
* 使用本地内存存储登录失败记录
|
||||||
|
*/
|
||||||
|
public class LoginLockUtil {
|
||||||
|
|
||||||
|
// 最大失败次数
|
||||||
|
private static final int MAX_FAILED_ATTEMPTS = 5;
|
||||||
|
// 锁定时间(分钟)
|
||||||
|
private static final int LOCK_TIME_MINUTES = 30;
|
||||||
|
// 失败记录过期时间(分钟)
|
||||||
|
private static final int RECORD_EXPIRE_MINUTES = 60;
|
||||||
|
|
||||||
|
// 登录失败记录:key=用户名,value=失败记录
|
||||||
|
private static final ConcurrentHashMap<String, LoginAttempt> attempts = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static class LoginAttempt {
|
||||||
|
int failedCount;
|
||||||
|
LocalDateTime lastAttemptTime;
|
||||||
|
LocalDateTime lockUntil;
|
||||||
|
|
||||||
|
LoginAttempt() {
|
||||||
|
this.failedCount = 0;
|
||||||
|
this.lastAttemptTime = LocalDateTime.now();
|
||||||
|
this.lockUntil = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录登录失败
|
||||||
|
* @param username 用户名
|
||||||
|
*/
|
||||||
|
public static void recordFailedAttempt(String username) {
|
||||||
|
if (username == null || username.isEmpty()) return;
|
||||||
|
|
||||||
|
cleanupExpiredRecords();
|
||||||
|
|
||||||
|
LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt());
|
||||||
|
attempt.failedCount++;
|
||||||
|
attempt.lastAttemptTime = LocalDateTime.now();
|
||||||
|
|
||||||
|
// 达到最大失败次数,锁定账号
|
||||||
|
if (attempt.failedCount >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
attempt.lockUntil = LocalDateTime.now().plusMinutes(LOCK_TIME_MINUTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录登录成功(清除失败记录)
|
||||||
|
* @param username 用户名
|
||||||
|
*/
|
||||||
|
public static void recordSuccess(String username) {
|
||||||
|
if (username == null || username.isEmpty()) return;
|
||||||
|
attempts.remove(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查账号是否被锁定
|
||||||
|
* @param username 用户名
|
||||||
|
* @return true-已锁定,false-未锁定
|
||||||
|
*/
|
||||||
|
public static boolean isLocked(String username) {
|
||||||
|
if (username == null || username.isEmpty()) return false;
|
||||||
|
|
||||||
|
LoginAttempt attempt = attempts.get(username);
|
||||||
|
if (attempt == null) return false;
|
||||||
|
|
||||||
|
// 检查是否仍在锁定时间内
|
||||||
|
if (attempt.lockUntil != null) {
|
||||||
|
if (LocalDateTime.now().isBefore(attempt.lockUntil)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// 锁定时间已过,清除记录
|
||||||
|
attempts.remove(username);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取剩余锁定时间(秒)
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 剩余秒数,如果未锁定返回0
|
||||||
|
*/
|
||||||
|
public static long getRemainingLockTime(String username) {
|
||||||
|
if (username == null || username.isEmpty()) return 0;
|
||||||
|
|
||||||
|
LoginAttempt attempt = attempts.get(username);
|
||||||
|
if (attempt == null || attempt.lockUntil == null) return 0;
|
||||||
|
|
||||||
|
long remaining = ChronoUnit.SECONDS.between(LocalDateTime.now(), attempt.lockUntil);
|
||||||
|
return Math.max(0, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取剩余失败次数
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 剩余次数
|
||||||
|
*/
|
||||||
|
public static int getRemainingAttempts(String username) {
|
||||||
|
if (username == null || username.isEmpty()) return MAX_FAILED_ATTEMPTS;
|
||||||
|
|
||||||
|
LoginAttempt attempt = attempts.get(username);
|
||||||
|
if (attempt == null) return MAX_FAILED_ATTEMPTS;
|
||||||
|
|
||||||
|
return Math.max(0, MAX_FAILED_ATTEMPTS - attempt.failedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期记录
|
||||||
|
*/
|
||||||
|
private static void cleanupExpiredRecords() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
attempts.entrySet().removeIf(entry -> {
|
||||||
|
LoginAttempt attempt = entry.getValue();
|
||||||
|
// 未锁定且长时间没有登录的记录
|
||||||
|
if (attempt.lockUntil == null) {
|
||||||
|
return ChronoUnit.MINUTES.between(attempt.lastAttemptTime, now) > RECORD_EXPIRE_MINUTES;
|
||||||
|
}
|
||||||
|
// 锁定已过期的记录
|
||||||
|
return now.isAfter(attempt.lockUntil);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user