From ad1477979d1a2d2d5625d15b0260f8953dd936d8 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 13 May 2026 17:53:21 +0800 Subject: [PATCH] feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001 --- backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java | 3 +++ backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java | 34 ++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java | 34 ++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/test4/support/TestJwtFactory.java | 37 +++++++++++++++++++++++++++++++++++++ 7 files changed, 299 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java create mode 100644 backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java create mode 100644 backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java create mode 100644 backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java create mode 100644 backend/src/test/java/com/xly/test4/support/TestJwtFactory.java diff --git a/backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java index 69c976f..d7791b5 100644 --- a/backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java @@ -4,10 +4,12 @@ import com.xly.test4.common.response.Result; import com.xly.test4.common.response.ResultCode; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @@ -27,6 +29,7 @@ public class GlobalExceptionHandler { } @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) public Result handleAccessDenied(AccessDeniedException e) { return Result.fail(ResultCode.FORBIDDEN, "权限不足"); } diff --git a/backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..58b722b --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package com.xly.test4.common.security; + +import com.xly.test4.common.exception.BusinessException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER = "Bearer "; + + private final JwtTokenProvider tokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { + this.tokenProvider = tokenProvider; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain chain) throws ServletException, IOException { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header != null && header.startsWith(BEARER)) { + String token = header.substring(BEARER.length()); + try { + CurrentUser user = tokenProvider.parse(token); + List authorities = user.getAuthorities() == null + ? Collections.emptyList() + : user.getAuthorities().stream().map(SimpleGrantedAuthority::new).toList(); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(user, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (BusinessException e) { + SecurityContextHolder.clearContext(); + } + } + chain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java b/backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java new file mode 100644 index 0000000..338eca2 --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java @@ -0,0 +1,34 @@ +package com.xly.test4.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.test4.common.response.Result; +import com.xly.test4.common.response.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + public RestAccessDeniedHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), + Result.fail(ResultCode.FORBIDDEN, "权限不足")); + } +} diff --git a/backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java b/backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..2cba92c --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java @@ -0,0 +1,34 @@ +package com.xly.test4.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xly.test4.common.response.Result; +import com.xly.test4.common.response.ResultCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + public RestAuthenticationEntryPoint(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), + Result.fail(ResultCode.UNAUTHENTICATED, "未认证")); + } +} diff --git a/backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java b/backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java new file mode 100644 index 0000000..2460c78 --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java @@ -0,0 +1,56 @@ +package com.xly.test4.common.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final RestAuthenticationEntryPoint entryPoint; + private final RestAccessDeniedHandler accessDeniedHandler; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, + RestAuthenticationEntryPoint entryPoint, + RestAccessDeniedHandler accessDeniedHandler) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.entryPoint = entryPoint; + this.accessDeniedHandler = accessDeniedHandler; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(req -> req + .requestMatchers( + "/api/auth/login", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/actuator/health" + ).permitAll() + .requestMatchers("/api/**").authenticated() + .anyRequest().permitAll() + ) + .exceptionHandling(eh -> eh + .authenticationEntryPoint(entryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java b/backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java new file mode 100644 index 0000000..0f60021 --- /dev/null +++ b/backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java @@ -0,0 +1,83 @@ +package com.xly.test4.common.security; + +import com.xly.test4.common.response.Result; +import com.xly.test4.support.TestJwtFactory; +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class SecurityIntegrationIT { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider tokenProvider; + + @TestConfiguration + static class ProbeConfig { + @Bean + ProbeController probeController() { + return new ProbeController(); + } + } + + @RestController + static class ProbeController { + @GetMapping("/api/_probe/secured") + @PreAuthorize("hasAuthority('usr:user:create')") + public Result secured() { + return Result.success("ok"); + } + } + + @Test + void unauthenticatedRequestToSecuredEndpoint_returns401AndCode40101() throws Exception { + mockMvc.perform(get("/api/_probe/secured")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)) + .andExpect(jsonPath("$.message").value("未认证")); + } + + @Test + void invalidJwt_returns401AndCode40101() throws Exception { + mockMvc.perform(get("/api/_probe/secured") + .header(HttpHeaders.AUTHORIZATION, "Bearer not-a-real-token")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void validJwtMissingRequiredAuthority_returns403AndCode40301() throws Exception { + String token = TestJwtFactory.normalUserToken(tokenProvider, 99, "alice"); + mockMvc.perform(get("/api/_probe/secured") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40301)) + .andExpect(jsonPath("$.message").value("权限不足")); + } + + @Test + void validAdminJwt_returns200() throws Exception { + String token = TestJwtFactory.adminToken(tokenProvider); + mockMvc.perform(get("/api/_probe/secured") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data").value("ok")); + } +} diff --git a/backend/src/test/java/com/xly/test4/support/TestJwtFactory.java b/backend/src/test/java/com/xly/test4/support/TestJwtFactory.java new file mode 100644 index 0000000..9b1e1bf --- /dev/null +++ b/backend/src/test/java/com/xly/test4/support/TestJwtFactory.java @@ -0,0 +1,37 @@ +package com.xly.test4.support; + +import com.xly.test4.common.security.CurrentUser; +import com.xly.test4.common.security.JwtTokenProvider; + +import java.util.List; + +public final class TestJwtFactory { + + private TestJwtFactory() { + } + + public static String adminToken(JwtTokenProvider provider) { + return adminToken(provider, 1); + } + + public static String adminToken(JwtTokenProvider provider, Integer userId) { + return provider.issue(CurrentUser.builder() + .userId(userId) + .userName("admin") + .brandsId("BR-DEFAULT") + .subsidiaryId("SUB-DEFAULT") + .authorities(List.of("usr:user:create", "usr:user:update", + "usr:user:list", "usr:user:assign-role")) + .build()); + } + + public static String normalUserToken(JwtTokenProvider provider, Integer userId, String userName) { + return provider.issue(CurrentUser.builder() + .userId(userId) + .userName(userName) + .brandsId("BR-DEFAULT") + .subsidiaryId("SUB-DEFAULT") + .authorities(List.of()) + .build()); + } +} -- libgit2 0.22.2