From 6ec0c3d77bd3cd85377ef60a0979fddc7c61d3d3 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 17 Mar 2026 00:57:08 +0800 Subject: [PATCH] fix: harden request and task security --- .../java/com/test/demo/aspect/LogAspect.java | 185 ++++++++++++++++-- .../com/test/demo/common/annotation/Log.java | 3 + .../java/com/test/demo/config/FileConfig.java | 18 ++ .../com/test/demo/config/WebMvcConfig.java | 6 +- .../demo/controller/EnumManageController.java | 2 + .../test/demo/controller/FileController.java | 5 +- .../test/demo/controller/LogController.java | 2 + .../test/demo/controller/MenuController.java | 5 + .../test/demo/controller/RoleController.java | 2 + .../test/demo/controller/TaskController.java | 2 + .../test/demo/controller/UserController.java | 8 +- .../interceptor/IdempotentInterceptor.java | 37 ++-- .../interceptor/SignatureInterceptor.java | 48 ++++- .../test/demo/service/DynamicTaskService.java | 86 +++++--- .../demo/service/impl/AuthServiceImpl.java | 2 + .../demo/service/impl/UserServiceImpl.java | 4 +- .../java/com/test/demo/util/FilePathUtil.java | 27 +-- .../test/demo/util/PasswordPolicyUtil.java | 34 ++++ src/main/resources/application.yml | 8 +- src/main/resources/sql/init-auth-schema.sql | 16 ++ 20 files changed, 403 insertions(+), 97 deletions(-) create mode 100644 src/main/java/com/test/demo/util/PasswordPolicyUtil.java diff --git a/src/main/java/com/test/demo/aspect/LogAspect.java b/src/main/java/com/test/demo/aspect/LogAspect.java index 787eee7..a0d11ff 100644 --- a/src/main/java/com/test/demo/aspect/LogAspect.java +++ b/src/main/java/com/test/demo/aspect/LogAspect.java @@ -1,6 +1,9 @@ package com.test.demo.aspect; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.test.demo.bo.LoginBo; import com.test.demo.common.annotation.Log; import com.test.demo.entity.OperLogEntity; @@ -8,7 +11,9 @@ import com.test.demo.entity.UserEntity; import com.test.demo.log.event.OperLogEvent; import com.test.demo.security.SecurityUtil; import com.test.demo.service.UserService; +import com.test.demo.util.DesensitizeUtil; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -16,11 +21,20 @@ import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Component; +import org.springframework.validation.BindingResult; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; +import java.lang.reflect.Array; import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Set; /** * 操作日志切面 @@ -31,6 +45,14 @@ import java.time.LocalDateTime; @RequiredArgsConstructor public class LogAspect { + private static final Set SENSITIVE_FIELDS = Set.of( + "password", "newpassword", "oldpassword", "token", "authorization", + "secret", "captcha", "captchacode", "captchakey", "accesstoken", "refreshtoken" + ); + + private static final Set EMAIL_FIELDS = Set.of("email"); + private static final Set PHONE_FIELDS = Set.of("phone", "mobile", "phonenumber"); + private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final UserService userService; @@ -42,7 +64,6 @@ public class LogAspect { operLog.setTitle(logAnnotation.value()); operLog.setLogType(logAnnotation.type().name()); - // 请求信息 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { HttpServletRequest request = attributes.getRequest(); @@ -51,19 +72,20 @@ public class LogAspect { operLog.setIp(getClientIp(request)); } - // 方法信息 MethodSignature signature = (MethodSignature) point.getSignature(); operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName()); - // 请求参数(脱敏) - try { - String params = objectMapper.writeValueAsString(point.getArgs()); - operLog.setRequestParam(truncate(params, 2000)); - } catch (Exception e) { - operLog.setRequestParam("参数序列化失败"); + if (logAnnotation.recordRequestParam()) { + try { + String params = objectMapper.writeValueAsString(maskMethodArguments(signature, point.getArgs())); + operLog.setRequestParam(truncate(params, 2000)); + } catch (Exception e) { + operLog.setRequestParam("参数序列化失败"); + } + } else { + operLog.setRequestParam(""); } - // 用户信息 try { Long userId = SecurityUtil.getUserIdOrNull(); operLog.setUserId(userId); @@ -73,17 +95,18 @@ public class LogAspect { operLog.setUserName(user.getUsername()); } } - } catch (Exception ignored) {} + } catch (Exception ignored) { + } Object result = null; try { result = point.proceed(); operLog.setStatus(1); - // 返回结果(脱敏) try { - String resultStr = objectMapper.writeValueAsString(result); + String resultStr = objectMapper.writeValueAsString(maskSensitiveValue(result)); operLog.setResponseResult(truncate(resultStr, 2000)); - } catch (Exception ignored) {} + } catch (Exception ignored) { + } } catch (Throwable e) { operLog.setStatus(0); operLog.setErrorMsg(truncate(e.getMessage(), 2000)); @@ -105,7 +128,6 @@ public class LogAspect { operLog.setCostTime(System.currentTimeMillis() - startTime); operLog.setCreateTime(LocalDateTime.now()); - // 日志输出 switch (logAnnotation.level()) { case DEBUG -> log.debug("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); case WARN -> log.warn("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); @@ -113,7 +135,6 @@ public class LogAspect { default -> log.info("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); } - // 异步写入数据库 if (logAnnotation.saveToDB()) { eventPublisher.publishEvent(new OperLogEvent(this, operLog)); } @@ -121,6 +142,132 @@ public class LogAspect { return result; } + private Object maskSensitiveValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof String stringValue) { + return sanitizeSimpleValue(null, stringValue); + } + if (value instanceof Number || value instanceof Boolean || value instanceof Enum) { + return value; + } + if (value instanceof HttpServletRequest || value instanceof HttpServletResponse + || value instanceof MultipartFile || value instanceof MultipartFile[] + || value instanceof BindingResult || value instanceof Resource) { + return "<" + value.getClass().getSimpleName() + ">"; + } + if (value.getClass().isArray()) { + ArrayNode arrayNode = objectMapper.createArrayNode(); + int length = Array.getLength(value); + for (int i = 0; i < length; i++) { + arrayNode.addPOJO(maskSensitiveValue(Array.get(value, i))); + } + return arrayNode; + } + if (value instanceof Collection collection) { + ArrayNode arrayNode = objectMapper.createArrayNode(); + for (Object item : collection) { + arrayNode.addPOJO(maskSensitiveValue(item)); + } + return arrayNode; + } + if (value instanceof Map map) { + ObjectNode objectNode = objectMapper.createObjectNode(); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + objectNode.set(key, objectMapper.valueToTree(maskValueByField(key, entry.getValue()))); + } + return objectNode; + } + + JsonNode node = objectMapper.valueToTree(value); + return maskJsonNode(node, null); + } + + private ObjectNode maskMethodArguments(MethodSignature signature, Object[] args) { + ObjectNode objectNode = objectMapper.createObjectNode(); + if (args == null || args.length == 0) { + return objectNode; + } + String[] parameterNames = signature.getParameterNames(); + for (int i = 0; i < args.length; i++) { + String parameterName = parameterNames != null && i < parameterNames.length && parameterNames[i] != null + ? parameterNames[i] + : "arg" + i; + objectNode.set(parameterName, objectMapper.valueToTree(maskValueByField(parameterName, args[i]))); + } + return objectNode; + } + + private JsonNode maskJsonNode(JsonNode node, String fieldName) { + if (node == null || node.isNull()) { + return node; + } + if (isSensitiveKey(fieldName)) { + return objectMapper.getNodeFactory().textNode("******"); + } + if (node.isTextual()) { + return objectMapper.getNodeFactory().textNode(sanitizeSimpleValue(fieldName, node.asText())); + } + if (node.isArray()) { + ArrayNode arrayNode = objectMapper.createArrayNode(); + for (JsonNode item : node) { + arrayNode.add(maskJsonNode(item, null)); + } + return arrayNode; + } + if (node.isObject()) { + ObjectNode objectNode = objectMapper.createObjectNode(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + objectNode.set(entry.getKey(), maskJsonNode(entry.getValue(), entry.getKey())); + } + return objectNode; + } + return node; + } + + private Object maskValueByField(String fieldName, Object value) { + if (value instanceof String stringValue) { + return sanitizeSimpleValue(fieldName, stringValue); + } + if (isSensitiveKey(fieldName)) { + return "******"; + } + return maskSensitiveValue(value); + } + + private String sanitizeSimpleValue(String fieldName, String value) { + if (value == null) { + return null; + } + if (isSensitiveKey(fieldName)) { + return "******"; + } + String normalized = normalizeKey(fieldName); + if (EMAIL_FIELDS.contains(normalized)) { + return DesensitizeUtil.email(value); + } + if (PHONE_FIELDS.contains(normalized)) { + return DesensitizeUtil.phone(value); + } + return value; + } + + private boolean isSensitiveKey(String key) { + String normalized = normalizeKey(key); + return !normalized.isEmpty() && SENSITIVE_FIELDS.contains(normalized); + } + + private String normalizeKey(String key) { + if (key == null) { + return ""; + } + return key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT); + } + private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { @@ -129,7 +276,6 @@ public class LogAspect { if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } - // 多级代理取第一个 if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } @@ -137,7 +283,9 @@ public class LogAspect { } private String truncate(String str, int maxLen) { - if (str == null) return null; + if (str == null) { + return null; + } return str.length() > maxLen ? str.substring(0, maxLen) : str; } @@ -158,7 +306,8 @@ public class LogAspect { if (username != null) { return String.valueOf(username); } - } catch (Exception ignored) {} + } catch (Exception ignored) { + } } return null; } diff --git a/src/main/java/com/test/demo/common/annotation/Log.java b/src/main/java/com/test/demo/common/annotation/Log.java index d5c30c5..6b655aa 100644 --- a/src/main/java/com/test/demo/common/annotation/Log.java +++ b/src/main/java/com/test/demo/common/annotation/Log.java @@ -21,6 +21,9 @@ public @interface Log { /** 是否写入数据库 */ boolean saveToDB() default false; + /** 是否记录请求参数 */ + boolean recordRequestParam() default true; + /** 日志等级 */ LogLevel level() default LogLevel.INFO; diff --git a/src/main/java/com/test/demo/config/FileConfig.java b/src/main/java/com/test/demo/config/FileConfig.java index 5a72161..b93d4ea 100644 --- a/src/main/java/com/test/demo/config/FileConfig.java +++ b/src/main/java/com/test/demo/config/FileConfig.java @@ -1,8 +1,10 @@ package com.test.demo.config; +import jakarta.annotation.PostConstruct; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import java.util.List; @@ -24,4 +26,20 @@ public class FileConfig { "txt", "csv", "zip", "rar", "7z", "mp4", "avi", "mov", "mp3", "wav" ); + + /** 预览路径前缀 */ + private String previewUrl = "/api/file/preview"; + + @PostConstruct + public void validate() { + if (!StringUtils.hasText(uploadPath)) { + throw new IllegalStateException("app.file.upload-path 未配置"); + } + if (allowedExtensions == null || allowedExtensions.isEmpty()) { + throw new IllegalStateException("app.file.allowed-extensions 未配置"); + } + if (!StringUtils.hasText(previewUrl) || !previewUrl.startsWith("/")) { + throw new IllegalStateException("app.file.preview-url 配置非法"); + } + } } diff --git a/src/main/java/com/test/demo/config/WebMvcConfig.java b/src/main/java/com/test/demo/config/WebMvcConfig.java index 0d1971b..0f9807d 100644 --- a/src/main/java/com/test/demo/config/WebMvcConfig.java +++ b/src/main/java/com/test/demo/config/WebMvcConfig.java @@ -65,7 +65,8 @@ public class WebMvcConfig implements WebMvcConfigurer { StpUtil.checkLogin(); StpUtil.checkRole("ADMIN"); })) - .addPathPatterns("/actuator/**"); + .addPathPatterns("/actuator/**") + .excludePathPatterns("/actuator/health", "/actuator/health/**"); registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) .addPathPatterns("/**") @@ -75,7 +76,8 @@ public class WebMvcConfig implements WebMvcConfigurer { "/captcha/**", "/file/preview/**", "/enums/**", - "/actuator/**", + "/actuator/health", + "/actuator/health/**", "/doc.html", "/webjars/**", "/v3/api-docs/**", diff --git a/src/main/java/com/test/demo/controller/EnumManageController.java b/src/main/java/com/test/demo/controller/EnumManageController.java index 99c8289..d392e7a 100644 --- a/src/main/java/com/test/demo/controller/EnumManageController.java +++ b/src/main/java/com/test/demo/controller/EnumManageController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.EnumItemBo; import com.test.demo.bo.EnumTypeBo; import com.test.demo.bo.EnumTypeQueryBo; @@ -30,6 +31,7 @@ import java.util.List; @RestController @RequestMapping("/enum-manage") @RequiredArgsConstructor +@SaCheckRole("ADMIN") @Tag(name = "枚举字典管理") public class EnumManageController { diff --git a/src/main/java/com/test/demo/controller/FileController.java b/src/main/java/com/test/demo/controller/FileController.java index 3f72a1a..2133f3a 100644 --- a/src/main/java/com/test/demo/controller/FileController.java +++ b/src/main/java/com/test/demo/controller/FileController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.FileQueryBo; import com.test.demo.common.annotation.Log; import com.test.demo.common.base.PageResult; @@ -49,7 +50,8 @@ public class FileController { @PostMapping("/upload") @Operation(summary = "上传文件") @SaCheckPermission(PermissionConstant.FILE_UPLOAD) - @Log(value = "上传文件", type = LogType.OPER) + @SaCheckRole("ADMIN") + @Log(value = "上传文件", type = LogType.OPER, recordRequestParam = false) public R upload( @Parameter(description = "上传文件") @RequestParam("file") MultipartFile file) { @@ -113,6 +115,7 @@ public class FileController { @PostMapping("/delete/{*filePath}") @Operation(summary = "删除文件") @SaCheckPermission(PermissionConstant.FILE_DELETE) + @SaCheckRole("ADMIN") @Log(value = "删除文件", type = LogType.OPER, saveToDB = true) public R delete( @Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx") diff --git a/src/main/java/com/test/demo/controller/LogController.java b/src/main/java/com/test/demo/controller/LogController.java index 720ee1d..0eef389 100644 --- a/src/main/java/com/test/demo/controller/LogController.java +++ b/src/main/java/com/test/demo/controller/LogController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.LoginLogQueryBo; import com.test.demo.bo.OperLogQueryBo; import com.test.demo.common.base.PageResult; @@ -25,6 +26,7 @@ import java.io.IOException; @RestController @RequestMapping("/log") @RequiredArgsConstructor +@SaCheckRole("ADMIN") @Tag(name = "日志管理") public class LogController { diff --git a/src/main/java/com/test/demo/controller/MenuController.java b/src/main/java/com/test/demo/controller/MenuController.java index 2f9eaf6..87ee300 100644 --- a/src/main/java/com/test/demo/controller/MenuController.java +++ b/src/main/java/com/test/demo/controller/MenuController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.MenuBo; import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Idempotent; @@ -33,6 +34,7 @@ public class MenuController { @PostMapping("/list") @Operation(summary = "查询菜单权限列表") @SaCheckPermission(PermissionConstant.MENU_LIST) + @SaCheckRole("ADMIN") public R> list() { return R.ok(menuService.listAll()); } @@ -52,6 +54,7 @@ public class MenuController { @PostMapping("/add") @Operation(summary = "新增菜单权限") @SaCheckPermission(PermissionConstant.MENU_ADD) + @SaCheckRole("ADMIN") @Idempotent(message = "请勿重复新增菜单") @Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true) public R add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) { @@ -62,6 +65,7 @@ public class MenuController { @PostMapping("/edit") @Operation(summary = "编辑菜单权限") @SaCheckPermission(PermissionConstant.MENU_EDIT) + @SaCheckRole("ADMIN") @Idempotent(message = "请勿重复编辑菜单") @Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true) public R edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) { @@ -72,6 +76,7 @@ public class MenuController { @PostMapping("/remove/{id}") @Operation(summary = "删除菜单权限") @SaCheckPermission(PermissionConstant.MENU_REMOVE) + @SaCheckRole("ADMIN") @Idempotent(message = "请勿重复删除菜单") @Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true) public R remove(@PathVariable Long id) { diff --git a/src/main/java/com/test/demo/controller/RoleController.java b/src/main/java/com/test/demo/controller/RoleController.java index 4aba227..0869d03 100644 --- a/src/main/java/com/test/demo/controller/RoleController.java +++ b/src/main/java/com/test/demo/controller/RoleController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.RoleBo; import com.test.demo.bo.RoleMenuAssignBo; import com.test.demo.bo.RoleQueryBo; @@ -29,6 +30,7 @@ import java.util.List; @RestController @RequestMapping("/role") @RequiredArgsConstructor +@SaCheckRole("ADMIN") @Tag(name = "角色管理") public class RoleController { diff --git a/src/main/java/com/test/demo/controller/TaskController.java b/src/main/java/com/test/demo/controller/TaskController.java index 2184590..793840a 100644 --- a/src/main/java/com/test/demo/controller/TaskController.java +++ b/src/main/java/com/test/demo/controller/TaskController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.bo.TaskQueryBo; import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Idempotent; @@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/task") @RequiredArgsConstructor +@SaCheckRole("ADMIN") @Tag(name = "定时任务管理") public class TaskController { diff --git a/src/main/java/com/test/demo/controller/UserController.java b/src/main/java/com/test/demo/controller/UserController.java index b5b799b..5c16121 100644 --- a/src/main/java/com/test/demo/controller/UserController.java +++ b/src/main/java/com/test/demo/controller/UserController.java @@ -1,6 +1,7 @@ package com.test.demo.controller; import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.dev33.satoken.annotation.SaCheckRole; import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.RateLimit; import com.test.demo.common.annotation.Idempotent; @@ -12,6 +13,7 @@ import com.test.demo.enums.LogType; import com.test.demo.bo.UserBo; import com.test.demo.bo.UserQueryBo; import com.test.demo.service.UserService; +import com.test.demo.util.PasswordPolicyUtil; import jakarta.servlet.http.HttpServletResponse; import com.test.demo.vo.UserVo; import io.swagger.v3.oas.annotations.Operation; @@ -28,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/user") @RequiredArgsConstructor +@SaCheckRole("ADMIN") @Tag(name = "用户管理") public class UserController { @@ -90,12 +93,13 @@ public class UserController { @Operation(summary = "重置密码") @SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD) @Idempotent(message = "请勿重复重置密码") - @Log(value = "重置密码", type = LogType.OPER, saveToDB = true) + @Log(value = "重置密码", type = LogType.OPER, saveToDB = true, recordRequestParam = false) public R resetPassword( @Parameter(description = "用户ID", example = "1") @PathVariable Long id, @Parameter(description = "新密码", example = "123456") @RequestParam String newPassword) { + PasswordPolicyUtil.validate(newPassword); userService.resetPassword(id, newPassword); return R.ok(); } @@ -104,7 +108,7 @@ public class UserController { @Operation(summary = "导入用户") @SaCheckPermission(PermissionConstant.USER_IMPORT) @Idempotent(message = "请勿重复导入用户") - @Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true) + @Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true, recordRequestParam = false) public R importUsers( @Parameter(description = "用户导入Excel文件") @RequestParam("file") MultipartFile file) { diff --git a/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java b/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java index aee4008..832ac0c 100644 --- a/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java +++ b/src/main/java/com/test/demo/interceptor/IdempotentInterceptor.java @@ -7,6 +7,7 @@ import com.test.demo.common.constant.RedisKeyConstant; import com.test.demo.common.exception.BizException; import com.test.demo.common.result.ResultCode; import com.test.demo.filter.RepeatableReadRequestWrapper; +import com.test.demo.security.SecurityUtil; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -40,10 +41,7 @@ public class IdempotentInterceptor implements HandlerInterceptor { return true; } - String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN); - if (token == null || token.isBlank()) { - token = request.getHeader("Authorization"); - } + String token = resolveRequestIdentity(request); String url = request.getRequestURI(); String queryString = request.getQueryString() != null ? request.getQueryString() : ""; String body = ""; @@ -51,15 +49,8 @@ public class IdempotentInterceptor implements HandlerInterceptor { body = new String(wrapper.getBody(), StandardCharsets.UTF_8); } String bodyHash = DigestUtil.sha256Hex(body); - String tokenPart; - if (token == null || token.isBlank()) { - String ip = firstNonBlank(request.getHeader("X-Forwarded-For"), request.getRemoteAddr()); - String userAgent = firstNonBlank(request.getHeader("User-Agent"), "unknown"); - tokenPart = request.getMethod() + ":anonymous:" + ip + ":" + userAgent + ":" + url + ":" + queryString + ":" + bodyHash; - } else { - tokenPart = token; - } - String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.md5Hex(tokenPart + url + queryString + bodyHash); + String fingerprint = request.getMethod() + ":" + url + "?" + queryString + ":" + bodyHash; + String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.sha256Hex(token + ":" + fingerprint); Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS); if (Boolean.FALSE.equals(absent)) { @@ -69,6 +60,26 @@ public class IdempotentInterceptor implements HandlerInterceptor { return true; } + + private String resolveRequestIdentity(HttpServletRequest request) { + Long userId = SecurityUtil.getUserIdOrNull(); + if (userId != null) { + return "login-user:" + userId; + } + String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN); + if (token != null && !token.isBlank()) { + return "idem-token:" + token.trim(); + } + token = request.getHeader("Authorization"); + if (token != null && !token.isBlank()) { + return "auth-token:" + token.trim(); + } + String ip = firstNonBlank(request.getHeader("X-Forwarded-For"), request.getRemoteAddr()); + String userAgent = firstNonBlank(request.getHeader("User-Agent"), "unknown"); + String contentType = firstNonBlank(request.getContentType(), "unknown"); + return "anonymous:" + ip + ":" + userAgent + ":" + contentType; + } + private String firstNonBlank(String first, String second) { if (first != null && !first.isBlank()) { return first.split(",")[0].trim(); diff --git a/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java b/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java index c2417e2..f9a5ee5 100644 --- a/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java +++ b/src/main/java/com/test/demo/interceptor/SignatureInterceptor.java @@ -13,9 +13,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -26,7 +29,22 @@ import java.util.concurrent.TimeUnit; @RequiredArgsConstructor public class SignatureInterceptor implements HandlerInterceptor { + private static final List PUBLIC_PATHS = List.of( + "/auth/login", + "/auth/register", + "/captcha/**", + "/file/preview/**", + "/actuator/health", + "/actuator/health/**", + "/enums/**", + "/doc.html", + "/webjars/**", + "/v3/api-docs/**", + "/swagger-resources/**" + ); + private final RedisTemplate redisTemplate; + private final AntPathMatcher antPathMatcher = new AntPathMatcher(); @Value("${security.sign.secret:change-me-sign-secret}") private String signSecret; @@ -39,27 +57,32 @@ public class SignatureInterceptor implements HandlerInterceptor { String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP); String nonce = request.getHeader(CommonConstant.HEADER_NONCE); String sign = request.getHeader(CommonConstant.HEADER_SIGN); + boolean protectedEndpoint = requiresSignature(request); - // 缺少签名头则跳过(可根据需求改为拒绝) - if (timestamp == null || nonce == null || sign == null) { + if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) { + if (protectedEndpoint) { + throw new BizException(ResultCode.SIGNATURE_INVALID.getCode(), "受保护接口缺少签名请求头"); + } return true; } - // 时间戳校验 - long ts = Long.parseLong(timestamp); + long ts; + try { + ts = Long.parseLong(timestamp); + } catch (NumberFormatException ex) { + throw new BizException(ResultCode.BAD_REQUEST, "签名时间戳格式错误"); + } long now = System.currentTimeMillis() / 1000; if (Math.abs(now - ts) > expireSeconds) { throw new BizException(ResultCode.REPLAY_ATTACK); } - // nonce 防重放 String nonceKey = RedisKeyConstant.NONCE + nonce; Boolean absent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", expireSeconds, TimeUnit.SECONDS); if (Boolean.FALSE.equals(absent)) { throw new BizException(ResultCode.REPLAY_ATTACK); } - // 签名校验 String body = ""; if (request instanceof RepeatableReadRequestWrapper wrapper) { body = new String(wrapper.getBody(), StandardCharsets.UTF_8); @@ -72,4 +95,17 @@ public class SignatureInterceptor implements HandlerInterceptor { return true; } + + private boolean requiresSignature(HttpServletRequest request) { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return false; + } + String path = request.getRequestURI(); + for (String publicPath : PUBLIC_PATHS) { + if (antPathMatcher.match(publicPath, path)) { + return false; + } + } + return true; + } } diff --git a/src/main/java/com/test/demo/service/DynamicTaskService.java b/src/main/java/com/test/demo/service/DynamicTaskService.java index 2ec73d6..5b761a4 100644 --- a/src/main/java/com/test/demo/service/DynamicTaskService.java +++ b/src/main/java/com/test/demo/service/DynamicTaskService.java @@ -32,6 +32,9 @@ import java.util.function.Consumer; @RequiredArgsConstructor public class DynamicTaskService implements ApplicationRunner { + private static final int STATUS_PAUSED = 0; + private static final int STATUS_RUNNING = 1; + private static final String TEMPLATE_BEAN_NAME = "dynamicTaskService"; private static final String TEMPLATE_METHOD_HEARTBEAT = "logHeartbeat"; private static final String TEMPLATE_METHOD_LOG_MESSAGE = "logMessage"; @@ -50,7 +53,7 @@ public class DynamicTaskService implements ApplicationRunner { @Override public void run(ApplicationArguments args) { var tasks = taskMapper.selectList( - new LambdaQueryWrapper().eq(ScheduledTaskEntity::getStatus, 1)); + new LambdaQueryWrapper().eq(ScheduledTaskEntity::getStatus, STATUS_RUNNING)); for (ScheduledTaskEntity task : tasks) { validateTaskTemplate(task); addTask(task); @@ -61,6 +64,11 @@ public class DynamicTaskService implements ApplicationRunner { public void addTask(ScheduledTaskEntity task) { validateTaskTemplate(task); withTaskLock(task.getId(), () -> { + if (!isRunningStatus(task.getStatus())) { + log.info("定时任务当前为暂停状态,不进行调度: taskId={}", task.getId()); + doRemoveTask(task.getId()); + return; + } if (isFutureActive(runningTasks.get(task.getId()))) { log.warn("定时任务已在运行,跳过重复启动: taskId={}", task.getId()); return; @@ -82,6 +90,10 @@ public class DynamicTaskService implements ApplicationRunner { public void restartTask(ScheduledTaskEntity task) { validateTaskTemplate(task); withTaskLock(task.getId(), () -> { + if (!isRunningStatus(task.getStatus())) { + doRemoveTask(task.getId()); + return; + } doRemoveTask(task.getId()); ScheduledFuture future = taskScheduler.schedule( () -> executeTask(task), new CronTrigger(task.getCronExpression())); @@ -107,34 +119,38 @@ public class DynamicTaskService implements ApplicationRunner { public void create(ScheduledTaskEntity task) { normalizeAndValidateTask(task); - task.setStatus(1); + task.setStatus(normalizeStatus(task.getStatus())); taskMapper.insert(task); - addTask(task); + if (isRunningStatus(task.getStatus())) { + addTask(task); + } } public void update(ScheduledTaskEntity task) { - ScheduledTaskEntity existing = requireTask(task.getId()); - normalizeAndValidateTask(task); - task.setStatus(task.getStatus() == null ? existing.getStatus() : task.getStatus()); - taskMapper.updateById(task); - ScheduledTaskEntity latest = requireTask(task.getId()); - if (Integer.valueOf(1).equals(latest.getStatus())) { - restartTask(latest); - } else { - removeTask(task.getId()); - } + withTaskLock(task.getId(), () -> { + ScheduledTaskEntity existing = requireTask(task.getId()); + normalizeAndValidateTask(task); + task.setStatus(task.getStatus() == null ? existing.getStatus() : normalizeStatus(task.getStatus())); + taskMapper.updateById(task); + ScheduledTaskEntity latest = requireTask(task.getId()); + if (isRunningStatus(latest.getStatus())) { + restartTask(latest); + return; + } + doRemoveTask(task.getId()); + }); } public void pause(Long taskId) { withTaskLock(taskId, () -> { ScheduledTaskEntity task = requireTask(taskId); - if (Integer.valueOf(0).equals(task.getStatus())) { + if (Integer.valueOf(STATUS_PAUSED).equals(task.getStatus())) { doRemoveTask(taskId); return; } ScheduledTaskEntity entity = new ScheduledTaskEntity(); entity.setId(taskId); - entity.setStatus(0); + entity.setStatus(STATUS_PAUSED); taskMapper.updateById(entity); doRemoveTask(taskId); }); @@ -144,27 +160,18 @@ public class DynamicTaskService implements ApplicationRunner { withTaskLock(taskId, () -> { ScheduledTaskEntity task = requireTask(taskId); validateTaskTemplate(task); - if (Integer.valueOf(1).equals(task.getStatus()) && isFutureActive(runningTasks.get(taskId))) { + if (isRunningStatus(task.getStatus()) && isFutureActive(runningTasks.get(taskId))) { log.warn("定时任务已恢复,跳过重复调度: taskId={}", taskId); return; } - if (!Integer.valueOf(1).equals(task.getStatus())) { + if (!isRunningStatus(task.getStatus())) { ScheduledTaskEntity entity = new ScheduledTaskEntity(); entity.setId(taskId); - entity.setStatus(1); + entity.setStatus(STATUS_RUNNING); taskMapper.updateById(entity); - task.setStatus(1); + task.setStatus(STATUS_RUNNING); } - ScheduledFuture future = taskScheduler.schedule( - () -> executeTask(task), new CronTrigger(task.getCronExpression())); - if (future == null) { - throw new BizException(ResultCode.INTERNAL_ERROR, "定时任务恢复失败"); - } - ScheduledFuture previous = runningTasks.put(taskId, future); - if (previous != null && previous != future) { - previous.cancel(false); - } - log.info("定时任务已启动: {} [{}]", task.getTaskName(), task.getCronExpression()); + addTask(task); }); } @@ -196,6 +203,15 @@ public class DynamicTaskService implements ApplicationRunner { if (!CronExpression.isValidExpression(task.getCronExpression())) { throw new BizException(ResultCode.BAD_REQUEST, "Cron 表达式非法"); } + task.setBeanName(StrUtil.trim(task.getBeanName())); + task.setMethodName(StrUtil.trim(task.getMethodName())); + task.setMethodParams(StrUtil.trimToNull(task.getMethodParams())); + if (task.getMethodParams() != null && task.getMethodParams().length() > 256) { + throw new BizException(ResultCode.BAD_REQUEST, "任务参数长度不能超过256"); + } + if (task.getStatus() != null && !isAllowedStatus(task.getStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "任务状态非法"); + } validateTaskTemplate(task); } @@ -230,6 +246,18 @@ public class DynamicTaskService implements ApplicationRunner { return future != null && !future.isCancelled() && !future.isDone(); } + private boolean isRunningStatus(Integer status) { + return Integer.valueOf(STATUS_RUNNING).equals(status); + } + + private boolean isAllowedStatus(Integer status) { + return Integer.valueOf(STATUS_RUNNING).equals(status) || Integer.valueOf(STATUS_PAUSED).equals(status); + } + + private int normalizeStatus(Integer status) { + return status == null ? STATUS_RUNNING : status; + } + private void withTaskLock(Long taskId, Runnable action) { if (taskId == null) { action.run(); diff --git a/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java b/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java index 46852ce..95e3c24 100644 --- a/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/test/demo/service/impl/AuthServiceImpl.java @@ -18,6 +18,7 @@ import com.test.demo.service.LoginLogService; import com.test.demo.service.MenuService; import com.test.demo.service.RoleService; import com.test.demo.service.UserService; +import com.test.demo.util.PasswordPolicyUtil; import com.test.demo.vo.LoginVo; import com.test.demo.vo.UserVo; import lombok.RequiredArgsConstructor; @@ -95,6 +96,7 @@ public class AuthServiceImpl implements AuthService { if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) { throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期"); } + PasswordPolicyUtil.validate(bo.getPassword()); UserEntity user = new UserEntity(); user.setUsername(bo.getUsername()); user.setNickname(bo.getNickname()); diff --git a/src/main/java/com/test/demo/service/impl/UserServiceImpl.java b/src/main/java/com/test/demo/service/impl/UserServiceImpl.java index 3b5c917..5b22d63 100644 --- a/src/main/java/com/test/demo/service/impl/UserServiceImpl.java +++ b/src/main/java/com/test/demo/service/impl/UserServiceImpl.java @@ -65,6 +65,7 @@ public class UserServiceImpl extends ServiceImpl impleme @Override public void add(UserBo bo) { + PasswordPolicyUtil.validate(bo.getPassword()); UserEntity entity = userConvert.toEntity(bo); String salt = SM3Util.generateSalt(); entity.setSalt(salt); @@ -146,7 +147,8 @@ public class UserServiceImpl extends ServiceImpl impleme entity.setEmail(row.getEmail()); entity.setPhone(row.getPhone()); entity.setAvatar(row.getAvatar()); - String rawPassword = StrUtil.blankToDefault(row.getPassword(), "123456"); + String rawPassword = StrUtil.blankToDefault(row.getPassword(), "ChangeMe@123"); + PasswordPolicyUtil.validate(rawPassword); String salt = SM3Util.generateSalt(); entity.setSalt(salt); entity.setPassword(SM3Util.hash(rawPassword, salt)); diff --git a/src/main/java/com/test/demo/util/FilePathUtil.java b/src/main/java/com/test/demo/util/FilePathUtil.java index e19bfc4..821f7c8 100644 --- a/src/main/java/com/test/demo/util/FilePathUtil.java +++ b/src/main/java/com/test/demo/util/FilePathUtil.java @@ -2,9 +2,7 @@ package com.test.demo.util; import cn.hutool.core.util.StrUtil; -import java.io.IOException; import java.nio.file.InvalidPathException; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.Paths; @@ -27,25 +25,14 @@ public final class FilePathUtil { } try { Path normalizedBasePath = Paths.get(basePath).toAbsolutePath().normalize(); - Path normalizedResolvedPath = normalizedBasePath.resolve(filePath).normalize(); - Path realBasePath = toRealPathIfPossible(normalizedBasePath); - Path realResolvedPath = toRealPathIfPossible(normalizedResolvedPath); - return realResolvedPath.startsWith(realBasePath); - } catch (InvalidPathException | IOException ex) { - return false; - } - } - - private static Path toRealPathIfPossible(Path path) throws IOException { - try { - return path.toRealPath(LinkOption.NOFOLLOW_LINKS); - } catch (IOException ex) { - Path parent = path.getParent(); - if (parent == null) { - throw ex; + Path requestedPath = Paths.get(filePath).normalize(); + if (requestedPath.isAbsolute()) { + return false; } - Path realParent = parent.toRealPath(LinkOption.NOFOLLOW_LINKS); - return realParent.resolve(path.getFileName()).normalize(); + Path normalizedResolvedPath = normalizedBasePath.resolve(requestedPath).normalize(); + return normalizedResolvedPath.startsWith(normalizedBasePath); + } catch (InvalidPathException ex) { + return false; } } diff --git a/src/main/java/com/test/demo/util/PasswordPolicyUtil.java b/src/main/java/com/test/demo/util/PasswordPolicyUtil.java new file mode 100644 index 0000000..5249259 --- /dev/null +++ b/src/main/java/com/test/demo/util/PasswordPolicyUtil.java @@ -0,0 +1,34 @@ +package com.test.demo.util; + +import cn.hutool.core.util.StrUtil; +import com.test.demo.common.exception.BizException; +import com.test.demo.common.result.ResultCode; + +/** + * 密码强度校验工具 + */ +public final class PasswordPolicyUtil { + + private PasswordPolicyUtil() {} + + public static void validate(String password) { + if (StrUtil.isBlank(password)) { + throw new BizException(ResultCode.BAD_REQUEST, "密码不能为空"); + } + if (password.length() < 8 || password.length() > 32) { + throw new BizException(ResultCode.BAD_REQUEST, "密码长度需为 8-32 位"); + } + if (!password.matches(".*[A-Z].*")) { + throw new BizException(ResultCode.BAD_REQUEST, "密码需包含大写字母"); + } + if (!password.matches(".*[a-z].*")) { + throw new BizException(ResultCode.BAD_REQUEST, "密码需包含小写字母"); + } + if (!password.matches(".*\\d.*")) { + throw new BizException(ResultCode.BAD_REQUEST, "密码需包含数字"); + } + if (!password.matches(".*[^A-Za-z0-9].*")) { + throw new BizException(ResultCode.BAD_REQUEST, "密码需包含特殊字符"); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5103a18..b45a412 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,11 +62,6 @@ sa-token: token-style: uuid is-log: false -# 文件上传路径 -file: - upload-path: /data/upload - preview-url: /api/file/preview - # 接口签名配置 security: sign: @@ -89,3 +84,6 @@ management: app: cors: allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:} + file: + upload-path: /data/upload + preview-url: /api/file/preview diff --git a/src/main/resources/sql/init-auth-schema.sql b/src/main/resources/sql/init-auth-schema.sql index 2c78928..087a3b8 100644 --- a/src/main/resources/sql/init-auth-schema.sql +++ b/src/main/resources/sql/init-auth-schema.sql @@ -5,6 +5,22 @@ USE `demo`; +SET @need_uk_username = ( + SELECT CASE WHEN COUNT(1) = 0 THEN 1 ELSE 0 END + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'sys_user' + AND index_name = 'uk_username' +); +SET @sql_uk_username = IF( + @need_uk_username = 1, + 'ALTER TABLE `sys_user` ADD UNIQUE KEY `uk_username` (`username`)', + 'SELECT 1' +); +PREPARE stmt_uk_username FROM @sql_uk_username; +EXECUTE stmt_uk_username; +DEALLOCATE PREPARE stmt_uk_username; + CREATE TABLE IF NOT EXISTS `sys_role` ( `id` BIGINT NOT NULL COMMENT '主键ID', `role_code` VARCHAR(64) NOT NULL COMMENT '角色编码',