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 | 1 | package com.example.erp.module.usr.vo; |
| 2 | 2 | |
| 3 | +import com.fasterxml.jackson.annotation.JsonProperty; | |
| 3 | 4 | import lombok.Getter; |
| 4 | 5 | import lombok.Setter; |
| 5 | 6 | |
| 6 | 7 | @Getter |
| 7 | 8 | @Setter |
| 8 | 9 | public class BrandVO { |
| 10 | + @JsonProperty("sNo") | |
| 9 | 11 | private String sNo; |
| 12 | + | |
| 13 | + @JsonProperty("sName") | |
| 10 | 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 | +} | ... | ... |