ソースを参照

B-2(R5): fix admin-auth login exposing HTTP 500 on unauthorized/empty-body

缺陷(docs/tdd/test/blockers.md B-2 / test_blockers.md R5):
未授权/空 body/缺参调用 POST /api/admin-auth/login 返回业务码 500
(message=手机号和验证码不能为空),且空 body 会 NPE;安全测试无法稳定断言被拒。

修复(对齐项目既有惯例: Result 业务码表达语义 + HTTP 200 信封;真实 HTTP 401
由 JwtInterceptor 对受保护路径负责,登录端点为公开路径):
- login: dto==null 先判空避免 NPE; 缺 phone/code 属参数校验 -> 400(原 500)
- login-by-password / send-code: 同面公开端点同类 R5 暴露, 一并判空 + 400
- 手机号格式校验也归参数校验 -> 400
- 成功路径/验证码校验/登录锁定/角色判定逻辑零改动

契约测试 AdminAuthControllerLoginStatusTest(10 case, MockMvc standalone + 全 mock):
空 body/{} /缺 code/缺 phone -> 400 非 5xx 无 NPE; 合法验证码登录 -> 200+token。

mvn clean compile 通过; mvn test -Dtest=AdminAuthController*Test 10/10 通过;
cfc-web npm run build 通过。
Sisyphus 1 週間 前
親
コミット
d936d1b18b

+ 18 - 9
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java

@@ -59,14 +59,15 @@ public class AdminAuthController {
      * 发送管理员登录验证码
      */
     @PostMapping("/send-code")
-    public Result<Boolean> sendCode(@RequestBody SendCodeDTO dto) {
-        if (dto.getPhone() == null || dto.getPhone().isEmpty()) {
-            return Result.error("手机号不能为空");
+    public Result<Boolean> sendCode(@RequestBody(required = false) SendCodeDTO dto) {
+        // B-2/R5:空 body / 缺手机号属参数校验 → 400,先判空避免 NPE,绝不可 500
+        if (dto == null || dto.getPhone() == null || dto.getPhone().isEmpty()) {
+            return Result.error(400, "手机号不能为空");
         }
 
-        // 验证手机号格式
+        // 验证手机号格式(参数校验 → 400)
         if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) {
-            return Result.error("手机号格式不正确");
+            return Result.error(400, "手机号格式不正确");
         }
 
         // 验证该手机号是否已注册且为管理员/规划师
@@ -97,9 +98,13 @@ public class AdminAuthController {
      * 管理员验证码登录
      */
     @PostMapping("/login")
-    public Result<Map<String, Object>> login(@RequestBody AdminLoginDTO dto, HttpServletRequest request) {
+    public Result<Map<String, Object>> login(@RequestBody(required = false) AdminLoginDTO dto, HttpServletRequest request) {
+        // B-2/R5:空 body(dto==null)先判空避免 NPE;缺手机号/验证码属参数校验 → 400,绝不可 500
+        if (dto == null) {
+            return Result.error(400, "请求参数格式错误,请检查请求体 JSON 或字段类型");
+        }
         if (dto.getPhone() == null || dto.getCode() == null) {
-            return Result.error("手机号和验证码不能为空");
+            return Result.error(400, "手机号和验证码不能为空");
         }
 
         String ip = getClientIP(request);
@@ -271,9 +276,13 @@ public class AdminAuthController {
      * 管理员用户名密码登录
      */
     @PostMapping("/login-by-password")
-    public Result<Map<String, Object>> loginByPassword(@RequestBody AdminLoginDTO dto, HttpServletRequest request) {
+    public Result<Map<String, Object>> loginByPassword(@RequestBody(required = false) AdminLoginDTO dto, HttpServletRequest request) {
+        // B-2/R5:空 body(dto==null)先判空避免 NPE;缺用户名/密码属参数校验 → 400,绝不可 500
+        if (dto == null) {
+            return Result.error(400, "请求参数格式错误,请检查请求体 JSON 或字段类型");
+        }
         if (dto.getPhone() == null || dto.getPassword() == null) {
-            return Result.error("用户名和密码不能为空");
+            return Result.error(400, "用户名和密码不能为空");
         }
 
         String ip = getClientIP(request);

+ 250 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminAuthControllerLoginStatusTest.java

@@ -0,0 +1,250 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.config.JwtConfig;
+import com.etotem.cfc.dto.AdminLoginDTO;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.LoginAttemptService;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.VerificationCodeService;
+import com.etotem.cfc.service.WechatWebLoginService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.MediaType;
+import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * TDD 研发负责人 B-2 / R5:POST /api/admin-auth/login 未授权/空 body/缺参不得暴露 500。
+ *
+ * <p>缺陷:空 body / 缺参原走 {@code Result.error(...)}(业务码 500,且 dto==null 会 NPE);</p>
+ *
+ * <p>契约口径(对齐项目既有惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、
+ * 业务码表达语义;真实 HTTP 401 由 {@code JwtInterceptor} 对受保护路径负责,
+ * {@code /api/admin-auth/login} 为公开路径):
+ * <ul>
+ *   <li>空 body(dto==null)→ 业务码 400,无 NPE;</li>
+ *   <li>缺 code / 缺 phone / 空对象 {} → 业务码 400(参数校验);</li>
+ *   <li>正常验证码登录成功 → 业务码 200 + token/adminId/role/roles(成功路径行为不变);</li>
+ *   <li>同面公开端点 {@code send-code} / {@code login-by-password} 空 body / 缺参 → 400(同类 R5 暴露)。</li>
+ * </ul>
+ * 测试隔离:MockMvc standalone + 全 mock 依赖,无 Spring 上下文 / 无 DB。
+ */
+class AdminAuthControllerLoginStatusTest {
+
+    private AdminAuthController controller;
+    private MockMvc mockMvc;
+    private UserMapper userMapper;
+    private UserService userService;
+    private VerificationCodeService verificationCodeService;
+    private JwtConfig jwtConfig;
+    private LoginAttemptService loginAttemptService;
+    private WechatWebLoginService wechatWebLoginService;
+
+    @BeforeEach
+    void setUp() {
+        controller = new AdminAuthController();
+        userMapper = mock(UserMapper.class);
+        userService = mock(UserService.class);
+        verificationCodeService = mock(VerificationCodeService.class);
+        jwtConfig = mock(JwtConfig.class);
+        loginAttemptService = mock(LoginAttemptService.class);
+        wechatWebLoginService = mock(WechatWebLoginService.class);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+        ReflectionTestUtils.setField(controller, "userService", userService);
+        ReflectionTestUtils.setField(controller, "verificationCodeService", verificationCodeService);
+        ReflectionTestUtils.setField(controller, "jwtConfig", jwtConfig);
+        ReflectionTestUtils.setField(controller, "loginAttemptService", loginAttemptService);
+        ReflectionTestUtils.setField(controller, "wechatWebLoginService", wechatWebLoginService);
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    // ---------------------------------------------------------------
+    // (a) 空 body(dto==null)→ 非 5xx / 400,无 NPE
+    // ---------------------------------------------------------------
+
+    @Test
+    @DisplayName("login:直接调用 null dto(空 body)→ 业务码 400,无 NPE,且不触达业务依赖")
+    void login_nullDto_directCall_returns400WithoutNpe() {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+
+        Result<Map<String, Object>> result = controller.login(null, request);
+
+        assertEquals(400, result.getCode());
+        assertNotNull(result.getMessage());
+        // 参数校验前置:未触达验证码/锁定/用户查询等业务依赖
+        verify(loginAttemptService, never()).isBlocked(anyString());
+        verify(verificationCodeService, never()).verifyCode(anyString(), anyString(), anyString());
+        verify(userMapper, never()).selectOne(any());
+    }
+
+    @Test
+    @DisplayName("login HTTP:空请求体 → 非 5xx(允许 200+业务码400 或框架 400),绝无 500")
+    void login_emptyBody_http_not5xx() throws Exception {
+        MvcResult mvcResult = mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON))
+                .andReturn();
+
+        int httpStatus = mvcResult.getResponse().getStatus();
+        assertTrue(httpStatus >= 200 && httpStatus < 500,
+                "空 body 不得返回 5xx,实际 HTTP " + httpStatus);
+
+        // 若返回了业务 JSON,业务码也不得为 500
+        String body = mvcResult.getResponse().getContentAsString();
+        if (body != null && !body.isEmpty()) {
+            assertFalse(body.contains("\"code\":500"), "空 body 业务码不得为 500: " + body);
+        }
+    }
+
+    // ---------------------------------------------------------------
+    // (a/b) 空对象 {} 与缺 code → 400(参数校验,非 500)
+    // ---------------------------------------------------------------
+
+    @Test
+    @DisplayName("login HTTP:空对象 {}(历史实测触发 500 的 payload)→ 业务码 400")
+    void login_emptyObject_returns400() throws Exception {
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
+    }
+
+    @Test
+    @DisplayName("login HTTP:缺 code → 业务码 400(参数校验)")
+    void login_missingCode_returns400() throws Exception {
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"13800000001\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
+    }
+
+    @Test
+    @DisplayName("login HTTP:缺 phone → 业务码 400(参数校验)")
+    void login_missingPhone_returns400() throws Exception {
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"code\":\"123456\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("手机号和验证码不能为空"));
+    }
+
+    // ---------------------------------------------------------------
+    // (c) 正常登录成功路径不受影响
+    // ---------------------------------------------------------------
+
+    @Test
+    @DisplayName("login HTTP:合法验证码登录 → 业务码 200 + token/adminId/role/roles")
+    void login_success_returnsToken() throws Exception {
+        User admin = adminUser(100L);
+        when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
+        when(verificationCodeService.verifyCode("13800000001", "123456", "admin_login")).thenReturn(true);
+        when(userMapper.selectOne(any())).thenReturn(admin);
+        when(userService.getUserRoles(100L)).thenReturn(Collections.singletonList("admin"));
+        when(jwtConfig.generateToken(eq(100L), anyList())).thenReturn("jwt-token");
+
+        mockMvc.perform(post("/api/admin-auth/login")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"phone\":\"13800000001\",\"code\":\"123456\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.data.token").value("jwt-token"))
+                .andExpect(jsonPath("$.data.adminId").value(100))
+                .andExpect(jsonPath("$.data.username").value("nick100"))
+                .andExpect(jsonPath("$.data.role").value("admin"));
+
+        // 成功路径仍完整走验证码校验与登录尝试清零(业务逻辑不变;lockKey 格式为 phone:ip)
+        verify(verificationCodeService).verifyCode("13800000001", "123456", "admin_login");
+        verify(loginAttemptService).clearSuccess("13800000001:127.0.0.1");
+    }
+
+    // ---------------------------------------------------------------
+    // 同面公开端点:send-code / login-by-password 同类 R5 暴露(空 body/缺参 → 400)
+    // ---------------------------------------------------------------
+
+    @Test
+    @DisplayName("login-by-password:空 body(dto==null)→ 400,无 NPE")
+    void loginByPassword_nullDto_returns400() {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+
+        Result<Map<String, Object>> result = controller.loginByPassword(null, request);
+
+        assertEquals(400, result.getCode());
+        assertNotNull(result.getMessage());
+    }
+
+    @Test
+    @DisplayName("login-by-password:缺密码 → 业务码 400(参数校验)")
+    void loginByPassword_missingPassword_returns400() {
+        AdminLoginDTO dto = new AdminLoginDTO();
+        dto.setPhone("13800000001");
+
+        Result<Map<String, Object>> result = controller.loginByPassword(dto, mock(HttpServletRequest.class));
+
+        assertEquals(400, result.getCode());
+        assertEquals("用户名和密码不能为空", result.getMessage());
+    }
+
+    @Test
+    @DisplayName("send-code:空 body(dto==null)→ 400,无 NPE")
+    void sendCode_nullDto_returns400() {
+        Result<Boolean> result = controller.sendCode(null);
+
+        assertEquals(400, result.getCode());
+        assertNotNull(result.getMessage());
+    }
+
+    @Test
+    @DisplayName("send-code:缺手机号 → 业务码 400(参数校验)")
+    void sendCode_missingPhone_returns400() {
+        Result<Boolean> result = controller.sendCode(new com.etotem.cfc.dto.SendCodeDTO());
+
+        assertEquals(400, result.getCode());
+        assertEquals("手机号不能为空", result.getMessage());
+    }
+
+    // ---------------------------------------------------------------
+    // helpers
+    // ---------------------------------------------------------------
+
+    private static User adminUser(Long id) {
+        User user = new User();
+        user.setId(id);
+        user.setRole("admin");
+        user.setRoles(new ArrayList<>(Collections.singletonList("admin")));
+        user.setNickname("nick" + id);
+        user.setRealName("管理员" + id);
+        user.setPhone("13800000001");
+        return user;
+    }
+}