Przeglądaj źródła

fix(security): SEC-20261031-01 安全契约缺口修复 — switch-role角色白名单 + member/list开放端点 + 上传PDF类型校验 + 鉴权统一

1. UserService.switchRoleForLogin: 增加角色白名单(parent/child/teacher) + 必须持有目标角色,
   防止普通用户越权切到 admin/operator 等后台角色并签发高权限 token(权限提升高危)
2. JwtInterceptor.LOGIN_REQUIRED_PATHS: 新增 /api/auth/switch-role、/api/family/user/switch-role、
   /api/family/member/list、/api/energy/overview、/api/ai/health-coach/send —— 无 token 直接 HTTP 401,
   杜绝匿名(userId=-1)放行到 controller 后走业务 500 的口径漂移(开放端点高危)
3. FamilyMembersController.listMembers: userId<=0(匿名哨兵) 统一拦截为 401, 防止匿名访问家庭私有成员列表
4. AssessmentReportController.uploadReport: 仅接受 PDF(Content-Type或扩展名), 非 PDF 返回 HTTP 415(上传校验高危)
5. 鉴权统一(系统): AuthController.switchRole / FamilyUserController.switchRole 增加 userId<=0 -> 401,
   对齐 ssoTicket 既有口径, 使未登录/匿名在拦截器与控制器两层行为一致
E2E Test Bot 1 tydzień temu
rodzic
commit
0befb7671e

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -90,7 +90,14 @@ private static final String[] PUBLIC_PATHS = {
         "/api/health/report/proxy-parse",
         "/api/health/report/proxy-confirm",
         // T04 方案A:指标收藏后端化——收藏是用户私有写数据,匿名(userId=-1)写入会污染数据,必须登录
-        "/api/indicator/favorite"
+        "/api/indicator/favorite",
+        // SEC-20261031-01:鉴权统一——权限提升/私有数据/计费接口一律要求登录,无 token 直接 401,
+        // 杜绝匿名(userId=-1)被放行到 controller 后走"业务 500"而非安全 401 的口径漂移。
+        "/api/auth/switch-role",            // 角色切换:属权限提升操作(可重签 token),必须登录
+        "/api/family/user/switch-role",     // 角色切换(家庭侧):同上,必须登录
+        "/api/family/member/list",          // 家庭成员列表:家庭私有数据,必须登录
+        "/api/energy/overview",             // 五维能量概览:用户私有数据,必须登录
+        "/api/ai/health-coach/send"         // AI 健康教练:耗 AI 配额 + 家庭上下文,必须登录
     };
 
     @Override

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentReportController.java

@@ -22,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
 import java.util.Date;
 import java.util.concurrent.CompletableFuture;
 import java.util.List;
@@ -105,12 +106,24 @@ public class AssessmentReportController {
     public Result<DanAssessmentResult> uploadReport(
             @RequestParam("file") MultipartFile file,
             @RequestParam("memberId") Long memberId,
-            @RequestParam(value = "notes", required = false) String notes) {
+            @RequestParam(value = "notes", required = false) String notes,
+            HttpServletResponse response) {
 
         if (file == null || file.isEmpty()) {
             return Result.error("文件不能为空");
         }
 
+        // SEC-20261031-01:上传类型校验——仅接受 PDF(Content-Type 或扩展名任一命中),
+        // 非 PDF/恶意内容直接 HTTP 415,防止进入存储/解析链路(对齐 HealthReportController 口径)
+        String contentType = file.getContentType();
+        String originalFilename = file.getOriginalFilename();
+        boolean isPdf = (contentType != null && "application/pdf".equalsIgnoreCase(contentType))
+                || (originalFilename != null && originalFilename.toLowerCase().endsWith(".pdf"));
+        if (!isPdf) {
+            response.setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE);
+            return Result.error(415, "仅支持PDF文件");
+        }
+
         String filePath = fileStorageService.store(file, "assessment-reports");
         if (filePath == null) {
             return Result.error("文件上传失败");

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -329,8 +329,9 @@ public class AuthController {
     
     @PostMapping("/switch-role")
     public Result<LoginResultDTO> switchRole(@RequestBody SwitchRoleDTO dto, @RequestAttribute("userId") Long userId) {
-        if (userId == null) {
-            return Result.error("未登录");
+        // SEC-20261031-01:鉴权统一——匿名注入 userId=-1,统一拦截为 401(对齐 ssoTicket 口径)
+        if (userId == null || userId <= 0) {
+            return Result.error(401, "请先登录");
         }
         try {
             LoginResultDTO result = userService.switchRoleForLogin(userId, dto.getRole());

+ 4 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -66,8 +66,10 @@ public Result<?> listMembers(@RequestBody(required = false) Map<String, Object>
                              @RequestAttribute("userId") Long userId,
                              @RequestAttribute("role") String role,
                              @RequestAttribute("roles") List<String> roles) {
-    if (userId == null) {
-        return Result.error("请先登录");
+    // SEC-20261031-01:拦截器对匿名请求注入 userId=-1,此处必须同时拦截 null 与 <=0,
+    // 防止匿名请求绕到家庭私有数据列表(口径统一为 401,与 JwtInterceptor LOGIN_REQUIRED_PATHS 一致)
+    if (userId == null || userId <= 0) {
+        return Result.error(401, "请先登录");
     }
 
     // B2-1:显式指定 familyId 时,按该家庭返回成员(teacher 可查绑定家庭;平台角色任意家庭)。

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyUserController.java

@@ -101,6 +101,10 @@ public class FamilyUserController {
     @PostMapping("/switch-role")
     public Result<String> switchRole(@RequestAttribute("userId") Long userId,
         @RequestBody Map<String, String> body) {
+        // SEC-20261031-01:鉴权统一——匿名注入 userId=-1,统一拦截为 401
+        if (userId == null || userId <= 0) {
+            return Result.error(401, "请先登录");
+        }
         String role = body.get("role");
         String result = userService.switchRole(userId, role);
         if (result == null) {

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -644,9 +644,18 @@ private FamilyInvitationService familyInvitationService;
         if ("admin".equals(user.getRole())) {
             throw new RuntimeException("管理员角色不允许切换");
         }
+        // SEC-20261031-01:角色白名单——小程序仅允许切换到 parent/child/teacher,
+        // 防止普通用户越权切到 admin/operator 等后台角色并签发高权限 token
+        if (!isLoginRole(role)) {
+            throw new RuntimeException("不支持切换到该角色");
+        }
         if ("teacher".equals(role) && !hasRole(user, "teacher")) {
             throw new RuntimeException("您不是成长规划师,无法切换为规划师模式");
         }
+        // 必须真实持有目标角色(兼容 roles 字段为空的历史用户:若目标等同当前角色则放行)
+        if (!hasRole(user, role) && !role.equals(user.getRole())) {
+            throw new RuntimeException("您没有该角色权限");
+        }
         user.setRole(role);
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
@@ -776,6 +785,15 @@ private FamilyInvitationService familyInvitationService;
                 || AdminRoles.BACKEND_ROLES.contains(role);
     }
 
+    // 小程序客户端登录态允许切换的角色白名单(仅 C 端身份 + 规划师)。
+    // 后台/入驻角色(admin/operator/finance/butler...)不得通过本接口切换并签发客户端 token。
+    private static final Set<String> LOGIN_ROLES = Collections.unmodifiableSet(
+            new HashSet<>(Arrays.asList("parent", "child", "teacher")));
+
+    private boolean isLoginRole(String role) {
+        return role != null && LOGIN_ROLES.contains(role);
+    }
+
 /**
    * 家庭主账号切换到指定家庭成员身份(视图切换,不修改用户的实际角色)
    * 家庭主账号可以在本人模式下查看家庭成员的任务和积分