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;
|
||||
|
||||
import com.test.bijihoudaun.interceptor.CaptchaValidationInterceptor;
|
||||
import com.test.bijihoudaun.interceptor.RateLimitInterceptor;
|
||||
import com.test.bijihoudaun.interceptor.ReplayAttackInterceptor;
|
||||
import com.test.bijihoudaun.interceptor.XSSInterceptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
@@ -11,6 +14,9 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Autowired
|
||||
private CaptchaValidationInterceptor captchaValidationInterceptor;
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
@@ -29,13 +35,26 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 限流拦截器(最先执行)
|
||||
registry.addInterceptor(new RateLimitInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.order(1);
|
||||
|
||||
|
||||
// 防重放攻击拦截器
|
||||
registry.addInterceptor(new ReplayAttackInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||
.order(2);
|
||||
|
||||
// XSS 过滤拦截器(不再排除 Markdown 接口,但图片上传不过滤)
|
||||
registry.addInterceptor(new XSSInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/api/markdown/**", "/api/images/**", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||
.order(2);
|
||||
.excludePathPatterns("/api/images/upload", "/doc.html", "/webjars/**", "/v3/api-docs/**")
|
||||
.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;
|
||||
|
||||
import com.test.bijihoudaun.annotation.RequireCaptcha;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
import com.test.bijihoudaun.entity.TrashItemVo;
|
||||
import com.test.bijihoudaun.service.TrashService;
|
||||
@@ -32,6 +33,7 @@ public class TrashController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/permanently/{type}/{id}")
|
||||
@RequireCaptcha("永久删除")
|
||||
@Operation(summary = "永久删除项目")
|
||||
public R<Void> permanentlyDeleteItem(@PathVariable String type, @PathVariable String id) {
|
||||
trashService.permanentlyDeleteItem(id, type);
|
||||
@@ -39,6 +41,7 @@ public class TrashController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/clean")
|
||||
@RequireCaptcha("清空回收站")
|
||||
@Operation(summary = "清空回收站")
|
||||
public R<Void> cleanTrash() {
|
||||
trashService.cleanTrash();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.test.bijihoudaun.controller;
|
||||
|
||||
import com.test.bijihoudaun.annotation.RequireCaptcha;
|
||||
import com.test.bijihoudaun.bo.UpdatePasswordBo;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.test.bijihoudaun.common.response.R;
|
||||
@@ -84,6 +85,7 @@ public class UserController {
|
||||
}
|
||||
|
||||
@Operation(summary = "删除当前登录的用户")
|
||||
@RequireCaptcha("删除账号")
|
||||
@DeleteMapping("/deleteUser")
|
||||
public R<String> deleteUser(){
|
||||
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
@@ -105,6 +107,7 @@ public class UserController {
|
||||
}
|
||||
|
||||
@Operation(summary = "更新用户密码")
|
||||
@RequireCaptcha("修改密码")
|
||||
@PutMapping("/password")
|
||||
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
|
||||
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 jakarta.servlet.http.HttpServletRequest;
|
||||
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 java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 限流拦截器 - 支持按 IP 和按用户双重限流
|
||||
*/
|
||||
public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
|
||||
// 普通接口限流配置
|
||||
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_USER = 10;
|
||||
// 时间窗口(毫秒)
|
||||
private static final long WINDOW_SIZE_MS = 60_000;
|
||||
|
||||
private final Map<String, RequestCounter> requestCounters = new ConcurrentHashMap<>();
|
||||
private final Map<String, RequestCounter> loginCounters = new ConcurrentHashMap<>();
|
||||
// IP 级别限流
|
||||
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 {
|
||||
AtomicInteger count;
|
||||
@@ -48,26 +62,81 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
String clientIp = getClientIp(request);
|
||||
String requestUri = request.getRequestURI();
|
||||
|
||||
String username = getCurrentUsername();
|
||||
|
||||
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;
|
||||
|
||||
RequestCounter counter = counters.computeIfAbsent(clientIp, k -> new RequestCounter());
|
||||
|
||||
if (!counter.incrementAndCheck(maxRequests)) {
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.setStatus(429);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
response.getWriter().write(mapper.writeValueAsString(
|
||||
R.fail(ResultCode.FAILED.getCode(), isLoginRequest ? "登录请求过于频繁,请稍后再试" : "请求过于频繁,请稍后再试")
|
||||
));
|
||||
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.setStatus(429);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
response.getWriter().write(mapper.writeValueAsString(
|
||||
R.fail(ResultCode.FAILED.getCode(), message)
|
||||
));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
|
||||
@@ -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.http.HtmlUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* XSS 过滤拦截器
|
||||
* 使用 HttpServletRequestWrapper 真正替换请求参数中的 XSS 内容
|
||||
*/
|
||||
public class XSSInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
// 清理请求参数中的XSS内容
|
||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
parameterMap.forEach((key, values) -> {
|
||||
String[] newValues = Arrays.stream(values)
|
||||
.map(v -> StrUtil.isBlank(v) ? v : HtmlUtil.filter(v))
|
||||
.toArray(String[]::new);
|
||||
request.setAttribute("filtered_" + key, newValues);
|
||||
});
|
||||
// 包装请求,过滤 XSS
|
||||
XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request);
|
||||
// 将包装后的请求设置到属性中,供后续使用
|
||||
request.setAttribute("XSS_FILTERED_REQUEST", wrappedRequest);
|
||||
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.service.UserService;
|
||||
import com.test.bijihoudaun.util.JwtTokenUtil;
|
||||
import com.test.bijihoudaun.util.LoginLockUtil;
|
||||
import com.test.bijihoudaun.util.PasswordUtils;
|
||||
import com.test.bijihoudaun.util.UuidV7;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -86,11 +87,32 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
|
||||
@Override
|
||||
public String login(String username, String password) {
|
||||
UserDetails userDetails = loadUserByUsername(username);
|
||||
if (!PasswordUtils.verify(password, userDetails.getPassword())) {
|
||||
// 检查账号是否被锁定
|
||||
if (LoginLockUtil.isLocked(username)) {
|
||||
long remainingSeconds = LoginLockUtil.getRemainingLockTime(username);
|
||||
throw new BadCredentialsException("账号已被锁定,请" + (remainingSeconds / 60 + 1) + "分钟后重试");
|
||||
}
|
||||
|
||||
try {
|
||||
UserDetails userDetails = loadUserByUsername(username);
|
||||
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("用户名或密码错误");
|
||||
}
|
||||
return jwtTokenUtil.generateToken(userDetails);
|
||||
}
|
||||
|
||||
@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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ const initVditor = () => {
|
||||
if (vditor.value) {
|
||||
vditor.value.destroy();
|
||||
}
|
||||
|
||||
|
||||
vditor.value = new Vditor('vditor-editor', {
|
||||
height: 'calc(100vh - 120px)',
|
||||
mode: 'ir',
|
||||
|
||||
Reference in New Issue
Block a user