Explorar o código

fix(B-9): getPlanDetail/getPlanList handler 增加 role 校验,消除鉴权缺口

Xiaogang Liao hai 1 semana
pai
achega
fc68bcd02d

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

@@ -124,7 +124,11 @@ private static final String[] PUBLIC_PATHS = {
         "/api/user/deactivate",             // 账号注销:隐私敏感
         // SC-X-22 CF 值获取预估:预估接口必须登录,游客无 token 直接 401,
         // 不泄露规则数值(契约 §7 第 1 行:不展示预估或「登录后可见」)。
-        "/api/reward/estimate"
+        "/api/reward/estimate",
+        // B-9:规划师端训练方案(detail/list/create/update/items/deploy...)属规划师私有数据,
+        // 此前无 token 被降级为匿名(userId=-1)放行到 controller,任何调用方均可读取/篡改方案。
+        // 全前缀强制登录:无 token 直接 401;teacher/admin 角色约束由控制器 @PreAuthorize 校验。
+        "/api/guide/training-plans/"
     };
 
     @Override

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/TrainingPlanController.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.entity.TrainingPlanItem;
 import com.etotem.cfc.service.TrainingPlanService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
@@ -13,6 +14,15 @@ import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.ParamUtils;
 
+/**
+ * 规划师端训练方案管理。
+ *
+ * <p>B-9 鉴权修复:本控制器的读写端点属规划师私有数据,访问需同时满足
+ * 1) 已登录({@code /api/guide/training-plans/} 已列入 JwtInterceptor 的 LOGIN_REQUIRED_PATHS,
+ *    无 token 直接 401);2) 角色为 teacher 或 admin({@code @PreAuthorize},
+ *    无权限抛 AccessDeniedException,由 GlobalExceptionHandler 转 Result.error(403, "无权限"))。
+ * 此前 getPlanDetail/getPlanList 未做角色校验,任何已认证角色(含匿名 userId=-1)均可到达 handler。</p>
+ */
 @Tag(name = "训练方案管理", description = "规划师端训练方案CRUD/生成/下发/看板")
 @RestController
 @RequestMapping("/api/guide/training-plans")
@@ -23,6 +33,7 @@ public class TrainingPlanController {
 
     @Operation(summary = "获取规划师的所有训练方案列表")
     @PostMapping("/list")
+    @PreAuthorize("hasAnyRole('teacher', 'admin')")
     public Result<List<TrainingPlan>> getPlanList(@RequestAttribute("userId") Long guideId,
             @RequestBody Map<String, Object> body,
             @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
@@ -38,6 +49,7 @@ public class TrainingPlanController {
 
     @Operation(summary = "获取方案详情(含项目列表)")
     @PostMapping("/detail")
+    @PreAuthorize("hasAnyRole('teacher', 'admin')")
     public Result<Map<String, Object>> getPlanDetail(@RequestBody Map<String, Object> body) {
         Long planId = ParamUtils.getLong(body.get("planId"));
         if (planId == null) {

+ 80 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/TrainingPlanEndpointAuthTest.java

@@ -0,0 +1,80 @@
+package com.etotem.cfc.config;
+
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import javax.servlet.http.HttpServletResponse;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * B-9:guide.trainingPlan.getPlanDetail / getPlanList 鉴权缺口。
+ *
+ * 修复前:/api/guide/training-plans/* 未列入 JwtInterceptor 的 LOGIN_REQUIRED_PATHS,
+ * 无 token 的请求被降级为匿名(userId=-1)放行到 controller,任何未登录调用方均可读取方案详情/列表。
+ * 修复后:无 token 直接 401「请先登录」,与 SEC-20261031-01 口径一致(私有业务数据不匿名放行)。
+ *
+ * 角色维度(teacher/admin)由控制器上的 @PreAuthorize 校验,另见
+ * {@code com.etotem.cfc.unit.TrainingPlanControllerRoleGuardTest}。
+ */
+public class TrainingPlanEndpointAuthTest {
+
+    @Mock
+    private UserMapper userMapper;
+
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+
+    @InjectMocks
+    private JwtInterceptor jwtInterceptor;
+
+    @BeforeEach
+    public void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private MockHttpServletResponse preHandleWithoutToken(String uri) throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.setMethod("POST");
+        request.setRequestURI(uri);
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        boolean result = jwtInterceptor.preHandle(request, response, null);
+
+        assertFalse(result, "无 token 时不应放行到 controller: " + uri);
+        return response;
+    }
+
+    @Test
+    public void getPlanDetail无Token应401() throws Exception {
+        MockHttpServletResponse response = preHandleWithoutToken("/api/guide/training-plans/detail");
+
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+
+    @Test
+    public void getPlanList无Token应401() throws Exception {
+        MockHttpServletResponse response = preHandleWithoutToken("/api/guide/training-plans/list");
+
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+
+    @Test
+    public void trainingPlans子路径无Token一律401() throws Exception {
+        MockHttpServletResponse response = preHandleWithoutToken("/api/guide/training-plans/items/delete");
+
+        assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
+        assertTrue(response.getContentAsString().contains("请先登录"));
+    }
+}

+ 149 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TrainingPlanControllerRoleGuardTest.java

@@ -0,0 +1,149 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.guide.TrainingPlanController;
+import com.etotem.cfc.entity.TrainingPlan;
+import com.etotem.cfc.entity.TrainingPlanItem;
+import com.etotem.cfc.service.TrainingPlanService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * B-9:guide.trainingPlan.getPlanDetail / getPlanList 的「规划师角色约束」落地验证。
+ *
+ * 修复前:两个 handler 不读取 role,任何已认证角色(含匿名 userId=-1)均可调用。
+ * 修复后:handler 上声明 @PreAuthorize("hasAnyRole('teacher','admin')"),
+ * 由 {@code SecurityConfig#@EnableGlobalMethodSecurity} 建立的方法级安全代理拦截;
+ * 无权限抛 AccessDeniedException,生产环境由 GlobalExceptionHandler 转 Result.error(403, "无权限")。
+ *
+ * 本测试用最小 Spring 上下文(仅方法安全 + 控制器 + mock service)直接验证代理行为,
+ * 不启动 Web 容器 / DB。
+ */
+class TrainingPlanControllerRoleGuardTest {
+
+    @Configuration
+    @EnableGlobalMethodSecurity(prePostEnabled = true)
+    static class MethodSecurityTestConfig {
+    }
+
+    private AnnotationConfigApplicationContext context;
+    private TrainingPlanController controller;
+    private TrainingPlanService trainingPlanService;
+
+    @BeforeEach
+    void setUp() {
+        // 手动注册 mock service:绕过 BeanPostProcessor,避免 Mockito 子类被 @Resource 二次注入
+        TrainingPlanService mockService = mock(TrainingPlanService.class);
+        context = new AnnotationConfigApplicationContext();
+        context.getBeanFactory().registerSingleton("trainingPlanService", mockService);
+        context.register(MethodSecurityTestConfig.class, TrainingPlanController.class);
+        context.refresh();
+        controller = context.getBean(TrainingPlanController.class);
+        trainingPlanService = mockService;
+    }
+
+    @AfterEach
+    void tearDown() {
+        SecurityContextHolder.clearContext();
+        context.close();
+    }
+
+    private void loginAs(String role) {
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(7L, null,
+                        Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role))));
+    }
+
+    private Map<String, Object> body(String key, Object value) {
+        Map<String, Object> body = new HashMap<>();
+        body.put(key, value);
+        return body;
+    }
+
+    private TrainingPlan plan(long id) {
+        TrainingPlan plan = new TrainingPlan();
+        plan.setId(id);
+        plan.setGuideId(7L);
+        plan.setTitle("暑期认知训练");
+        plan.setStatus("published");
+        return plan;
+    }
+
+    // ==================== detail ====================
+
+    @Test
+    void getPlanDetail_非规划师角色_拒绝访问() {
+        loginAs("user");
+        when(trainingPlanService.getPlanById(9L)).thenReturn(plan(9L));
+
+        assertThrows(AccessDeniedException.class,
+                () -> controller.getPlanDetail(body("planId", 9)));
+    }
+
+    @Test
+    void getPlanDetail_匿名角色_拒绝访问() {
+        loginAs("anonymous");
+
+        assertThrows(AccessDeniedException.class,
+                () -> controller.getPlanDetail(body("planId", 9)));
+    }
+
+    @Test
+    void getPlanDetail_规划师角色_放行() {
+        loginAs("teacher");
+        when(trainingPlanService.getPlanById(9L)).thenReturn(plan(9L));
+        when(trainingPlanService.getPlanItems(9L)).thenReturn(Collections.<TrainingPlanItem>emptyList());
+
+        Result<Map<String, Object>> result = controller.getPlanDetail(body("planId", 9));
+
+        assertEquals(200, result.getCode());
+    }
+
+    @Test
+    void getPlanDetail_管理员角色_放行() {
+        loginAs("admin");
+        when(trainingPlanService.getPlanById(9L)).thenReturn(plan(9L));
+        when(trainingPlanService.getPlanItems(9L)).thenReturn(Collections.<TrainingPlanItem>emptyList());
+
+        Result<Map<String, Object>> result = controller.getPlanDetail(body("planId", 9));
+
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== list ====================
+
+    @Test
+    void getPlanList_非规划师角色_拒绝访问() {
+        loginAs("user");
+
+        assertThrows(AccessDeniedException.class,
+                () -> controller.getPlanList(7L, new HashMap<>(), null));
+    }
+
+    @Test
+    void getPlanList_规划师角色_放行() {
+        loginAs("teacher");
+        when(trainingPlanService.getPlansByGuide(7L)).thenReturn(Collections.<TrainingPlan>emptyList());
+
+        Result<java.util.List<TrainingPlan>> result = controller.getPlanList(7L, new HashMap<>(), null);
+
+        assertEquals(200, result.getCode());
+    }
+}