From aa7b233e99110946f8dc6bbf2bbdf4e933d3a7f3 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 7 May 2026 09:18:10 +0800 Subject: [PATCH] feat(usr): POST /api/auth/login controller REQ-USR-004 --- backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java | 26 ++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java create mode 100644 backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java new file mode 100644 index 0000000..19756b2 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java @@ -0,0 +1,26 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.ApiResponse; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.service.LoginService; +import com.xly.erp.module.usr.vo.LoginResultVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REQ-USR-004 用户登录入口 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成。 */ +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class LoginController { + + private final LoginService loginService; + + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginDTO dto) { + return ApiResponse.ok(loginService.login(dto)); + } +} diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java new file mode 100644 index 0000000..7cb4494 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java @@ -0,0 +1,209 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.erp.module.usr.dto.LoginDTO; +import com.xly.erp.module.usr.entity.UserEntity; +import com.xly.erp.module.usr.mapper.UserMapper; +import com.xly.erp.module.usr.security.InMemoryLoginAttemptStore; +import com.xly.erp.module.usr.security.JwtTokenProvider; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.annotation.Rollback; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +@Rollback +class LoginControllerIT { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired UserMapper userMapper; + @Autowired PasswordEncoder passwordEncoder; + @Autowired InMemoryLoginAttemptStore attemptStore; + @Autowired JwtTokenProvider jwtTokenProvider; + + private String userName; + + @BeforeEach + void setUp() { + userName = "login_" + System.nanoTime(); + // 每个测试在 store 里清干净(store 是 Spring singleton 跨测试存活) + attemptStore.clear(userName); + } + + private Integer insertUser(String pw) { + UserEntity u = new UserEntity(); + u.setSUserNo("uno_" + System.nanoTime()); + u.setSUserName(userName); + u.setSUserType("普通用户"); + u.setSLanguage("zh"); + u.setBCanModifyDocs(false); + u.setSPasswordHash(passwordEncoder.encode(pw)); + u.setBDeleted(false); + u.setTCreateDate(LocalDateTime.now()); + userMapper.insert(u); + return u.getIIncrement(); + } + + private LoginDTO loginDto(String name, String pw) { + LoginDTO d = new LoginDTO(); + d.setSUserName(name); + d.setSPassword(pw); + d.setSVersion("standard"); + return d; + } + + private String json(Object o) throws Exception { return objectMapper.writeValueAsString(o); } + + @Test + void login_validCredentials_returns200WithToken() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").isString()) + .andExpect(jsonPath("$.data.expiresIn").value(7200)) + .andExpect(jsonPath("$.data.user.sUserName").value(userName)) + .andExpect(jsonPath("$.data.user.sUserType").value("普通用户")); + } + + @Test + void login_jwtClaimsAreCorrect() throws Exception { + Integer userId = insertUser("666666"); + + MvcResult result = mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andReturn(); + + String body = result.getResponse().getContentAsString(); + String token = objectMapper.readTree(body).path("data").path("accessToken").asText(); + assertThat(token).isNotEmpty(); + + Claims claims = jwtTokenProvider.parse(token); + assertThat(claims.getSubject()).isEqualTo(userName); + assertThat(claims.get("uid", Integer.class)).isEqualTo(userId); + assertThat(claims.get("type", String.class)).isEqualTo("普通用户"); + } + + @Test + void login_invalidUsername_returns40101() throws Exception { + // 不插入用户 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto("ghost_" + System.nanoTime(), "any")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_wrongPassword_returns40101() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_password")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_softDeletedUser_returns40101() throws Exception { + Integer userId = insertUser("666666"); + UserEntity patch = new UserEntity(); + patch.setIIncrement(userId); + patch.setBDeleted(true); + userMapper.updateById(patch); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void login_missingPassword_returns40010() throws Exception { + LoginDTO dto = loginDto(userName, "any"); + dto.setSPassword(null); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void login_invalidVersion_returns40010() throws Exception { + LoginDTO dto = loginDto(userName, "any"); + dto.setSVersion("experimental"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(dto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void login_5thFailureLocks_returns40301() throws Exception { + insertUser("666666"); + + // 4 次错误密码(不锁定) + for (int i = 0; i < 4; i++) { + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_" + i)))) + .andExpect(jsonPath("$.code").value(40101)); + } + + // 第 5 次错误密码:触发锁定,返回 40301 + cooldownSeconds + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "wrong_5")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40301)) + .andExpect(jsonPath("$.data.cooldownSeconds").isNumber()); + + // 锁定后正确密码也 40301 + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(jsonPath("$.code").value(40301)); + } + + @Test + void login_responseExcludesSPasswordHash() throws Exception { + insertUser("666666"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(json(loginDto(userName, "666666")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.sPasswordHash").doesNotExist()); + } +} -- libgit2 0.22.2