feat: 添加用户角色字段并实现权限控制

fix(security): 修复重放攻击拦截器的时间戳验证漏洞

refactor(security): 重构验证码工具类使用线程安全实现

perf(login): 优化登录锁定工具类性能并添加定期清理

fix(editor): 修复笔记编辑器空指针问题

style: 清理数据库索引脚本中的冗余注释

fix(api): 修复前端API调用参数编码问题

feat(image): 实现图片名称同步服务

refactor(markdown): 重构Markdown服务分离图片名称同步逻辑

fix(xss): 添加HTML转义函数防止XSS攻击

fix(user): 修复用户服务权限加载问题

fix(rate-limit): 修复速率限制拦截器并发问题

fix(axios): 生产环境隐藏详细错误信息

fix(image): 修复图片上传和删除的权限验证

refactor(captcha): 重构验证码工具类使用并发安全实现

fix(jwt): 修复JWT过滤器空指针问题

fix(export): 修复笔记导出XSS漏洞

fix(search): 修复Markdown搜索SQL注入问题

fix(interceptor): 修复重放攻击拦截器逻辑错误

fix(controller): 修复用户控制器空指针问题

fix(security): 修复nonce生成使用密码学安全方法
This commit is contained in:
ikmkj
2026-03-03 20:48:40 +08:00
parent d54719d82d
commit 375ccb89ff
22 changed files with 512 additions and 359 deletions

View File

@@ -4,13 +4,17 @@ package com.test.bijihoudaun.controller;
import cn.hutool.core.util.StrUtil;
import com.test.bijihoudaun.common.response.R;
import com.test.bijihoudaun.entity.Image;
import com.test.bijihoudaun.entity.User;
import com.test.bijihoudaun.service.ImageService;
import com.test.bijihoudaun.service.UserService;
import com.test.bijihoudaun.util.SecurityUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -18,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@@ -33,18 +38,30 @@ public class ImageController {
@Autowired
private ImageService imageService;
@Autowired
private UserService userService;
@Operation(summary = "上传图片")
@PostMapping
public R<Image> uploadImage(
@RequestPart("file") MultipartFile file,
@RequestParam(value = "userId", required = false) Long userId,
@RequestParam(value = "markdownId", required = false) Long markdownId) {
// 修复从SecurityContext获取当前用户不接受客户端传入的userId
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof UserDetails)) {
return R.fail("请先登录");
}
String username = ((UserDetails) principal).getUsername();
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
if (user == null) {
return R.fail("用户不存在");
}
try {
Image image = imageService.uploadImage(file, userId, markdownId);
Image image = imageService.uploadImage(file, user.getId(), markdownId);
return R.success(image);
} catch (IOException e) {
return R.fail();
return R.fail("上传失败:" + e.getMessage());
}
}
@@ -54,27 +71,33 @@ public class ImageController {
if (!SecurityUtil.isUserAuthenticated()) {
return R.fail("请先登录");
}
// 修复:添加权限验证,确保用户只能删除自己的图片
if (!canModifyImage(id)) {
return R.fail("无权删除此图片");
}
boolean result = imageService.deleteImage(id);
if (result) {
return R.success();
} else {
return R.fail();
return R.fail("删除失败");
}
}
@Operation(summary = "在线预览", description = "浏览器直接打开文件流")
@GetMapping("/preview/{url}")
public void preview(@PathVariable String url, HttpServletResponse resp) throws IOException {
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
try (PrintWriter writer = resp.getWriter()) {
if (StrUtil.isBlank(url)) {
resp.setStatus(404);
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
return;
}
String sanitizedUrl = sanitizeFileName(url);
if (sanitizedUrl == null) {
resp.setStatus(403);
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
return;
}
@@ -83,14 +106,14 @@ public class ImageController {
if (!filePath.startsWith(basePath)) {
resp.setStatus(403);
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
return;
}
File file = filePath.toFile();
if (!file.exists() || !file.isFile()) {
resp.setStatus(404);
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
return;
}
@@ -101,6 +124,7 @@ public class ImageController {
StreamUtils.copy(in, resp.getOutputStream());
}
}
}
private String sanitizeFileName(String fileName) {
if (StrUtil.isBlank(fileName)) {
@@ -119,11 +143,15 @@ public class ImageController {
if (!SecurityUtil.isUserAuthenticated()) {
return R.fail("请先登录");
}
// 修复:添加权限验证
if (!canModifyImageByUrl(url)) {
return R.fail("无权删除此图片");
}
boolean result = imageService.deleteImageByUrl(url);
if (result) {
return R.success();
} else {
return R.fail();
return R.fail("删除失败");
}
}
@@ -133,14 +161,62 @@ public class ImageController {
if (!SecurityUtil.isUserAuthenticated()) {
return R.fail("请先登录");
}
// 修复:添加权限验证
for (String url : urls) {
if (!canModifyImageByUrl(url)) {
return R.fail("无权删除部分图片");
}
}
boolean result = imageService.deleteImageByUrls(urls);
if (result) {
return R.success();
} else {
return R.fail();
return R.fail("删除失败");
}
}
/**
* 检查当前用户是否有权限操作图片
*/
private boolean canModifyImage(Long imageId) {
// 从数据库查询图片所属用户
Image image = imageService.getById(imageId);
if (image == null) {
return false;
}
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof UserDetails)) {
return false;
}
String username = ((UserDetails) principal).getUsername();
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
if (user == null) {
return false;
}
return user.getId().equals(image.getUserId());
}
/**
* 检查当前用户是否有权限操作图片通过URL
*/
private boolean canModifyImageByUrl(String url) {
// 从数据库查询图片所属用户
Image image = imageService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<Image>().eq("stored_name", url));
if (image == null) {
return false;
}
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof UserDetails)) {
return false;
}
String username = ((UserDetails) principal).getUsername();
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
if (user == null) {
return false;
}
return user.getId().equals(image.getUserId());
}
private String getContentTypeFromFileExtension(String fileName) {
if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) {

View File

@@ -98,7 +98,7 @@ public class MarkdownController {
@PostMapping("/{id}/title")
public R<MarkdownFile> updateMarkdownTitle(
@PathVariable Long id,
String title) {
@RequestParam String title) {
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
if (ObjectUtil.isNotNull(updatedFile)) {
return R.success(updatedFile);

View File

@@ -11,8 +11,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
@RestController
@RequestMapping("/api/system")
@Tag(name = "系统管理")
@@ -31,7 +29,7 @@ public class SystemController {
}
@PostMapping("/registration/toggle")
@PreAuthorize("isAuthenticated()")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "切换注册功能状态")
public R<Void> toggleRegistration(@RequestBody Boolean enabled) {
systemSettingService.setRegistrationEnabled(enabled);
@@ -39,7 +37,7 @@ public class SystemController {
}
@PostMapping("/registration/generate-code")
@PreAuthorize("isAuthenticated()")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "生成注册码")
public R<String> generateRegistrationCode() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

View File

@@ -52,6 +52,10 @@ public class UserController {
return R.fail("无效或已过期的注册码");
}
User user = userService.register(username, password, email);
// 修复:添加空值检查
if (user == null) {
return R.fail("注册失败,请稍后重试");
}
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
userVO.setId(String.valueOf(user.getId()));
@@ -69,6 +73,11 @@ public class UserController {
String token = userService.login(username, password);
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
// 修复:添加空值检查
if (user == null) {
return R.fail("用户不存在");
}
Map<String, Object> result = new HashMap<>();
result.put("token", token);
@@ -88,7 +97,12 @@ public class UserController {
@RequireCaptcha("删除账号")
@DeleteMapping("/deleteUser")
public R<String> deleteUser(){
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 修复:添加类型检查
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof UserDetails)) {
return R.fail("无法获取用户信息");
}
UserDetails userDetails = (UserDetails) principal;
String username = userDetails.getUsername();
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
@@ -110,9 +124,20 @@ public class UserController {
@RequireCaptcha("修改密码")
@PutMapping("/password")
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// 修复:添加类型检查
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (!(principal instanceof UserDetails)) {
return R.fail("无法获取用户信息");
}
UserDetails userDetails = (UserDetails) principal;
String username = userDetails.getUsername();
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
// 修复:添加空值检查
if (ObjectUtil.isNull(user)) {
return R.fail("用户不存在");
}
userService.updatePassword(user.getId(), updatePasswordBo);
return R.success("密码更新成功");
}

View File

@@ -31,6 +31,10 @@ public class User {
@TableField("`email`")
private String email;
@Schema(description = "用户角色(ADMIN-管理员,USER-普通用户)",implementation = String.class)
@TableField("`role`")
private String role;
@Schema(description = "用户创建时间",implementation = Date.class)
@TableField("created_at")
private Date createdAt;

View File

@@ -5,6 +5,7 @@ import com.test.bijihoudaun.common.response.R;
import com.test.bijihoudaun.common.response.ResultCode;
import com.test.bijihoudaun.util.JwtTokenUtil;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -18,6 +19,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@@ -45,8 +47,8 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 从请求头中获取认证信息
String authHeader = request.getHeader(this.tokenHeader);
// 检查请求头是否存在且以指定的token前缀开头
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
// 修复:添加 tokenHead 空值检查
if (authHeader != null && this.tokenHead != null && authHeader.startsWith(this.tokenHead)) {
// 提取实际的token值去除前缀
final String authToken = authHeader.substring(this.tokenHead.length());
try {
@@ -76,6 +78,10 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
// 处理token签名异常
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
return;
} catch (JwtException e) {
// 修复捕获所有JWT异常作为兜底
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
return;
}
}
// 继续过滤器链的处理
@@ -93,9 +99,13 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
response.setContentType("application/json;charset=UTF-8");
// 设置HTTP状态码为401未授权
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
try (PrintWriter writer = response.getWriter()) {
// 创建ObjectMapper实例用于对象与JSON之间的转换
ObjectMapper mapper = new ObjectMapper();
// 将失败结果转换为JSON字符串并写入响应输出流
response.getWriter().write(mapper.writeValueAsString(R.fail(resultCode)));
writer.write(mapper.writeValueAsString(R.fail(resultCode)));
writer.flush();
}
}
}

View File

@@ -61,24 +61,18 @@ public class RateLimitInterceptor implements HandlerInterceptor {
boolean incrementAndCheck(int maxRequests) {
long now = System.currentTimeMillis();
lastAccessTime = now;
long currentWindow = windowStart;
if (now - currentWindow > WINDOW_SIZE_MS) {
// 尝试进入新窗口
synchronized (this) {
if (windowStart == currentWindow) {
// 确实需要新窗口
if (now - windowStart > WINDOW_SIZE_MS) {
// 进入新窗口
windowStart = now;
count.set(1);
return true;
}
}
// 其他线程已经更新了窗口,继续检查
}
int currentCount = count.incrementAndGet();
return currentCount <= maxRequests;
}
}
/**
* 检查是否过期超过2个时间窗口没有访问

View File

@@ -107,8 +107,9 @@ public class ReplayAttackInterceptor implements HandlerInterceptor {
}
long now = System.currentTimeMillis();
if (Math.abs(now - timestamp) > TIMESTAMP_VALIDITY) {
writeErrorResponse(response, "请求已过期,请重新发起请求");
// 修复:不允许未来时间戳,防止预发送攻击
if (timestamp > now || now - timestamp > TIMESTAMP_VALIDITY) {
writeErrorResponse(response, "请求时间戳无效或已过期");
return false;
}

View File

@@ -0,0 +1,85 @@
package com.test.bijihoudaun.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.test.bijihoudaun.entity.ImageName;
import com.test.bijihoudaun.mapper.ImageNameMapper;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 图片名称同步服务
* 处理 Markdown 内容中的图片名称同步,独立的事务和异步处理
*/
@Service
public class ImageNameSyncService {
@Resource
private ImageNameMapper imageNameMapper;
/**
* 同步图片名称
* 异步执行,独立事务
*/
@Async("imageNameSyncExecutor")
@Transactional
public void syncImageNames(Long markdownId, List<String> strings) {
// 查询数据库中已存在的文件名
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
.eq(ImageName::getMarkdownId, markdownId));
// 若是数据库中的数据为null则插入
if (CollUtil.isEmpty(imageNames)) {
if (CollUtil.isNotEmpty(strings)) {
List<ImageName> list = strings.stream().map(fileName -> {
ImageName imageName = new ImageName();
imageName.setFileName(fileName);
imageName.setMarkdownId(markdownId);
return imageName;
}).toList();
// 批量插入新的文件名使用MyBatis Plus批量插入
imageNameMapper.insert(list);
}
} else {
// 数据库中已有记录,需要对比处理
// 获取数据库中的文件名列表
List<String> dbFileNames = imageNames.stream()
.map(ImageName::getFileName)
.toList();
// 找出需要新增的文件名在strings中但不在数据库中
List<String> toInsert = strings.stream()
.filter(fileName -> !dbFileNames.contains(fileName))
.toList();
// 找出需要删除的记录在数据库中但不在strings中
List<ImageName> toDelete = imageNames.stream()
.filter(imageName -> !strings.contains(imageName.getFileName()))
.toList();
// 插入新增的文件名
if (CollUtil.isNotEmpty(toInsert)) {
List<ImageName> insertList = toInsert.stream().map(fileName -> {
ImageName imageName = new ImageName();
imageName.setFileName(fileName);
imageName.setMarkdownId(markdownId);
return imageName;
}).toList();
imageNameMapper.insert(insertList);
}
// 删除不再需要的记录
if (CollUtil.isNotEmpty(toDelete)) {
List<Long> deleteIds = toDelete.stream()
.map(ImageName::getId)
.toList();
imageNameMapper.deleteByIds(deleteIds);
}
}
}
}

View File

@@ -82,16 +82,22 @@ public class ImageServiceImpl
Path filePath = uploadPath.resolve(storedName);
// 优化:压缩图片后再保存
// 优化:压缩图片后再保存,确保流关闭
try (InputStream originalStream = file.getInputStream()) {
InputStream compressedStream = ImageCompressor.compressIfNeeded(
file.getInputStream(), contentType, file.getSize());
originalStream, contentType, file.getSize());
if (compressedStream != null) {
// 使用压缩后的图片
try (compressedStream) {
Files.copy(compressedStream, filePath);
}
} else {
// 使用原图
Files.copy(file.getInputStream(), filePath);
// 使用原图,需要重新获取流
try (InputStream newStream = file.getInputStream()) {
Files.copy(newStream, filePath);
}
}
}
Image image = new Image();

View File

@@ -15,7 +15,6 @@ import com.test.bijihoudaun.util.MarkdownImageExtractor;
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,6 +33,8 @@ public class MarkdownFileServiceImpl
ImageNameMapper imageNameMapper;
@Resource
SnowflakeIdGenerator snowflakeIdGenerator;
@Resource
ImageNameSyncService imageNameSyncService;
@Override
@@ -77,7 +78,8 @@ public class MarkdownFileServiceImpl
}
List<String> strings = MarkdownImageExtractor.extractImageFilenames(markdownFile.getContent());
syncImageNames(id, strings);
// 修复:调用单独的 Service 处理异步逻辑
imageNameSyncService.syncImageNames(id, strings);
MarkdownFileVO result = markdownFileMapper.selectByIdWithGrouping(id);
if (result != null) {
@@ -128,8 +130,13 @@ public class MarkdownFileServiceImpl
@Override
public List<MarkdownFile> searchByTitle(String keyword) {
// 修复:转义特殊字符防止 SQL 注入
if (keyword == null || keyword.trim().isEmpty()) {
return List.of();
}
String escapedKeyword = keyword.replace("%", "\\%").replace("_", "\\_");
QueryWrapper<MarkdownFile> queryWrapper = new QueryWrapper<>();
queryWrapper.like("title", keyword);
queryWrapper.like("title", escapedKeyword);
return this.list(queryWrapper);
}
@@ -149,62 +156,4 @@ public class MarkdownFileServiceImpl
return markdownFileMapper.selectRecentWithGrouping(limit);
}
@Async("imageNameSyncExecutor")
public void syncImageNames(Long markdownId, List<String> strings) {
// 查询数据库中已存在的文件名
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
.eq(ImageName::getMarkdownId, markdownId));
// 若是数据库中的数据为null则插入
if (CollUtil.isEmpty(imageNames)) {
if (CollUtil.isNotEmpty(strings)) {
List<ImageName> list = strings.stream().map(fileName -> {
ImageName imageName = new ImageName();
imageName.setFileName(fileName);
imageName.setMarkdownId(markdownId);
return imageName;
}).toList();
// 批量插入新的文件名使用MyBatis Plus批量插入
imageNameMapper.insert(list);
}
} else {
// 数据库中已有记录,需要对比处理
// 获取数据库中的文件名列表
List<String> dbFileNames = imageNames.stream()
.map(ImageName::getFileName)
.toList();
// 找出需要新增的文件名在strings中但不在数据库中
List<String> toInsert = strings.stream()
.filter(fileName -> !dbFileNames.contains(fileName))
.toList();
// 找出需要删除的记录在数据库中但不在strings中
List<ImageName> toDelete = imageNames.stream()
.filter(imageName -> !strings.contains(imageName.getFileName()))
.toList();
// 插入新增的文件名
if (CollUtil.isNotEmpty(toInsert)) {
List<ImageName> insertList = toInsert.stream().map(fileName -> {
ImageName imageName = new ImageName();
imageName.setFileName(fileName);
imageName.setMarkdownId(markdownId);
return imageName;
}).toList();
imageNameMapper.insert(insertList);
}
// 删除不再需要的记录
if (CollUtil.isNotEmpty(toDelete)) {
List<Long> deleteIds = toDelete.stream()
.map(ImageName::getId)
.toList();
imageNameMapper.deleteByIds(deleteIds);
}
}
}
}

View File

@@ -16,6 +16,8 @@ import com.test.bijihoudaun.util.PasswordUtils;
import com.test.bijihoudaun.util.UuidV7;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@@ -30,9 +32,9 @@ import java.util.Date;
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
private static final int MIN_USERNAME_LENGTH = 2;
private static final int MAX_USERNAME_LENGTH = 8;
private static final int MAX_USERNAME_LENGTH = 20;
private static final int MIN_PASSWORD_LENGTH = 6;
private static final int MAX_PASSWORD_LENGTH = 12;
private static final int MAX_PASSWORD_LENGTH = 128;
@Autowired
private UserMapper userMapper;
@@ -43,7 +45,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
if (ObjectUtil.isNull(user)) {
throw new UsernameNotFoundException("User not found with username: " + username);
}
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), new ArrayList<>());
// 加载用户角色权限
java.util.List<GrantedAuthority> authorities = new ArrayList<>();
String role = user.getRole();
if (role == null || role.isEmpty()) {
role = "USER"; // 默认角色
}
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
}
@Override

View File

@@ -12,7 +12,10 @@ import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 图形验证码工具类
@@ -33,11 +36,23 @@ public class CaptchaUtil {
private static final int MAX_CAPTCHAS = 5000;
// 存储验证码key=验证码IDvalue=验证码记录
private static final LRUCache<String, CaptchaRecord> captchaStore = new LRUCache<>(MAX_CAPTCHAS);
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private static final ConcurrentHashMap<String, CaptchaRecord> captchaStore = new ConcurrentHashMap<>();
// 安全随机数生成器
private static final SecureRandom random = new SecureRandom();
// 使用 ThreadLocal 确保线程安全
private static final ThreadLocal<SecureRandom> random = ThreadLocal.withInitial(SecureRandom::new);
// 定期清理线程池
private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "captcha-cleanup");
t.setDaemon(true);
return t;
});
static {
// 每5分钟清理一次过期验证码
cleanupScheduler.scheduleAtFixedRate(CaptchaUtil::cleanupExpiredCaptchas,
5, 5, TimeUnit.MINUTES);
}
private static class CaptchaRecord {
String code;
@@ -53,27 +68,6 @@ public class CaptchaUtil {
}
}
/**
* 简单的 LRU 缓存实现
*/
private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
LRUCache(int maxSize) {
super(maxSize, 0.75f, true);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
boolean shouldRemove = size() > maxSize;
if (shouldRemove) {
log.warn("验证码存储达到上限,移除最旧的验证码: {}", eldest.getKey());
}
return shouldRemove;
}
}
/**
* 验证码结果
*/
@@ -106,9 +100,6 @@ public class CaptchaUtil {
throw new RuntimeException("服务器繁忙,请稍后再试");
}
// 清理过期验证码
cleanupExpiredCaptchas();
// 生成验证码ID
String captchaId = UuidV7.uuid();
@@ -118,13 +109,8 @@ public class CaptchaUtil {
// 生成图片
String base64Image = generateImage(code);
lock.writeLock().lock();
try {
// 存储验证码
captchaStore.put(captchaId, new CaptchaRecord(code));
} finally {
lock.writeLock().unlock();
}
return new CaptchaResult(captchaId, base64Image);
}
@@ -140,20 +126,11 @@ public class CaptchaUtil {
return false;
}
lock.readLock().lock();
try {
CaptchaRecord record = captchaStore.get(captchaId);
if (record == null || record.isExpired()) {
// 验证码不存在或已过期,移除
if (record != null) {
lock.readLock().unlock();
lock.writeLock().lock();
try {
captchaStore.remove(captchaId);
} finally {
lock.writeLock().unlock();
lock.readLock().lock();
}
}
return false;
}
@@ -163,20 +140,10 @@ public class CaptchaUtil {
// 验证成功后立即删除(一次性使用)
if (success) {
lock.readLock().unlock();
lock.writeLock().lock();
try {
captchaStore.remove(captchaId);
} finally {
lock.writeLock().unlock();
lock.readLock().lock();
}
}
return success;
} finally {
lock.readLock().unlock();
}
}
/**
@@ -185,8 +152,9 @@ public class CaptchaUtil {
private static String generateCode() {
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
StringBuilder sb = new StringBuilder();
SecureRandom r = random.get();
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
sb.append(chars.charAt(random.nextInt(chars.length())));
sb.append(chars.charAt(r.nextInt(chars.length())));
}
return sb.toString();
}
@@ -196,56 +164,59 @@ public class CaptchaUtil {
*/
private static String generateImage(String code) {
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
Graphics2D g2d = image.createGraphics();
try {
// 设置背景色
g.setColor(Color.WHITE);
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
g2d.setColor(Color.WHITE);
g2d.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
// 绘制干扰线
g.setColor(Color.LIGHT_GRAY);
g2d.setColor(Color.LIGHT_GRAY);
SecureRandom r = random.get();
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);
int x1 = r.nextInt(IMAGE_WIDTH);
int y1 = r.nextInt(IMAGE_HEIGHT);
int x2 = r.nextInt(IMAGE_WIDTH);
int y2 = r.nextInt(IMAGE_HEIGHT);
g2d.drawLine(x1, y1, x2, y2);
}
// 绘制验证码字符
g.setFont(new Font("Arial", Font.BOLD, 24));
g2d.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)
g2d.setColor(new Color(
r.nextInt(100),
r.nextInt(100),
r.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);
int angle = r.nextInt(30) - 15;
g2d.rotate(Math.toRadians(angle), x + 10, 25);
g2d.drawString(String.valueOf(code.charAt(i)), x, 28);
g2d.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);
int x1 = r.nextInt(IMAGE_WIDTH);
int y1 = r.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," + java.util.Base64.getEncoder().encodeToString(imageBytes);
}
} catch (IOException e) {
throw new RuntimeException("生成验证码图片失败", e);
} finally {
g2d.dispose();
}
}
@@ -253,16 +224,19 @@ public class CaptchaUtil {
* 清理过期验证码
*/
private static void cleanupExpiredCaptchas() {
// 每80%容量时清理一次,减少开销
if (captchaStore.size() < MAX_CAPTCHAS * 0.8) {
return;
}
lock.writeLock().lock();
try {
captchaStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
} finally {
lock.writeLock().unlock();
int removed = 0;
for (Map.Entry<String, CaptchaRecord> entry : captchaStore.entrySet()) {
if (entry.getValue().isExpired()) {
captchaStore.remove(entry.getKey());
removed++;
}
}
if (removed > 0) {
log.debug("清理了 {} 个过期验证码", removed);
}
} catch (Exception e) {
log.error("清理过期验证码失败", e);
}
}
@@ -270,11 +244,6 @@ public class CaptchaUtil {
* 获取当前验证码数量(用于监控)
*/
public static int getCaptchaCount() {
lock.readLock().lock();
try {
return captchaStore.size();
} finally {
lock.readLock().unlock();
}
}
}

View File

@@ -6,11 +6,14 @@ import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* 登录锁定工具类
* 使用本地内存存储登录失败记录,带容量限制
* 使用本地内存存储登录失败记录,带容量限制和定期清理
*/
@Slf4j
public class LoginLockUtil {
@@ -28,6 +31,19 @@ public class LoginLockUtil {
private static final LRUCache<String, LoginAttempt> attempts = new LRUCache<>(MAX_RECORDS);
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
// 定期清理线程池
private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "login-lock-cleanup");
t.setDaemon(true);
return t;
});
static {
// 每10分钟清理一次过期记录
cleanupScheduler.scheduleAtFixedRate(LoginLockUtil::cleanupExpiredLocks,
10, 10, TimeUnit.MINUTES);
}
private static class LoginAttempt {
int failedCount;
LocalDateTime lastAttemptTime;
@@ -38,6 +54,10 @@ public class LoginLockUtil {
this.lastAttemptTime = LocalDateTime.now();
this.lockUntil = null;
}
boolean isExpired() {
return ChronoUnit.MINUTES.between(lastAttemptTime, LocalDateTime.now()) > RECORD_EXPIRE_MINUTES;
}
}
/**
@@ -68,8 +88,6 @@ public class LoginLockUtil {
public static void recordFailedAttempt(String username) {
if (username == null || username.isEmpty()) return;
cleanupExpiredRecords();
lock.writeLock().lock();
try {
LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt());
@@ -116,20 +134,7 @@ public class LoginLockUtil {
// 检查是否仍在锁定时间内
if (attempt.lockUntil != null) {
if (LocalDateTime.now().isBefore(attempt.lockUntil)) {
return true;
} else {
// 锁定时间已过,清除记录
lock.readLock().unlock();
lock.writeLock().lock();
try {
attempts.remove(username);
} finally {
lock.writeLock().unlock();
lock.readLock().lock();
}
return false;
}
return LocalDateTime.now().isBefore(attempt.lockUntil);
}
return false;
} finally {
@@ -177,14 +182,9 @@ public class LoginLockUtil {
}
/**
* 清理过期记录
* 清理过期的锁定记录
*/
private static void cleanupExpiredRecords() {
// 每100次操作清理一次减少开销
if (attempts.size() < MAX_RECORDS * 0.8) {
return;
}
private static void cleanupExpiredLocks() {
lock.writeLock().lock();
try {
LocalDateTime now = LocalDateTime.now();
@@ -192,7 +192,7 @@ public class LoginLockUtil {
LoginAttempt attempt = entry.getValue();
// 未锁定且长时间没有登录的记录
if (attempt.lockUntil == null) {
return ChronoUnit.MINUTES.between(attempt.lastAttemptTime, now) > RECORD_EXPIRE_MINUTES;
return attempt.isExpired();
}
// 锁定已过期的记录
return now.isAfter(attempt.lockUntil);

View File

@@ -1,38 +1,24 @@
-- 数据库性能优化索引
-- 创建时间: 2024-03-03
-- 数据库性能优化索引(完整修正版)
-- markdown_file 表索引
-- 按分组查询
CREATE INDEX IF NOT EXISTS idx_markdown_grouping_id ON markdown_file(grouping_id);
-- 按删除状态查询(软删除)
CREATE INDEX IF NOT EXISTS idx_markdown_is_deleted ON markdown_file(is_deleted);
-- 按创建时间排序
CREATE INDEX IF NOT EXISTS idx_markdown_created_at ON markdown_file(created_at);
-- 复合索引:查询未删除的分组笔记
CREATE INDEX IF NOT EXISTS idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted);
-- ==================== markdown_file 表索引 ====================
CREATE INDEX idx_markdown_grouping_id ON markdown_file(grouping_id);
CREATE INDEX idx_markdown_is_deleted ON markdown_file(is_deleted);
CREATE INDEX idx_markdown_created_at ON markdown_file(created_at);
CREATE INDEX idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted);
-- image 表索引
-- 按 markdown_id 查询(关联查询)
CREATE INDEX IF NOT EXISTS idx_image_markdown_id ON image(markdown_id);
-- 按存储文件名查询
CREATE INDEX IF NOT EXISTS idx_image_stored_name ON image(stored_name);
-- 按创建时间查询(清理旧图片)
CREATE INDEX IF NOT EXISTS idx_image_created_at ON image(created_at);
-- ==================== image 表索引 ====================
CREATE INDEX idx_image_markdown_id ON image(markdown_id);
CREATE INDEX idx_image_stored_name ON image(stored_name);
CREATE INDEX idx_image_created_at ON image(created_at);
-- grouping 表索引
-- 按父分组查询
CREATE INDEX IF NOT EXISTS idx_grouping_parent_id ON grouping(parent_id);
-- 按删除状态查询
CREATE INDEX IF NOT EXISTS idx_grouping_is_deleted ON grouping(is_deleted);
-- ==================== grouping 表索引(注意反引号!)====================
CREATE INDEX idx_grouping_parent_id ON `grouping`(parentId);
CREATE INDEX idx_grouping_is_deleted ON `grouping`(is_deleted);
-- trash 表索引
-- 按用户查询
CREATE INDEX IF NOT EXISTS idx_trash_user_id ON trash(user_id);
-- 按删除时间排序(清理过期数据)
CREATE INDEX IF NOT EXISTS idx_trash_deleted_at ON trash(deleted_at);
-- 按类型查询
CREATE INDEX IF NOT EXISTS idx_trash_item_type ON trash(item_type);
-- ==================== trash 表索引 ====================
CREATE INDEX idx_trash_user_id ON trash(user_id);
CREATE INDEX idx_trash_deleted_at ON trash(deleted_at);
CREATE INDEX idx_trash_item_type ON trash(item_type);
-- user 表索引
-- 按用户名查询(登录)
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
-- ==================== user 表索引 ====================
CREATE INDEX idx_user_username ON user(username);

View File

@@ -0,0 +1,12 @@
-- 添加用户角色字段
-- 创建时间: 2024-03-04
-- 为用户表添加 role 字段
ALTER TABLE `user`
ADD COLUMN `role` VARCHAR(50) DEFAULT 'USER' COMMENT '用户角色ADMIN-管理员USER-普通用户';
-- 将第一个用户设置为管理员(可选,根据实际需求)
-- UPDATE `user` SET `role` = 'ADMIN' WHERE `id` = 1;
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_user_role ON `user`(`role`);

View File

@@ -2,9 +2,14 @@ import axiosApi from '@/utils/axios.js'
export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${data}`)
// 修复:使用 encodeURIComponent 编码 URL 参数,防止注入
export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${encodeURIComponent(data)}`)
// 获取所有分组
export const groupingAll = (data) => axiosApi.get(`/api/groupings?parentId=${data}`);
export const groupingAll = (data) => {
const params = new URLSearchParams();
if (data) params.append('parentId', data);
return axiosApi.get(`/api/groupings?${params.toString()}`);
};
// 获取所有Markdown文件
export const markdownAll = () => axiosApi.get(`/api/markdown`);
// 预览markdown文件
@@ -19,20 +24,15 @@ export const updateMarkdown = (data) => {
return axiosApi.post(`/api/markdown/updateMarkdown`, data)
}
// 批量删除图片
// 修复:后端接收 JSON 数组,不是 FormData
export const deleteImages = (list) => {
const formData = new FormData()
formData.append('urls', list)
return axiosApi.post('/api/images/batch', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return axiosApi.post('/api/images/batch', list)
}
// 上传图片
export const uploadImage = (file, userId, markdownId) => {
// 修复:移除 userId 参数,后端从 SecurityContext 获取当前用户
export const uploadImage = (file, markdownId) => {
const formData = new FormData()
if (file) formData.append('file', file)
if (userId) formData.append('userId', userId)
if (markdownId) formData.append('markdownId', markdownId)
return axiosApi.post('/api/images', formData, {
headers: {
@@ -59,7 +59,11 @@ export const login = (data) => {
}
// 搜索
export const searchMarkdown = (keyword) => axiosApi.get(`/api/markdown/search?keyword=${keyword}`);
export const searchMarkdown = (keyword) => {
const params = new URLSearchParams();
params.append('keyword', keyword);
return axiosApi.get(`/api/markdown/search?${params.toString()}`);
};
// 注册
export const register = (data) => {

View File

@@ -134,6 +134,7 @@ import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { escapeHtml } from '@/utils/security';
import {
groupingAll,
markdownList,
@@ -501,9 +502,11 @@ const handleExport = async (format) => {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
downloadBlob(blob, `${title}.md`);
} else if (format === 'html') {
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${title}</h1>${previewElement.innerHTML}</body></html>`;
// 修复对title进行HTML转义防止XSS
const escapedTitle = escapeHtml(title);
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapedTitle}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${escapedTitle}</h1>${previewElement.innerHTML}</body></html>`;
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
downloadBlob(blob, `${title}.html`);
downloadBlob(blob, `${escapedTitle}.html`);
} else if (format === 'pdf') {
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });

View File

@@ -92,8 +92,14 @@ const initVditor = () => {
const save = async (value) => {
if (isSaving.value) return;
// 修复:添加空值检查
if (!vditor.value) {
console.warn('编辑器未初始化');
return;
}
clearTimeout(saveTimeout);
const content = typeof value === 'string' ? value : vditor.value.getValue();
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
if (content === lastSavedContent.value && currentId.value) {
return;

View File

@@ -32,8 +32,9 @@
>
<div class="menu-section" v-if="isMobile">
<div v-if="userStore.isLoggedIn" class="user-info">
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() }}</el-avatar>
<span class="username">{{ userStore.userInfo?.username }}</span>
<!-- 修复添加默认值防止空指针 -->
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() || '?' }}</el-avatar>
<span class="username">{{ userStore.userInfo?.username || '访客' }}</span>
</div>
<div v-else class="guest-info">
<el-button type="primary" @click="goToLogin">登录</el-button>

View File

@@ -92,8 +92,11 @@ instance.interceptors.response.use(
return Promise.reject(new Error(msg));
}
// 其他错误,显示后端返回的消
const msg = data?.msg || error.message;
// 其他错误,生产环境隐藏详细错误信
const isDev = import.meta.env.DEV;
const msg = isDev
? (data?.msg || error.message)
: (data?.msg || '操作失败,请稍后重试');
ElMessage.error(msg);
} else {
ElMessage({

View File

@@ -5,15 +5,15 @@
/**
* 生成随机 nonce (32位随机字符串)
* 使用 crypto.getRandomValues 确保密码学安全
* @returns {string} nonce
*/
export function generateNonce() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let nonce = '';
for (let i = 0; i < 32; i++) {
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
}
return nonce;
const array = new Uint8Array(24); // 24 bytes = 32 base64 chars
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/[+/=]/g, '') // 移除特殊字符
.substring(0, 32);
}
/**
@@ -61,3 +61,15 @@ export function needsReplayAttackValidation(method, url) {
}
return REPLAY_ATTACK_METHODS.includes(method.toUpperCase());
}
/**
* HTML 转义函数,防止 XSS
* @param {string} text 需要转义的文本
* @returns {string} 转义后的文本
*/
export function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}