From 23929a974f00d83a78fb907e0d597bfa5d721e7e Mon Sep 17 00:00:00 2001 From: ikmkj Date: Tue, 3 Mar 2026 17:49:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AE=89=E5=85=A8):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E5=92=8C=E7=99=BB=E5=BD=95=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=A2=9E=E5=BC=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增验证码功能用于敏感操作,包括删除账号、修改密码等 添加登录失败锁定机制和限流策略 实现防重放攻击和XSS防护增强 重构XSS拦截器使用请求包装器 --- .../annotation/RequireCaptcha.java | 19 ++ .../test/bijihoudaun/config/WebConfig.java | 25 ++- .../controller/CaptchaController.java | 33 +++ .../controller/TrashController.java | 3 + .../controller/UserController.java | 3 + .../CaptchaValidationInterceptor.java | 67 ++++++ .../interceptor/RateLimitInterceptor.java | 91 +++++++- .../interceptor/ReplayAttackInterceptor.java | 112 ++++++++++ .../interceptor/XSSInterceptor.java | 74 ++++++- .../service/impl/UserServiceImpl.java | 28 ++- .../test/bijihoudaun/util/CaptchaUtil.java | 201 ++++++++++++++++++ .../test/bijihoudaun/util/LoginLockUtil.java | 131 ++++++++++++ .../src/components/home/NoteEditor.vue | 2 +- 13 files changed, 763 insertions(+), 26 deletions(-) create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/annotation/RequireCaptcha.java create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/CaptchaValidationInterceptor.java create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/annotation/RequireCaptcha.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/annotation/RequireCaptcha.java new file mode 100644 index 0000000..65db725 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/annotation/RequireCaptcha.java @@ -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 ""; +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java index 993d8d7..4812db1 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java @@ -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); } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java new file mode 100644 index 0000000..b64229f --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java @@ -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> generateCaptcha() { + CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha(); + + Map data = new HashMap<>(); + data.put("captchaId", result.getCaptchaId()); + data.put("captchaImage", result.getBase64Image()); + + return R.success(data); + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/TrashController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/TrashController.java index 5e53222..ff214d8 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/TrashController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/TrashController.java @@ -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 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 cleanTrash() { trashService.cleanTrash(); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java index 6da2b02..9d955e3 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java @@ -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 deleteUser(){ UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); @@ -105,6 +107,7 @@ public class UserController { } @Operation(summary = "更新用户密码") + @RequireCaptcha("修改密码") @PutMapping("/password") public R updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) { UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/CaptchaValidationInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/CaptchaValidationInterceptor.java new file mode 100644 index 0000000..bdbf3fa --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/CaptchaValidationInterceptor.java @@ -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) + )); + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java index 77c32a1..25d5a5c 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java @@ -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 requestCounters = new ConcurrentHashMap<>(); - private final Map loginCounters = new ConcurrentHashMap<>(); + // IP 级别限流 + private final Map ipCounters = new ConcurrentHashMap<>(); + private final Map ipLoginCounters = new ConcurrentHashMap<>(); + // 用户级别限流 + private final Map userCounters = new ConcurrentHashMap<>(); + private final Map 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 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 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 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)) { diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java new file mode 100644 index 0000000..5364367 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java @@ -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 EXCLUDE_PATHS = new HashSet<>(Arrays.asList( + "/api/user/login", + "/api/user/register", + "/api/system/registration/status" + )); + + // 存储已使用的 nonce:key=nonce,value=使用时间戳 + private static final Map 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) + )); + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java index 6f089eb..5cfb22f 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java @@ -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 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 getParameterMap() { + Map originalMap = super.getParameterMap(); + Map filteredMap = new HashMap<>(); + for (Map.Entry 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); + } + } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java index 6b884ed..906ff03 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java @@ -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 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 diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java new file mode 100644 index 0000000..3cd8775 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java @@ -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 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()); + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java new file mode 100644 index 0000000..51cd7dd --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java @@ -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 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); + }); + } +} diff --git a/biji-qianduan/src/components/home/NoteEditor.vue b/biji-qianduan/src/components/home/NoteEditor.vue index 7460b37..3a562ff 100644 --- a/biji-qianduan/src/components/home/NoteEditor.vue +++ b/biji-qianduan/src/components/home/NoteEditor.vue @@ -42,7 +42,7 @@ const initVditor = () => { if (vditor.value) { vditor.value.destroy(); } - + vditor.value = new Vditor('vditor-editor', { height: 'calc(100vh - 120px)', mode: 'ir',