From ae7e90e04532b3ab5a291bf3b779519ae8ee1097 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 8 May 2026 09:57:55 +0800 Subject: [PATCH] feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004 --- backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java | 45 +++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/example/erp/config/SecurityConfig.java | 32 ++++++++++++++++++++++++++++++++ backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java | 38 ++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java | 4 ++++ backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/example/erp/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java create mode 100644 backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java diff --git a/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java b/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9957bb2 --- /dev/null +++ b/backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java @@ -0,0 +1,45 @@ +package com.example.erp.config; + +import com.example.erp.common.exception.BizException; +import com.example.erp.common.util.JwtUtil; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + Claims claims = jwtUtil.parseAccessToken(token); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + claims.getSubject(), null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (BizException ignored) { + // invalid token — no auth set; Spring Security will return 401 + } + } + chain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/example/erp/config/SecurityConfig.java b/backend/src/main/java/com/example/erp/config/SecurityConfig.java new file mode 100644 index 0000000..9653dd1 --- /dev/null +++ b/backend/src/main/java/com/example/erp/config/SecurityConfig.java @@ -0,0 +1,32 @@ +package com.example.erp.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java b/backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java new file mode 100644 index 0000000..851b726 --- /dev/null +++ b/backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java @@ -0,0 +1,38 @@ +package com.example.erp.module.usr.controller; + +import com.example.erp.common.response.Result; +import com.example.erp.module.usr.dto.LoginReqDTO; +import com.example.erp.module.usr.dto.RefreshTokenReqDTO; +import com.example.erp.module.usr.service.AuthService; +import com.example.erp.module.usr.vo.BrandVO; +import com.example.erp.module.usr.vo.LoginVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginReqDTO req) { + return Result.ok(authService.login(req)); + } + + @PostMapping("/refresh") + public Result> refresh(@Valid @RequestBody RefreshTokenReqDTO req) { + String newToken = authService.refresh(req.getRefreshToken()); + return Result.ok(Map.of("accessToken", newToken)); + } + + @GetMapping("/brands") + public Result> brands() { + return Result.ok(authService.getBrands()); + } +} diff --git a/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java b/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java index 3001f6e..a94d50a 100644 --- a/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java +++ b/backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java @@ -1,11 +1,15 @@ package com.example.erp.module.usr.vo; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Getter; import lombok.Setter; @Getter @Setter public class BrandVO { + @JsonProperty("sNo") private String sNo; + + @JsonProperty("sName") private String sName; } diff --git a/backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java b/backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java new file mode 100644 index 0000000..7a9809d --- /dev/null +++ b/backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java @@ -0,0 +1,106 @@ +package com.example.erp.module.usr; + +import com.example.erp.common.exception.BizException; +import com.example.erp.module.usr.dto.LoginReqDTO; +import com.example.erp.module.usr.service.AuthService; +import com.example.erp.module.usr.vo.BrandVO; +import com.example.erp.module.usr.vo.LoginVO; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.example.erp.config.SecurityConfig; +import com.example.erp.config.JwtAuthenticationFilter; +import com.example.erp.config.BeanConfig; +import com.example.erp.common.util.JwtUtil; +import com.example.erp.config.JwtProperties; +import com.example.erp.module.usr.controller.AuthController; + +@WebMvcTest(controllers = AuthController.class) +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class}) +class AuthControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @MockBean private AuthService authService; + + @Test + void login_wrongPassword_returns40100() throws Exception { + when(authService.login(any())).thenThrow(new BizException(40100, "用户名或密码错误")); + + Map body = Map.of("brandNo", "STD", "username", "admin", "password", "wrong"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40100)) + .andExpect(jsonPath("$.message").value("用户名或密码错误")); + } + + @Test + void login_validCredentials_returns200AndTokens() throws Exception { + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO(); + userInfo.setUserId("u1"); + userInfo.setUsername("admin"); + userInfo.setUserType("普通用户"); + userInfo.setLanguage("中文"); + userInfo.setBrandId("b1"); + + LoginVO loginVO = new LoginVO(); + loginVO.setAccessToken("access-token"); + loginVO.setRefreshToken("refresh-token"); + loginVO.setExpiresIn(86400L); + loginVO.setUserInfo(userInfo); + + when(authService.login(any())).thenReturn(loginVO); + + Map body = Map.of("brandNo", "STD", "username", "admin", "password", "666666"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.accessToken").value("access-token")) + .andExpect(jsonPath("$.data.userInfo.userId").value("u1")); + } + + @Test + void refresh_withInvalidToken_returns40103() throws Exception { + when(authService.refresh(anyString())).thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录")); + + Map body = Map.of("refreshToken", "invalid-token"); + mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40103)); + } + + @Test + void getBrands_returns200AndList() throws Exception { + BrandVO b = new BrandVO(); + b.setSNo("STD"); + b.setSName("标准版"); + when(authService.getBrands()).thenReturn(List.of(b)); + + mockMvc.perform(get("/api/auth/brands")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data[0].sNo").value("STD")) + .andExpect(jsonPath("$.data[0].sName").value("标准版")); + } +} -- libgit2 0.22.2