fix: harden request and task security

This commit is contained in:
root
2026-03-17 00:57:08 +08:00
parent 7af4abea43
commit 6ec0c3d77b
20 changed files with 403 additions and 97 deletions

View File

@@ -1,6 +1,9 @@
package com.test.demo.aspect; package com.test.demo.aspect;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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.bo.LoginBo;
import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Log;
import com.test.demo.entity.OperLogEntity; 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.log.event.OperLogEvent;
import com.test.demo.security.SecurityUtil; import com.test.demo.security.SecurityUtil;
import com.test.demo.service.UserService; import com.test.demo.service.UserService;
import com.test.demo.util.DesensitizeUtil;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint; 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.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature; import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.validation.BindingResult;
import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Array;
import java.time.LocalDateTime; 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 @RequiredArgsConstructor
public class LogAspect { public class LogAspect {
private static final Set<String> SENSITIVE_FIELDS = Set.of(
"password", "newpassword", "oldpassword", "token", "authorization",
"secret", "captcha", "captchacode", "captchakey", "accesstoken", "refreshtoken"
);
private static final Set<String> EMAIL_FIELDS = Set.of("email");
private static final Set<String> PHONE_FIELDS = Set.of("phone", "mobile", "phonenumber");
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final UserService userService; private final UserService userService;
@@ -42,7 +64,6 @@ public class LogAspect {
operLog.setTitle(logAnnotation.value()); operLog.setTitle(logAnnotation.value());
operLog.setLogType(logAnnotation.type().name()); operLog.setLogType(logAnnotation.type().name());
// 请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) { if (attributes != null) {
HttpServletRequest request = attributes.getRequest(); HttpServletRequest request = attributes.getRequest();
@@ -51,19 +72,20 @@ public class LogAspect {
operLog.setIp(getClientIp(request)); operLog.setIp(getClientIp(request));
} }
// 方法信息
MethodSignature signature = (MethodSignature) point.getSignature(); MethodSignature signature = (MethodSignature) point.getSignature();
operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName()); operLog.setMethod(signature.getDeclaringTypeName() + "." + signature.getName());
// 请求参数(脱敏) if (logAnnotation.recordRequestParam()) {
try { try {
String params = objectMapper.writeValueAsString(point.getArgs()); String params = objectMapper.writeValueAsString(maskMethodArguments(signature, point.getArgs()));
operLog.setRequestParam(truncate(params, 2000)); operLog.setRequestParam(truncate(params, 2000));
} catch (Exception e) { } catch (Exception e) {
operLog.setRequestParam("参数序列化失败"); operLog.setRequestParam("参数序列化失败");
} }
} else {
operLog.setRequestParam("<omitted>");
}
// 用户信息
try { try {
Long userId = SecurityUtil.getUserIdOrNull(); Long userId = SecurityUtil.getUserIdOrNull();
operLog.setUserId(userId); operLog.setUserId(userId);
@@ -73,17 +95,18 @@ public class LogAspect {
operLog.setUserName(user.getUsername()); operLog.setUserName(user.getUsername());
} }
} }
} catch (Exception ignored) {} } catch (Exception ignored) {
}
Object result = null; Object result = null;
try { try {
result = point.proceed(); result = point.proceed();
operLog.setStatus(1); operLog.setStatus(1);
// 返回结果(脱敏)
try { try {
String resultStr = objectMapper.writeValueAsString(result); String resultStr = objectMapper.writeValueAsString(maskSensitiveValue(result));
operLog.setResponseResult(truncate(resultStr, 2000)); operLog.setResponseResult(truncate(resultStr, 2000));
} catch (Exception ignored) {} } catch (Exception ignored) {
}
} catch (Throwable e) { } catch (Throwable e) {
operLog.setStatus(0); operLog.setStatus(0);
operLog.setErrorMsg(truncate(e.getMessage(), 2000)); operLog.setErrorMsg(truncate(e.getMessage(), 2000));
@@ -105,7 +128,6 @@ public class LogAspect {
operLog.setCostTime(System.currentTimeMillis() - startTime); operLog.setCostTime(System.currentTimeMillis() - startTime);
operLog.setCreateTime(LocalDateTime.now()); operLog.setCreateTime(LocalDateTime.now());
// 日志输出
switch (logAnnotation.level()) { switch (logAnnotation.level()) {
case DEBUG -> log.debug("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime()); case DEBUG -> log.debug("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
case WARN -> log.warn("[{}] {} 耗时:{}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()); default -> log.info("[{}] {} 耗时:{}ms", logAnnotation.type(), logAnnotation.value(), operLog.getCostTime());
} }
// 异步写入数据库
if (logAnnotation.saveToDB()) { if (logAnnotation.saveToDB()) {
eventPublisher.publishEvent(new OperLogEvent(this, operLog)); eventPublisher.publishEvent(new OperLogEvent(this, operLog));
} }
@@ -121,6 +142,132 @@ public class LogAspect {
return result; 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<Map.Entry<String, JsonNode>> fields = node.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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) { private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For"); String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
@@ -129,7 +276,6 @@ public class LogAspect {
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr(); ip = request.getRemoteAddr();
} }
// 多级代理取第一个
if (ip != null && ip.contains(",")) { if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim(); ip = ip.split(",")[0].trim();
} }
@@ -137,7 +283,9 @@ public class LogAspect {
} }
private String truncate(String str, int maxLen) { 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; return str.length() > maxLen ? str.substring(0, maxLen) : str;
} }
@@ -158,7 +306,8 @@ public class LogAspect {
if (username != null) { if (username != null) {
return String.valueOf(username); return String.valueOf(username);
} }
} catch (Exception ignored) {} } catch (Exception ignored) {
}
} }
return null; return null;
} }

View File

@@ -21,6 +21,9 @@ public @interface Log {
/** 是否写入数据库 */ /** 是否写入数据库 */
boolean saveToDB() default false; boolean saveToDB() default false;
/** 是否记录请求参数 */
boolean recordRequestParam() default true;
/** 日志等级 */ /** 日志等级 */
LogLevel level() default LogLevel.INFO; LogLevel level() default LogLevel.INFO;

View File

@@ -1,8 +1,10 @@
package com.test.demo.config; package com.test.demo.config;
import jakarta.annotation.PostConstruct;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List; import java.util.List;
@@ -24,4 +26,20 @@ public class FileConfig {
"txt", "csv", "zip", "rar", "7z", "txt", "csv", "zip", "rar", "7z",
"mp4", "avi", "mov", "mp3", "wav" "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 配置非法");
}
}
} }

View File

@@ -65,7 +65,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
StpUtil.checkLogin(); StpUtil.checkLogin();
StpUtil.checkRole("ADMIN"); StpUtil.checkRole("ADMIN");
})) }))
.addPathPatterns("/actuator/**"); .addPathPatterns("/actuator/**")
.excludePathPatterns("/actuator/health", "/actuator/health/**");
registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
.addPathPatterns("/**") .addPathPatterns("/**")
@@ -75,7 +76,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/captcha/**", "/captcha/**",
"/file/preview/**", "/file/preview/**",
"/enums/**", "/enums/**",
"/actuator/**", "/actuator/health",
"/actuator/health/**",
"/doc.html", "/doc.html",
"/webjars/**", "/webjars/**",
"/v3/api-docs/**", "/v3/api-docs/**",

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.EnumItemBo; import com.test.demo.bo.EnumItemBo;
import com.test.demo.bo.EnumTypeBo; import com.test.demo.bo.EnumTypeBo;
import com.test.demo.bo.EnumTypeQueryBo; import com.test.demo.bo.EnumTypeQueryBo;
@@ -30,6 +31,7 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/enum-manage") @RequestMapping("/enum-manage")
@RequiredArgsConstructor @RequiredArgsConstructor
@SaCheckRole("ADMIN")
@Tag(name = "枚举字典管理") @Tag(name = "枚举字典管理")
public class EnumManageController { public class EnumManageController {

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.FileQueryBo; import com.test.demo.bo.FileQueryBo;
import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Log;
import com.test.demo.common.base.PageResult; import com.test.demo.common.base.PageResult;
@@ -49,7 +50,8 @@ public class FileController {
@PostMapping("/upload") @PostMapping("/upload")
@Operation(summary = "上传文件") @Operation(summary = "上传文件")
@SaCheckPermission(PermissionConstant.FILE_UPLOAD) @SaCheckPermission(PermissionConstant.FILE_UPLOAD)
@Log(value = "上传文件", type = LogType.OPER) @SaCheckRole("ADMIN")
@Log(value = "上传文件", type = LogType.OPER, recordRequestParam = false)
public R<String> upload( public R<String> upload(
@Parameter(description = "上传文件") @Parameter(description = "上传文件")
@RequestParam("file") MultipartFile file) { @RequestParam("file") MultipartFile file) {
@@ -113,6 +115,7 @@ public class FileController {
@PostMapping("/delete/{*filePath}") @PostMapping("/delete/{*filePath}")
@Operation(summary = "删除文件") @Operation(summary = "删除文件")
@SaCheckPermission(PermissionConstant.FILE_DELETE) @SaCheckPermission(PermissionConstant.FILE_DELETE)
@SaCheckRole("ADMIN")
@Log(value = "删除文件", type = LogType.OPER, saveToDB = true) @Log(value = "删除文件", type = LogType.OPER, saveToDB = true)
public R<Boolean> delete( public R<Boolean> delete(
@Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx") @Parameter(description = "文件相对路径", example = "2026/03/15/demo.xlsx")

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.LoginLogQueryBo; import com.test.demo.bo.LoginLogQueryBo;
import com.test.demo.bo.OperLogQueryBo; import com.test.demo.bo.OperLogQueryBo;
import com.test.demo.common.base.PageResult; import com.test.demo.common.base.PageResult;
@@ -25,6 +26,7 @@ import java.io.IOException;
@RestController @RestController
@RequestMapping("/log") @RequestMapping("/log")
@RequiredArgsConstructor @RequiredArgsConstructor
@SaCheckRole("ADMIN")
@Tag(name = "日志管理") @Tag(name = "日志管理")
public class LogController { public class LogController {

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.MenuBo; import com.test.demo.bo.MenuBo;
import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent; import com.test.demo.common.annotation.Idempotent;
@@ -33,6 +34,7 @@ public class MenuController {
@PostMapping("/list") @PostMapping("/list")
@Operation(summary = "查询菜单权限列表") @Operation(summary = "查询菜单权限列表")
@SaCheckPermission(PermissionConstant.MENU_LIST) @SaCheckPermission(PermissionConstant.MENU_LIST)
@SaCheckRole("ADMIN")
public R<List<MenuEntity>> list() { public R<List<MenuEntity>> list() {
return R.ok(menuService.listAll()); return R.ok(menuService.listAll());
} }
@@ -52,6 +54,7 @@ public class MenuController {
@PostMapping("/add") @PostMapping("/add")
@Operation(summary = "新增菜单权限") @Operation(summary = "新增菜单权限")
@SaCheckPermission(PermissionConstant.MENU_ADD) @SaCheckPermission(PermissionConstant.MENU_ADD)
@SaCheckRole("ADMIN")
@Idempotent(message = "请勿重复新增菜单") @Idempotent(message = "请勿重复新增菜单")
@Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true) @Log(value = "新增菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) { public R<Void> add(@RequestBody @Validated(ValidGroup.Add.class) MenuBo bo) {
@@ -62,6 +65,7 @@ public class MenuController {
@PostMapping("/edit") @PostMapping("/edit")
@Operation(summary = "编辑菜单权限") @Operation(summary = "编辑菜单权限")
@SaCheckPermission(PermissionConstant.MENU_EDIT) @SaCheckPermission(PermissionConstant.MENU_EDIT)
@SaCheckRole("ADMIN")
@Idempotent(message = "请勿重复编辑菜单") @Idempotent(message = "请勿重复编辑菜单")
@Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true) @Log(value = "编辑菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) { public R<Void> edit(@RequestBody @Validated(ValidGroup.Update.class) MenuBo bo) {
@@ -72,6 +76,7 @@ public class MenuController {
@PostMapping("/remove/{id}") @PostMapping("/remove/{id}")
@Operation(summary = "删除菜单权限") @Operation(summary = "删除菜单权限")
@SaCheckPermission(PermissionConstant.MENU_REMOVE) @SaCheckPermission(PermissionConstant.MENU_REMOVE)
@SaCheckRole("ADMIN")
@Idempotent(message = "请勿重复删除菜单") @Idempotent(message = "请勿重复删除菜单")
@Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true) @Log(value = "删除菜单权限", type = LogType.OPER, saveToDB = true)
public R<Void> remove(@PathVariable Long id) { public R<Void> remove(@PathVariable Long id) {

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.RoleBo; import com.test.demo.bo.RoleBo;
import com.test.demo.bo.RoleMenuAssignBo; import com.test.demo.bo.RoleMenuAssignBo;
import com.test.demo.bo.RoleQueryBo; import com.test.demo.bo.RoleQueryBo;
@@ -29,6 +30,7 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/role") @RequestMapping("/role")
@RequiredArgsConstructor @RequiredArgsConstructor
@SaCheckRole("ADMIN")
@Tag(name = "角色管理") @Tag(name = "角色管理")
public class RoleController { public class RoleController {

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.test.demo.bo.TaskQueryBo; import com.test.demo.bo.TaskQueryBo;
import com.test.demo.common.annotation.Log; import com.test.demo.common.annotation.Log;
import com.test.demo.common.annotation.Idempotent; import com.test.demo.common.annotation.Idempotent;
@@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/task") @RequestMapping("/task")
@RequiredArgsConstructor @RequiredArgsConstructor
@SaCheckRole("ADMIN")
@Tag(name = "定时任务管理") @Tag(name = "定时任务管理")
public class TaskController { public class TaskController {

View File

@@ -1,6 +1,7 @@
package com.test.demo.controller; package com.test.demo.controller;
import cn.dev33.satoken.annotation.SaCheckPermission; 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.Log;
import com.test.demo.common.annotation.RateLimit; import com.test.demo.common.annotation.RateLimit;
import com.test.demo.common.annotation.Idempotent; 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.UserBo;
import com.test.demo.bo.UserQueryBo; import com.test.demo.bo.UserQueryBo;
import com.test.demo.service.UserService; import com.test.demo.service.UserService;
import com.test.demo.util.PasswordPolicyUtil;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import com.test.demo.vo.UserVo; import com.test.demo.vo.UserVo;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -28,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
@RestController @RestController
@RequestMapping("/user") @RequestMapping("/user")
@RequiredArgsConstructor @RequiredArgsConstructor
@SaCheckRole("ADMIN")
@Tag(name = "用户管理") @Tag(name = "用户管理")
public class UserController { public class UserController {
@@ -90,12 +93,13 @@ public class UserController {
@Operation(summary = "重置密码") @Operation(summary = "重置密码")
@SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD) @SaCheckPermission(PermissionConstant.USER_RESET_PASSWORD)
@Idempotent(message = "请勿重复重置密码") @Idempotent(message = "请勿重复重置密码")
@Log(value = "重置密码", type = LogType.OPER, saveToDB = true) @Log(value = "重置密码", type = LogType.OPER, saveToDB = true, recordRequestParam = false)
public R<Void> resetPassword( public R<Void> resetPassword(
@Parameter(description = "用户ID", example = "1") @Parameter(description = "用户ID", example = "1")
@PathVariable Long id, @PathVariable Long id,
@Parameter(description = "新密码", example = "123456") @Parameter(description = "新密码", example = "123456")
@RequestParam String newPassword) { @RequestParam String newPassword) {
PasswordPolicyUtil.validate(newPassword);
userService.resetPassword(id, newPassword); userService.resetPassword(id, newPassword);
return R.ok(); return R.ok();
} }
@@ -104,7 +108,7 @@ public class UserController {
@Operation(summary = "导入用户") @Operation(summary = "导入用户")
@SaCheckPermission(PermissionConstant.USER_IMPORT) @SaCheckPermission(PermissionConstant.USER_IMPORT)
@Idempotent(message = "请勿重复导入用户") @Idempotent(message = "请勿重复导入用户")
@Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true) @Log(value = "导入用户", type = LogType.IMPORT, saveToDB = true, recordRequestParam = false)
public R<Void> importUsers( public R<Void> importUsers(
@Parameter(description = "用户导入Excel文件") @Parameter(description = "用户导入Excel文件")
@RequestParam("file") MultipartFile file) { @RequestParam("file") MultipartFile file) {

View File

@@ -7,6 +7,7 @@ import com.test.demo.common.constant.RedisKeyConstant;
import com.test.demo.common.exception.BizException; import com.test.demo.common.exception.BizException;
import com.test.demo.common.result.ResultCode; import com.test.demo.common.result.ResultCode;
import com.test.demo.filter.RepeatableReadRequestWrapper; import com.test.demo.filter.RepeatableReadRequestWrapper;
import com.test.demo.security.SecurityUtil;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -40,10 +41,7 @@ public class IdempotentInterceptor implements HandlerInterceptor {
return true; return true;
} }
String token = request.getHeader(CommonConstant.HEADER_IDEMPOTENT_TOKEN); String token = resolveRequestIdentity(request);
if (token == null || token.isBlank()) {
token = request.getHeader("Authorization");
}
String url = request.getRequestURI(); String url = request.getRequestURI();
String queryString = request.getQueryString() != null ? request.getQueryString() : ""; String queryString = request.getQueryString() != null ? request.getQueryString() : "";
String body = ""; String body = "";
@@ -51,15 +49,8 @@ public class IdempotentInterceptor implements HandlerInterceptor {
body = new String(wrapper.getBody(), StandardCharsets.UTF_8); body = new String(wrapper.getBody(), StandardCharsets.UTF_8);
} }
String bodyHash = DigestUtil.sha256Hex(body); String bodyHash = DigestUtil.sha256Hex(body);
String tokenPart; String fingerprint = request.getMethod() + ":" + url + "?" + queryString + ":" + bodyHash;
if (token == null || token.isBlank()) { String key = RedisKeyConstant.IDEMPOTENT + DigestUtil.sha256Hex(token + ":" + fingerprint);
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);
Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS); Boolean absent = redisTemplate.opsForValue().setIfAbsent(key, "1", idempotent.expireTime(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(absent)) { if (Boolean.FALSE.equals(absent)) {
@@ -69,6 +60,26 @@ public class IdempotentInterceptor implements HandlerInterceptor {
return true; 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) { private String firstNonBlank(String first, String second) {
if (first != null && !first.isBlank()) { if (first != null && !first.isBlank()) {
return first.split(",")[0].trim(); return first.split(",")[0].trim();

View File

@@ -13,9 +13,12 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
@@ -26,7 +29,22 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor @RequiredArgsConstructor
public class SignatureInterceptor implements HandlerInterceptor { public class SignatureInterceptor implements HandlerInterceptor {
private static final List<String> 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<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Value("${security.sign.secret:change-me-sign-secret}") @Value("${security.sign.secret:change-me-sign-secret}")
private String signSecret; private String signSecret;
@@ -39,27 +57,32 @@ public class SignatureInterceptor implements HandlerInterceptor {
String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP); String timestamp = request.getHeader(CommonConstant.HEADER_TIMESTAMP);
String nonce = request.getHeader(CommonConstant.HEADER_NONCE); String nonce = request.getHeader(CommonConstant.HEADER_NONCE);
String sign = request.getHeader(CommonConstant.HEADER_SIGN); String sign = request.getHeader(CommonConstant.HEADER_SIGN);
boolean protectedEndpoint = requiresSignature(request);
// 缺少签名头则跳过(可根据需求改为拒绝) if (!StringUtils.hasText(timestamp) || !StringUtils.hasText(nonce) || !StringUtils.hasText(sign)) {
if (timestamp == null || nonce == null || sign == null) { if (protectedEndpoint) {
throw new BizException(ResultCode.SIGNATURE_INVALID.getCode(), "受保护接口缺少签名请求头");
}
return true; return true;
} }
// 时间戳校验 long ts;
long ts = Long.parseLong(timestamp); try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException ex) {
throw new BizException(ResultCode.BAD_REQUEST, "签名时间戳格式错误");
}
long now = System.currentTimeMillis() / 1000; long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - ts) > expireSeconds) { if (Math.abs(now - ts) > expireSeconds) {
throw new BizException(ResultCode.REPLAY_ATTACK); throw new BizException(ResultCode.REPLAY_ATTACK);
} }
// nonce 防重放
String nonceKey = RedisKeyConstant.NONCE + nonce; String nonceKey = RedisKeyConstant.NONCE + nonce;
Boolean absent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", expireSeconds, TimeUnit.SECONDS); Boolean absent = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", expireSeconds, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(absent)) { if (Boolean.FALSE.equals(absent)) {
throw new BizException(ResultCode.REPLAY_ATTACK); throw new BizException(ResultCode.REPLAY_ATTACK);
} }
// 签名校验
String body = ""; String body = "";
if (request instanceof RepeatableReadRequestWrapper wrapper) { if (request instanceof RepeatableReadRequestWrapper wrapper) {
body = new String(wrapper.getBody(), StandardCharsets.UTF_8); body = new String(wrapper.getBody(), StandardCharsets.UTF_8);
@@ -72,4 +95,17 @@ public class SignatureInterceptor implements HandlerInterceptor {
return true; 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;
}
} }

View File

@@ -32,6 +32,9 @@ import java.util.function.Consumer;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DynamicTaskService implements ApplicationRunner { 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_BEAN_NAME = "dynamicTaskService";
private static final String TEMPLATE_METHOD_HEARTBEAT = "logHeartbeat"; private static final String TEMPLATE_METHOD_HEARTBEAT = "logHeartbeat";
private static final String TEMPLATE_METHOD_LOG_MESSAGE = "logMessage"; private static final String TEMPLATE_METHOD_LOG_MESSAGE = "logMessage";
@@ -50,7 +53,7 @@ public class DynamicTaskService implements ApplicationRunner {
@Override @Override
public void run(ApplicationArguments args) { public void run(ApplicationArguments args) {
var tasks = taskMapper.selectList( var tasks = taskMapper.selectList(
new LambdaQueryWrapper<ScheduledTaskEntity>().eq(ScheduledTaskEntity::getStatus, 1)); new LambdaQueryWrapper<ScheduledTaskEntity>().eq(ScheduledTaskEntity::getStatus, STATUS_RUNNING));
for (ScheduledTaskEntity task : tasks) { for (ScheduledTaskEntity task : tasks) {
validateTaskTemplate(task); validateTaskTemplate(task);
addTask(task); addTask(task);
@@ -61,6 +64,11 @@ public class DynamicTaskService implements ApplicationRunner {
public void addTask(ScheduledTaskEntity task) { public void addTask(ScheduledTaskEntity task) {
validateTaskTemplate(task); validateTaskTemplate(task);
withTaskLock(task.getId(), () -> { withTaskLock(task.getId(), () -> {
if (!isRunningStatus(task.getStatus())) {
log.info("定时任务当前为暂停状态,不进行调度: taskId={}", task.getId());
doRemoveTask(task.getId());
return;
}
if (isFutureActive(runningTasks.get(task.getId()))) { if (isFutureActive(runningTasks.get(task.getId()))) {
log.warn("定时任务已在运行,跳过重复启动: taskId={}", task.getId()); log.warn("定时任务已在运行,跳过重复启动: taskId={}", task.getId());
return; return;
@@ -82,6 +90,10 @@ public class DynamicTaskService implements ApplicationRunner {
public void restartTask(ScheduledTaskEntity task) { public void restartTask(ScheduledTaskEntity task) {
validateTaskTemplate(task); validateTaskTemplate(task);
withTaskLock(task.getId(), () -> { withTaskLock(task.getId(), () -> {
if (!isRunningStatus(task.getStatus())) {
doRemoveTask(task.getId());
return;
}
doRemoveTask(task.getId()); doRemoveTask(task.getId());
ScheduledFuture<?> future = taskScheduler.schedule( ScheduledFuture<?> future = taskScheduler.schedule(
() -> executeTask(task), new CronTrigger(task.getCronExpression())); () -> executeTask(task), new CronTrigger(task.getCronExpression()));
@@ -107,34 +119,38 @@ public class DynamicTaskService implements ApplicationRunner {
public void create(ScheduledTaskEntity task) { public void create(ScheduledTaskEntity task) {
normalizeAndValidateTask(task); normalizeAndValidateTask(task);
task.setStatus(1); task.setStatus(normalizeStatus(task.getStatus()));
taskMapper.insert(task); taskMapper.insert(task);
if (isRunningStatus(task.getStatus())) {
addTask(task); addTask(task);
} }
}
public void update(ScheduledTaskEntity task) { public void update(ScheduledTaskEntity task) {
withTaskLock(task.getId(), () -> {
ScheduledTaskEntity existing = requireTask(task.getId()); ScheduledTaskEntity existing = requireTask(task.getId());
normalizeAndValidateTask(task); normalizeAndValidateTask(task);
task.setStatus(task.getStatus() == null ? existing.getStatus() : task.getStatus()); task.setStatus(task.getStatus() == null ? existing.getStatus() : normalizeStatus(task.getStatus()));
taskMapper.updateById(task); taskMapper.updateById(task);
ScheduledTaskEntity latest = requireTask(task.getId()); ScheduledTaskEntity latest = requireTask(task.getId());
if (Integer.valueOf(1).equals(latest.getStatus())) { if (isRunningStatus(latest.getStatus())) {
restartTask(latest); restartTask(latest);
} else { return;
removeTask(task.getId());
} }
doRemoveTask(task.getId());
});
} }
public void pause(Long taskId) { public void pause(Long taskId) {
withTaskLock(taskId, () -> { withTaskLock(taskId, () -> {
ScheduledTaskEntity task = requireTask(taskId); ScheduledTaskEntity task = requireTask(taskId);
if (Integer.valueOf(0).equals(task.getStatus())) { if (Integer.valueOf(STATUS_PAUSED).equals(task.getStatus())) {
doRemoveTask(taskId); doRemoveTask(taskId);
return; return;
} }
ScheduledTaskEntity entity = new ScheduledTaskEntity(); ScheduledTaskEntity entity = new ScheduledTaskEntity();
entity.setId(taskId); entity.setId(taskId);
entity.setStatus(0); entity.setStatus(STATUS_PAUSED);
taskMapper.updateById(entity); taskMapper.updateById(entity);
doRemoveTask(taskId); doRemoveTask(taskId);
}); });
@@ -144,27 +160,18 @@ public class DynamicTaskService implements ApplicationRunner {
withTaskLock(taskId, () -> { withTaskLock(taskId, () -> {
ScheduledTaskEntity task = requireTask(taskId); ScheduledTaskEntity task = requireTask(taskId);
validateTaskTemplate(task); 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); log.warn("定时任务已恢复,跳过重复调度: taskId={}", taskId);
return; return;
} }
if (!Integer.valueOf(1).equals(task.getStatus())) { if (!isRunningStatus(task.getStatus())) {
ScheduledTaskEntity entity = new ScheduledTaskEntity(); ScheduledTaskEntity entity = new ScheduledTaskEntity();
entity.setId(taskId); entity.setId(taskId);
entity.setStatus(1); entity.setStatus(STATUS_RUNNING);
taskMapper.updateById(entity); taskMapper.updateById(entity);
task.setStatus(1); task.setStatus(STATUS_RUNNING);
} }
ScheduledFuture<?> future = taskScheduler.schedule( addTask(task);
() -> 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());
}); });
} }
@@ -196,6 +203,15 @@ public class DynamicTaskService implements ApplicationRunner {
if (!CronExpression.isValidExpression(task.getCronExpression())) { if (!CronExpression.isValidExpression(task.getCronExpression())) {
throw new BizException(ResultCode.BAD_REQUEST, "Cron 表达式非法"); 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); validateTaskTemplate(task);
} }
@@ -230,6 +246,18 @@ public class DynamicTaskService implements ApplicationRunner {
return future != null && !future.isCancelled() && !future.isDone(); 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) { private void withTaskLock(Long taskId, Runnable action) {
if (taskId == null) { if (taskId == null) {
action.run(); action.run();

View File

@@ -18,6 +18,7 @@ import com.test.demo.service.LoginLogService;
import com.test.demo.service.MenuService; import com.test.demo.service.MenuService;
import com.test.demo.service.RoleService; import com.test.demo.service.RoleService;
import com.test.demo.service.UserService; import com.test.demo.service.UserService;
import com.test.demo.util.PasswordPolicyUtil;
import com.test.demo.vo.LoginVo; import com.test.demo.vo.LoginVo;
import com.test.demo.vo.UserVo; import com.test.demo.vo.UserVo;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -95,6 +96,7 @@ public class AuthServiceImpl implements AuthService {
if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) { if (!captchaService.verify(bo.getCaptchaKey(), bo.getCaptchaCode())) {
throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期"); throw new BizException(ResultCode.BAD_REQUEST, "验证码错误或已过期");
} }
PasswordPolicyUtil.validate(bo.getPassword());
UserEntity user = new UserEntity(); UserEntity user = new UserEntity();
user.setUsername(bo.getUsername()); user.setUsername(bo.getUsername());
user.setNickname(bo.getNickname()); user.setNickname(bo.getNickname());

View File

@@ -65,6 +65,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
@Override @Override
public void add(UserBo bo) { public void add(UserBo bo) {
PasswordPolicyUtil.validate(bo.getPassword());
UserEntity entity = userConvert.toEntity(bo); UserEntity entity = userConvert.toEntity(bo);
String salt = SM3Util.generateSalt(); String salt = SM3Util.generateSalt();
entity.setSalt(salt); entity.setSalt(salt);
@@ -146,7 +147,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
entity.setEmail(row.getEmail()); entity.setEmail(row.getEmail());
entity.setPhone(row.getPhone()); entity.setPhone(row.getPhone());
entity.setAvatar(row.getAvatar()); 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(); String salt = SM3Util.generateSalt();
entity.setSalt(salt); entity.setSalt(salt);
entity.setPassword(SM3Util.hash(rawPassword, salt)); entity.setPassword(SM3Util.hash(rawPassword, salt));

View File

@@ -2,9 +2,7 @@ package com.test.demo.util;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import java.io.IOException;
import java.nio.file.InvalidPathException; import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
@@ -27,25 +25,14 @@ public final class FilePathUtil {
} }
try { try {
Path normalizedBasePath = Paths.get(basePath).toAbsolutePath().normalize(); Path normalizedBasePath = Paths.get(basePath).toAbsolutePath().normalize();
Path normalizedResolvedPath = normalizedBasePath.resolve(filePath).normalize(); Path requestedPath = Paths.get(filePath).normalize();
Path realBasePath = toRealPathIfPossible(normalizedBasePath); if (requestedPath.isAbsolute()) {
Path realResolvedPath = toRealPathIfPossible(normalizedResolvedPath);
return realResolvedPath.startsWith(realBasePath);
} catch (InvalidPathException | IOException ex) {
return false; return false;
} }
} Path normalizedResolvedPath = normalizedBasePath.resolve(requestedPath).normalize();
return normalizedResolvedPath.startsWith(normalizedBasePath);
private static Path toRealPathIfPossible(Path path) throws IOException { } catch (InvalidPathException ex) {
try { return false;
return path.toRealPath(LinkOption.NOFOLLOW_LINKS);
} catch (IOException ex) {
Path parent = path.getParent();
if (parent == null) {
throw ex;
}
Path realParent = parent.toRealPath(LinkOption.NOFOLLOW_LINKS);
return realParent.resolve(path.getFileName()).normalize();
} }
} }

View File

@@ -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, "密码需包含特殊字符");
}
}
}

View File

@@ -62,11 +62,6 @@ sa-token:
token-style: uuid token-style: uuid
is-log: false is-log: false
# 文件上传路径
file:
upload-path: /data/upload
preview-url: /api/file/preview
# 接口签名配置 # 接口签名配置
security: security:
sign: sign:
@@ -89,3 +84,6 @@ management:
app: app:
cors: cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:} allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:}
file:
upload-path: /data/upload
preview-url: /api/file/preview

View File

@@ -5,6 +5,22 @@
USE `demo`; 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` ( CREATE TABLE IF NOT EXISTS `sys_role` (
`id` BIGINT NOT NULL COMMENT '主键ID', `id` BIGINT NOT NULL COMMENT '主键ID',
`role_code` VARCHAR(64) NOT NULL COMMENT '角色编码', `role_code` VARCHAR(64) NOT NULL COMMENT '角色编码',