feat(安全): 添加验证码和登录安全增强功能

新增验证码功能用于敏感操作,包括删除账号、修改密码等
添加登录失败锁定机制和限流策略
实现防重放攻击和XSS防护增强
重构XSS拦截器使用请求包装器
This commit is contained in:
ikmkj
2026-03-03 17:49:50 +08:00
parent 5a24569ebd
commit 23929a974f
13 changed files with 763 additions and 26 deletions

View File

@@ -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 "";
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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)
));
}
}

View File

@@ -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)) {

View File

@@ -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"
));
// 存储已使用的 noncekey=noncevalue=使用时间戳
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)
));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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=验证码IDvalue=验证码记录
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());
}
}

View File

@@ -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);
});
}
}

View File

@@ -42,7 +42,7 @@ const initVditor = () => {
if (vditor.value) {
vditor.value.destroy();
}
vditor.value = new Vditor('vditor-editor', {
height: 'calc(100vh - 120px)',
mode: 'ir',