Pārlūkot izejas kodu

fix(P0): 修复安全与权限问题

- 修复 /auth/refresh 返回旧 token 的 bug,刷新后将旧 token 加入黑名单
- ProcessInstanceController 增加认证与管理员角色控制
- 流程实例详情增加可见性校验,防止越权查看他人流程
- JWT secret 移除生产/开发默认 fallback,启动时校验长度和默认值
- 文件下载不再静态公开,统一走 FileDownloadController 按流程实例可见性鉴权
- CORS 改为读取 CORS_ALLOWED_ORIGINS,默认仅允许本地开发地址
- 后端编译通过
ye-zhaojia 1 nedēļu atpakaļ
vecāks
revīzija
0d8ab3588b

+ 1 - 0
docker-compose.yml

@@ -11,6 +11,7 @@ services:
       - qqflow-network
     environment:
       SPRING_PROFILES_ACTIVE: test
+      JWT_SECRET: qqflow-test-secret-key-2024-min-32chars
       SPRING_DATASOURCE_URL: jdbc:mysql://192.168.1.139:3306/qqflowengine?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&serverTimezone=Asia/Shanghai&character_set_server=utf8mb4
       SPRING_DATASOURCE_USERNAME: qqflowengine
       SPRING_DATASOURCE_PASSWORD: 123456

+ 93 - 0
src/main/java/com/qqflow/engine/common/controller/FileDownloadController.java

@@ -0,0 +1,93 @@
+package com.qqflow.engine.common.controller;
+
+import com.qqflow.engine.domain.flow.mapper.AttachmentMapper;
+import com.qqflow.engine.domain.flow.po.Attachment;
+import com.qqflow.engine.domain.flow.service.ProcessInstanceService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+public class FileDownloadController {
+
+    private static final String UPLOAD_DIR = "uploads";
+
+    private final AttachmentMapper attachmentMapper;
+    private final ProcessInstanceService processInstanceService;
+
+    @GetMapping("/uploads/{date}/{filename}")
+    public void download(@PathVariable String date,
+                         @PathVariable String filename,
+                         HttpServletRequest request,
+                         HttpServletResponse response) throws IOException {
+        // 基本路径校验,防止目录遍历
+        if (!isValidDate(date) || !isValidFilename(filename)) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "非法文件路径");
+            return;
+        }
+
+        String fileUrl = "/uploads/" + date + "/" + filename;
+        Attachment attachment = this.attachmentMapper.selectByFileUrl(fileUrl);
+        if (attachment == null) {
+            // 不暴露文件是否存在,统一返回 403
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该文件");
+            return;
+        }
+
+        // 校验当前登录用户对所属流程实例的可见性
+        try {
+            this.processInstanceService.checkInstanceVisibility(attachment.getInstanceId());
+        } catch (Exception e) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权访问该文件");
+            return;
+        }
+
+        Path filePath = Paths.get(UPLOAD_DIR, date, filename);
+        if (!Files.exists(filePath) || !Files.isRegularFile(filePath)) {
+            response.sendError(HttpServletResponse.SC_NOT_FOUND, "文件不存在");
+            return;
+        }
+
+        // 校验请求路径与真实文件路径一致,防止通过编码绕过
+        Path normalizedPath = filePath.toAbsolutePath().normalize();
+        Path basePath = Paths.get(UPLOAD_DIR).toAbsolutePath().normalize();
+        if (!normalizedPath.startsWith(basePath)) {
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "非法文件路径");
+            return;
+        }
+
+        String contentType = request.getServletContext().getMimeType(filename);
+        if (!StringUtils.hasText(contentType)) {
+            contentType = "application/octet-stream";
+        }
+        response.setContentType(contentType);
+        response.setHeader("Content-Disposition", "inline; filename=\"" + filename + "\"");
+        Files.copy(filePath, response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
+    private boolean isValidDate(String date) {
+        return date != null && date.matches("\\d{6}");
+    }
+
+    private boolean isValidFilename(String filename) {
+        return filename != null
+                && !filename.isBlank()
+                && !filename.contains("..")
+                && !filename.contains("/")
+                && !filename.contains("\\")
+                && filename.matches("^[a-f0-9\\-]+\\.[a-zA-Z0-9]+$");
+    }
+}

+ 10 - 3
src/main/java/com/qqflow/engine/common/util/JwtUtils.java

@@ -9,6 +9,7 @@ import jakarta.annotation.PostConstruct;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
@@ -20,15 +21,21 @@ import java.util.UUID;
 @Component
 public class JwtUtils {
 
-    private static final String DEFAULT_SECRET = "qqflow-engine-secret-key-2024-very-long-and-secure";
+    private static final String OLD_DEFAULT_SECRET = "qqflow-engine-secret-key-2024-very-long-and-secure";
 
     @Value("${jwt.secret}")
     private String secret;
 
     @PostConstruct
     public void checkSecret() {
-        if (DEFAULT_SECRET.equals(secret)) {
-            log.warn("JWT secret is using default value; set JWT_SECRET env var in production");
+        if (!StringUtils.hasText(secret)) {
+            throw new IllegalStateException("JWT secret 未配置,请设置 JWT_SECRET 环境变量");
+        }
+        if (secret.length() < 32) {
+            throw new IllegalStateException("JWT secret 长度不能少于 32 位");
+        }
+        if (OLD_DEFAULT_SECRET.equals(secret)) {
+            throw new IllegalStateException("JWT secret 禁止使用默认密钥,请重新生成并设置 JWT_SECRET 环境变量");
         }
     }
 

+ 12 - 7
src/main/java/com/qqflow/engine/config/WebConfig.java

@@ -4,8 +4,9 @@ import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
 import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.util.StringUtils;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
-import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
 import java.time.format.DateTimeFormatter;
@@ -13,6 +14,9 @@ import java.time.format.DateTimeFormatter;
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
 
+    @Value("${cors.allowed-origins:}")
+    private String corsAllowedOrigins;
+
     @Bean
     public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
         return builder -> builder.serializers(
@@ -21,17 +25,18 @@ public class WebConfig implements WebMvcConfigurer {
 
     @Override
     public void addCorsMappings(CorsRegistry registry) {
+        // 未配置 CORS 来源时,默认只允许本地开发地址,生产请通过 CORS_ALLOWED_ORIGINS 注入
+        String origins = StringUtils.hasText(this.corsAllowedOrigins)
+                ? this.corsAllowedOrigins
+                : "http://localhost:3000,http://127.0.0.1:3000";
+        String[] originArray = origins.split(",");
         registry.addMapping("/**")
-                .allowedOriginPatterns("*")
+                .allowedOriginPatterns(originArray)
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)
                 .maxAge(3600);
     }
 
-    @Override
-    public void addResourceHandlers(ResourceHandlerRegistry registry) {
-        registry.addResourceHandler("/uploads/**")
-                .addResourceLocations("file:uploads/");
-    }
+    // 文件下载不再通过静态资源映射公开访问,统一走 FileDownloadController 做权限校验
 }

+ 0 - 1
src/main/java/com/qqflow/engine/config/security/SecurityConfig.java

@@ -31,7 +31,6 @@ public class SecurityConfig {
                         .requestMatchers("/auth/**").permitAll()
                         .requestMatchers("/webhook/**").permitAll()
                         .requestMatchers("/open-api/**").permitAll()
-                        .requestMatchers("/uploads/**").permitAll()
                         .requestMatchers(HttpMethod.OPTIONS).permitAll()
                         .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
                         .requestMatchers("/WW_verify_*.txt").permitAll()

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/controller/ProcessInstanceController.java

@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -29,6 +30,7 @@ import java.util.List;
 @RestController
 @RequestMapping("/flow/instance")
 @RequiredArgsConstructor
+@PreAuthorize("isAuthenticated()")
 @Tag(name = "流程实例管理")
 public class ProcessInstanceController {
 
@@ -41,6 +43,7 @@ public class ProcessInstanceController {
     }
 
     @GetMapping("/page")
+    @PreAuthorize("hasAnyRole('super_admin','flow_manager')")
     @Operation(summary = "流程实例分页列表(管理员)")
     public Result<PageResult<ProcessInstanceDTO>> page(
             @RequestParam(defaultValue = "1") Integer pageNum,

+ 7 - 0
src/main/java/com/qqflow/engine/domain/flow/mapper/AttachmentMapper.java

@@ -1,9 +1,16 @@
 package com.qqflow.engine.domain.flow.mapper;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.qqflow.engine.domain.flow.po.Attachment;
 import org.apache.ibatis.annotations.Mapper;
 
 @Mapper
 public interface AttachmentMapper extends BaseMapper<Attachment> {
+
+    default Attachment selectByFileUrl(String fileUrl) {
+        return this.selectOne(
+                new LambdaQueryWrapper<Attachment>()
+                        .eq(Attachment::getFileUrl, fileUrl));
+    }
 }

+ 3 - 0
src/main/java/com/qqflow/engine/domain/flow/service/ProcessInstanceService.java

@@ -38,4 +38,7 @@ public interface ProcessInstanceService {
 
     /** 当前节点负责人补充/修正表单数据 */
     void supplementFormData(Long instanceId, String formData);
+
+    /** 校验当前登录用户是否有权查看指定流程实例 */
+    void checkInstanceVisibility(Long instanceId);
 }

+ 10 - 0
src/main/java/com/qqflow/engine/domain/flow/service/impl/ProcessInstanceServiceImpl.java

@@ -127,6 +127,7 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
         if (po == null) {
             throw new BusinessException("流程实例不存在");
         }
+        this.checkInstanceVisibility(po);
         ProcessInstanceDTO dto = ProcessInstanceDTO.of(po);
         this.fillBizValue(dto, po.getProcessDefinitionId());
         return dto;
@@ -533,6 +534,15 @@ public class ProcessInstanceServiceImpl implements ProcessInstanceService {
     }
 
     @Override
+    public void checkInstanceVisibility(Long instanceId) {
+        ProcessInstance instance = this.processInstanceMapper.selectById(instanceId);
+        if (instance == null) {
+            throw new BusinessException("流程实例不存在");
+        }
+        this.checkInstanceVisibility(instance);
+    }
+
+    @Override
     @Transactional
     public void supplementFormData(Long instanceId, String formData) {
         Long operatorId = SecurityUtils.getUserId();

+ 1 - 1
src/main/java/com/qqflow/engine/domain/system/controller/AuthController.java

@@ -54,7 +54,7 @@ public class AuthController {
         String token = request.getHeader("Authorization");
         String newToken = sysUserService.refreshToken(token);
         Map<String, String> map = new HashMap<>();
-        map.put("token", token);
+        map.put("token", newToken);
         return Result.ok(map);
     }
 

+ 12 - 1
src/main/java/com/qqflow/engine/domain/system/service/impl/SysUserServiceImpl.java

@@ -345,7 +345,18 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser>
         if (loginUser == null) {
             throw new BusinessException("登录已过期");
         }
-        return jwtUtils.generateToken(loginUser);
+        String newToken = jwtUtils.generateToken(loginUser);
+        // 刷新后将旧 token 加入黑名单,防止旧 token 继续使用
+        try {
+            String uuid = jwtUtils.getUuidFromToken(realToken);
+            long remaining = jwtUtils.getRemainingTime(realToken);
+            if (remaining > 0) {
+                this.redisCache.setCacheObject("token:blacklist:" + uuid, "1", remaining, TimeUnit.MILLISECONDS);
+            }
+        } catch (Exception e) {
+            log.warn("刷新 token 时加入黑名单失败: {}", e.getMessage());
+        }
+        return newToken;
     }
 
     @Override

+ 2 - 1
src/main/resources/application-dev.yml

@@ -40,7 +40,8 @@ mybatis-plus:
       logic-not-delete-value: 0
 
 jwt:
-  secret: ${JWT_SECRET:qqflow-dev-secret-key-2024-very-long-and-secure}
+  # 开发环境也请通过 JWT_SECRET 环境变量注入,禁止在配置文件中写死
+  secret: ${JWT_SECRET:}
   expiration: 86400000
 
 enterprise-wechat:

+ 2 - 1
src/main/resources/application-test.yml

@@ -23,7 +23,8 @@ mybatis-plus:
       logic-not-delete-value: 0
 
 jwt:
-  secret: qqflow-test-secret-key-2024-min-32chars
+  # 测试环境密钥仅用于本地/CI,禁止用于生产;生产请通过环境变量 JWT_SECRET 注入
+  secret: ${JWT_SECRET:qqflow-test-secret-key-2024-min-32chars}
   expiration: 86400000
 
 logging:

+ 2 - 1
src/main/resources/application.yml

@@ -42,7 +42,8 @@ mybatis-plus:
       logic-not-delete-value: 0
 
 jwt:
-  secret: ${JWT_SECRET:qqflow-engine-secret-key-2024-very-long-and-secure}
+  # 生产环境必须设置 JWT_SECRET 环境变量,长度不少于 32 位;禁止提交默认值
+  secret: ${JWT_SECRET:}
   expiration: 86400000
   header: Authorization
   prefix: Bearer