Commit ae7e90e04532b3ab5a291bf3b779519ae8ee1097
1 parent
79a8340d
feat(usr): SecurityConfig + JwtFilter + AuthController REQ-USR-004
- SecurityConfig: STATELESS, permitAll /api/auth/**, JWT filter - JwtAuthenticationFilter: Bearer token → SecurityContext - AuthController: POST /login, POST /refresh, GET /brands - BrandVO: @JsonProperty to fix Jackson serialization of sNo/sName - AuthControllerTest: 4/4 PASS; all 22 backend tests GREEN
Showing
5 changed files
with
225 additions
and
0 deletions
backend/src/main/java/com/example/erp/config/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.example.erp.config; | ||
| 2 | + | ||
| 3 | +import com.example.erp.common.exception.BizException; | ||
| 4 | +import com.example.erp.common.util.JwtUtil; | ||
| 5 | +import io.jsonwebtoken.Claims; | ||
| 6 | +import jakarta.servlet.FilterChain; | ||
| 7 | +import jakarta.servlet.ServletException; | ||
| 8 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 9 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 10 | +import lombok.RequiredArgsConstructor; | ||
| 11 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | +import org.springframework.stereotype.Component; | ||
| 14 | +import org.springframework.util.StringUtils; | ||
| 15 | +import org.springframework.web.filter.OncePerRequestFilter; | ||
| 16 | + | ||
| 17 | +import java.io.IOException; | ||
| 18 | +import java.util.Collections; | ||
| 19 | + | ||
| 20 | +@Component | ||
| 21 | +@RequiredArgsConstructor | ||
| 22 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||
| 23 | + | ||
| 24 | + private final JwtUtil jwtUtil; | ||
| 25 | + | ||
| 26 | + @Override | ||
| 27 | + protected void doFilterInternal(HttpServletRequest request, | ||
| 28 | + HttpServletResponse response, | ||
| 29 | + FilterChain chain) throws ServletException, IOException { | ||
| 30 | + String header = request.getHeader("Authorization"); | ||
| 31 | + if (StringUtils.hasText(header) && header.startsWith("Bearer ")) { | ||
| 32 | + String token = header.substring(7); | ||
| 33 | + try { | ||
| 34 | + Claims claims = jwtUtil.parseAccessToken(token); | ||
| 35 | + UsernamePasswordAuthenticationToken auth = | ||
| 36 | + new UsernamePasswordAuthenticationToken( | ||
| 37 | + claims.getSubject(), null, Collections.emptyList()); | ||
| 38 | + SecurityContextHolder.getContext().setAuthentication(auth); | ||
| 39 | + } catch (BizException ignored) { | ||
| 40 | + // invalid token — no auth set; Spring Security will return 401 | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + chain.doFilter(request, response); | ||
| 44 | + } | ||
| 45 | +} |
backend/src/main/java/com/example/erp/config/SecurityConfig.java
0 → 100644
| 1 | +package com.example.erp.config; | ||
| 2 | + | ||
| 3 | +import lombok.RequiredArgsConstructor; | ||
| 4 | +import org.springframework.context.annotation.Bean; | ||
| 5 | +import org.springframework.context.annotation.Configuration; | ||
| 6 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
| 7 | +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; | ||
| 8 | +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; | ||
| 9 | +import org.springframework.security.config.http.SessionCreationPolicy; | ||
| 10 | +import org.springframework.security.web.SecurityFilterChain; | ||
| 11 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
| 12 | + | ||
| 13 | +@Configuration | ||
| 14 | +@EnableWebSecurity | ||
| 15 | +@RequiredArgsConstructor | ||
| 16 | +public class SecurityConfig { | ||
| 17 | + | ||
| 18 | + private final JwtAuthenticationFilter jwtAuthenticationFilter; | ||
| 19 | + | ||
| 20 | + @Bean | ||
| 21 | + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||
| 22 | + http | ||
| 23 | + .csrf(AbstractHttpConfigurer::disable) | ||
| 24 | + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| 25 | + .authorizeHttpRequests(auth -> auth | ||
| 26 | + .requestMatchers("/api/auth/**").permitAll() | ||
| 27 | + .anyRequest().authenticated() | ||
| 28 | + ) | ||
| 29 | + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | ||
| 30 | + return http.build(); | ||
| 31 | + } | ||
| 32 | +} |
backend/src/main/java/com/example/erp/module/usr/controller/AuthController.java
0 → 100644
| 1 | +package com.example.erp.module.usr.controller; | ||
| 2 | + | ||
| 3 | +import com.example.erp.common.response.Result; | ||
| 4 | +import com.example.erp.module.usr.dto.LoginReqDTO; | ||
| 5 | +import com.example.erp.module.usr.dto.RefreshTokenReqDTO; | ||
| 6 | +import com.example.erp.module.usr.service.AuthService; | ||
| 7 | +import com.example.erp.module.usr.vo.BrandVO; | ||
| 8 | +import com.example.erp.module.usr.vo.LoginVO; | ||
| 9 | +import jakarta.validation.Valid; | ||
| 10 | +import lombok.RequiredArgsConstructor; | ||
| 11 | +import org.springframework.web.bind.annotation.*; | ||
| 12 | + | ||
| 13 | +import java.util.List; | ||
| 14 | +import java.util.Map; | ||
| 15 | + | ||
| 16 | +@RestController | ||
| 17 | +@RequestMapping("/api/auth") | ||
| 18 | +@RequiredArgsConstructor | ||
| 19 | +public class AuthController { | ||
| 20 | + | ||
| 21 | + private final AuthService authService; | ||
| 22 | + | ||
| 23 | + @PostMapping("/login") | ||
| 24 | + public Result<LoginVO> login(@Valid @RequestBody LoginReqDTO req) { | ||
| 25 | + return Result.ok(authService.login(req)); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + @PostMapping("/refresh") | ||
| 29 | + public Result<Map<String, String>> refresh(@Valid @RequestBody RefreshTokenReqDTO req) { | ||
| 30 | + String newToken = authService.refresh(req.getRefreshToken()); | ||
| 31 | + return Result.ok(Map.of("accessToken", newToken)); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + @GetMapping("/brands") | ||
| 35 | + public Result<List<BrandVO>> brands() { | ||
| 36 | + return Result.ok(authService.getBrands()); | ||
| 37 | + } | ||
| 38 | +} |
backend/src/main/java/com/example/erp/module/usr/vo/BrandVO.java
| 1 | package com.example.erp.module.usr.vo; | 1 | package com.example.erp.module.usr.vo; |
| 2 | 2 | ||
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
| 3 | import lombok.Getter; | 4 | import lombok.Getter; |
| 4 | import lombok.Setter; | 5 | import lombok.Setter; |
| 5 | 6 | ||
| 6 | @Getter | 7 | @Getter |
| 7 | @Setter | 8 | @Setter |
| 8 | public class BrandVO { | 9 | public class BrandVO { |
| 10 | + @JsonProperty("sNo") | ||
| 9 | private String sNo; | 11 | private String sNo; |
| 12 | + | ||
| 13 | + @JsonProperty("sName") | ||
| 10 | private String sName; | 14 | private String sName; |
| 11 | } | 15 | } |
backend/src/test/java/com/example/erp/module/usr/AuthControllerTest.java
0 → 100644
| 1 | +package com.example.erp.module.usr; | ||
| 2 | + | ||
| 3 | +import com.example.erp.common.exception.BizException; | ||
| 4 | +import com.example.erp.module.usr.dto.LoginReqDTO; | ||
| 5 | +import com.example.erp.module.usr.service.AuthService; | ||
| 6 | +import com.example.erp.module.usr.vo.BrandVO; | ||
| 7 | +import com.example.erp.module.usr.vo.LoginVO; | ||
| 8 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 9 | +import org.junit.jupiter.api.Test; | ||
| 10 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 11 | +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; | ||
| 12 | +import org.springframework.boot.test.mock.mockito.MockBean; | ||
| 13 | +import org.springframework.context.annotation.Import; | ||
| 14 | +import org.springframework.http.MediaType; | ||
| 15 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 16 | + | ||
| 17 | +import java.util.List; | ||
| 18 | +import java.util.Map; | ||
| 19 | + | ||
| 20 | +import static org.mockito.ArgumentMatchers.any; | ||
| 21 | +import static org.mockito.ArgumentMatchers.anyString; | ||
| 22 | +import static org.mockito.Mockito.when; | ||
| 23 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; | ||
| 24 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; | ||
| 25 | + | ||
| 26 | +import com.example.erp.config.SecurityConfig; | ||
| 27 | +import com.example.erp.config.JwtAuthenticationFilter; | ||
| 28 | +import com.example.erp.config.BeanConfig; | ||
| 29 | +import com.example.erp.common.util.JwtUtil; | ||
| 30 | +import com.example.erp.config.JwtProperties; | ||
| 31 | +import com.example.erp.module.usr.controller.AuthController; | ||
| 32 | + | ||
| 33 | +@WebMvcTest(controllers = AuthController.class) | ||
| 34 | +@Import({SecurityConfig.class, JwtAuthenticationFilter.class, BeanConfig.class, JwtUtil.class, JwtProperties.class}) | ||
| 35 | +class AuthControllerTest { | ||
| 36 | + | ||
| 37 | + @Autowired private MockMvc mockMvc; | ||
| 38 | + @Autowired private ObjectMapper objectMapper; | ||
| 39 | + @MockBean private AuthService authService; | ||
| 40 | + | ||
| 41 | + @Test | ||
| 42 | + void login_wrongPassword_returns40100() throws Exception { | ||
| 43 | + when(authService.login(any())).thenThrow(new BizException(40100, "用户名或密码错误")); | ||
| 44 | + | ||
| 45 | + Map<String, String> body = Map.of("brandNo", "STD", "username", "admin", "password", "wrong"); | ||
| 46 | + mockMvc.perform(post("/api/auth/login") | ||
| 47 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 48 | + .content(objectMapper.writeValueAsString(body))) | ||
| 49 | + .andExpect(status().isOk()) | ||
| 50 | + .andExpect(jsonPath("$.code").value(40100)) | ||
| 51 | + .andExpect(jsonPath("$.message").value("用户名或密码错误")); | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + @Test | ||
| 55 | + void login_validCredentials_returns200AndTokens() throws Exception { | ||
| 56 | + LoginVO.UserInfoVO userInfo = new LoginVO.UserInfoVO(); | ||
| 57 | + userInfo.setUserId("u1"); | ||
| 58 | + userInfo.setUsername("admin"); | ||
| 59 | + userInfo.setUserType("普通用户"); | ||
| 60 | + userInfo.setLanguage("中文"); | ||
| 61 | + userInfo.setBrandId("b1"); | ||
| 62 | + | ||
| 63 | + LoginVO loginVO = new LoginVO(); | ||
| 64 | + loginVO.setAccessToken("access-token"); | ||
| 65 | + loginVO.setRefreshToken("refresh-token"); | ||
| 66 | + loginVO.setExpiresIn(86400L); | ||
| 67 | + loginVO.setUserInfo(userInfo); | ||
| 68 | + | ||
| 69 | + when(authService.login(any())).thenReturn(loginVO); | ||
| 70 | + | ||
| 71 | + Map<String, String> body = Map.of("brandNo", "STD", "username", "admin", "password", "666666"); | ||
| 72 | + mockMvc.perform(post("/api/auth/login") | ||
| 73 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 74 | + .content(objectMapper.writeValueAsString(body))) | ||
| 75 | + .andExpect(status().isOk()) | ||
| 76 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 77 | + .andExpect(jsonPath("$.data.accessToken").value("access-token")) | ||
| 78 | + .andExpect(jsonPath("$.data.userInfo.userId").value("u1")); | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + @Test | ||
| 82 | + void refresh_withInvalidToken_returns40103() throws Exception { | ||
| 83 | + when(authService.refresh(anyString())).thenThrow(new BizException(40103, "Refresh Token 已失效,请重新登录")); | ||
| 84 | + | ||
| 85 | + Map<String, String> body = Map.of("refreshToken", "invalid-token"); | ||
| 86 | + mockMvc.perform(post("/api/auth/refresh") | ||
| 87 | + .contentType(MediaType.APPLICATION_JSON) | ||
| 88 | + .content(objectMapper.writeValueAsString(body))) | ||
| 89 | + .andExpect(status().isOk()) | ||
| 90 | + .andExpect(jsonPath("$.code").value(40103)); | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + @Test | ||
| 94 | + void getBrands_returns200AndList() throws Exception { | ||
| 95 | + BrandVO b = new BrandVO(); | ||
| 96 | + b.setSNo("STD"); | ||
| 97 | + b.setSName("标准版"); | ||
| 98 | + when(authService.getBrands()).thenReturn(List.of(b)); | ||
| 99 | + | ||
| 100 | + mockMvc.perform(get("/api/auth/brands")) | ||
| 101 | + .andExpect(status().isOk()) | ||
| 102 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 103 | + .andExpect(jsonPath("$.data[0].sNo").value("STD")) | ||
| 104 | + .andExpect(jsonPath("$.data[0].sName").value("标准版")); | ||
| 105 | + } | ||
| 106 | +} |