feat(安全): 添加验证码和登录安全增强功能
新增验证码功能用于敏感操作,包括删除账号、修改密码等 添加登录失败锁定机制和限流策略 实现防重放攻击和XSS防护增强 重构XSS拦截器使用请求包装器
This commit is contained in:
@@ -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