Commit ad1477979d1a2d2d5625d15b0260f8953dd936d8
1 parent
0d074e78
feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001
Showing
7 changed files
with
299 additions
and
0 deletions
backend/src/main/java/com/xly/test4/common/exception/GlobalExceptionHandler.java
| @@ -4,10 +4,12 @@ import com.xly.test4.common.response.Result; | @@ -4,10 +4,12 @@ import com.xly.test4.common.response.Result; | ||
| 4 | import com.xly.test4.common.response.ResultCode; | 4 | import com.xly.test4.common.response.ResultCode; |
| 5 | import lombok.extern.slf4j.Slf4j; | 5 | import lombok.extern.slf4j.Slf4j; |
| 6 | import org.springframework.dao.DuplicateKeyException; | 6 | import org.springframework.dao.DuplicateKeyException; |
| 7 | +import org.springframework.http.HttpStatus; | ||
| 7 | import org.springframework.security.access.AccessDeniedException; | 8 | import org.springframework.security.access.AccessDeniedException; |
| 8 | import org.springframework.validation.FieldError; | 9 | import org.springframework.validation.FieldError; |
| 9 | import org.springframework.web.bind.MethodArgumentNotValidException; | 10 | import org.springframework.web.bind.MethodArgumentNotValidException; |
| 10 | import org.springframework.web.bind.annotation.ExceptionHandler; | 11 | import org.springframework.web.bind.annotation.ExceptionHandler; |
| 12 | +import org.springframework.web.bind.annotation.ResponseStatus; | ||
| 11 | import org.springframework.web.bind.annotation.RestControllerAdvice; | 13 | import org.springframework.web.bind.annotation.RestControllerAdvice; |
| 12 | 14 | ||
| 13 | @Slf4j | 15 | @Slf4j |
| @@ -27,6 +29,7 @@ public class GlobalExceptionHandler { | @@ -27,6 +29,7 @@ public class GlobalExceptionHandler { | ||
| 27 | } | 29 | } |
| 28 | 30 | ||
| 29 | @ExceptionHandler(AccessDeniedException.class) | 31 | @ExceptionHandler(AccessDeniedException.class) |
| 32 | + @ResponseStatus(HttpStatus.FORBIDDEN) | ||
| 30 | public Result<Object> handleAccessDenied(AccessDeniedException e) { | 33 | public Result<Object> handleAccessDenied(AccessDeniedException e) { |
| 31 | return Result.fail(ResultCode.FORBIDDEN, "权限不足"); | 34 | return Result.fail(ResultCode.FORBIDDEN, "权限不足"); |
| 32 | } | 35 | } |
backend/src/main/java/com/xly/test4/common/security/JwtAuthenticationFilter.java
0 → 100644
| 1 | +package com.xly.test4.common.security; | ||
| 2 | + | ||
| 3 | +import com.xly.test4.common.exception.BusinessException; | ||
| 4 | +import jakarta.servlet.FilterChain; | ||
| 5 | +import jakarta.servlet.ServletException; | ||
| 6 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 7 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 8 | +import org.springframework.http.HttpHeaders; | ||
| 9 | +import org.springframework.lang.NonNull; | ||
| 10 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
| 11 | +import org.springframework.security.core.authority.SimpleGrantedAuthority; | ||
| 12 | +import org.springframework.security.core.context.SecurityContextHolder; | ||
| 13 | +import org.springframework.stereotype.Component; | ||
| 14 | +import org.springframework.web.filter.OncePerRequestFilter; | ||
| 15 | + | ||
| 16 | +import java.io.IOException; | ||
| 17 | +import java.util.Collections; | ||
| 18 | +import java.util.List; | ||
| 19 | + | ||
| 20 | +@Component | ||
| 21 | +public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||
| 22 | + | ||
| 23 | + private static final String BEARER = "Bearer "; | ||
| 24 | + | ||
| 25 | + private final JwtTokenProvider tokenProvider; | ||
| 26 | + | ||
| 27 | + public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) { | ||
| 28 | + this.tokenProvider = tokenProvider; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + @Override | ||
| 32 | + protected void doFilterInternal(@NonNull HttpServletRequest request, | ||
| 33 | + @NonNull HttpServletResponse response, | ||
| 34 | + @NonNull FilterChain chain) throws ServletException, IOException { | ||
| 35 | + String header = request.getHeader(HttpHeaders.AUTHORIZATION); | ||
| 36 | + if (header != null && header.startsWith(BEARER)) { | ||
| 37 | + String token = header.substring(BEARER.length()); | ||
| 38 | + try { | ||
| 39 | + CurrentUser user = tokenProvider.parse(token); | ||
| 40 | + List<SimpleGrantedAuthority> authorities = user.getAuthorities() == null | ||
| 41 | + ? Collections.emptyList() | ||
| 42 | + : user.getAuthorities().stream().map(SimpleGrantedAuthority::new).toList(); | ||
| 43 | + UsernamePasswordAuthenticationToken auth = | ||
| 44 | + new UsernamePasswordAuthenticationToken(user, null, authorities); | ||
| 45 | + SecurityContextHolder.getContext().setAuthentication(auth); | ||
| 46 | + } catch (BusinessException e) { | ||
| 47 | + SecurityContextHolder.clearContext(); | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + chain.doFilter(request, response); | ||
| 51 | + } | ||
| 52 | +} |
backend/src/main/java/com/xly/test4/common/security/RestAccessDeniedHandler.java
0 → 100644
| 1 | +package com.xly.test4.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.test4.common.response.Result; | ||
| 5 | +import com.xly.test4.common.response.ResultCode; | ||
| 6 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 7 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 8 | +import org.springframework.http.MediaType; | ||
| 9 | +import org.springframework.security.access.AccessDeniedException; | ||
| 10 | +import org.springframework.security.web.access.AccessDeniedHandler; | ||
| 11 | +import org.springframework.stereotype.Component; | ||
| 12 | + | ||
| 13 | +import java.io.IOException; | ||
| 14 | + | ||
| 15 | +@Component | ||
| 16 | +public class RestAccessDeniedHandler implements AccessDeniedHandler { | ||
| 17 | + | ||
| 18 | + private final ObjectMapper objectMapper; | ||
| 19 | + | ||
| 20 | + public RestAccessDeniedHandler(ObjectMapper objectMapper) { | ||
| 21 | + this.objectMapper = objectMapper; | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + @Override | ||
| 25 | + public void handle(HttpServletRequest request, | ||
| 26 | + HttpServletResponse response, | ||
| 27 | + AccessDeniedException accessDeniedException) throws IOException { | ||
| 28 | + response.setStatus(HttpServletResponse.SC_FORBIDDEN); | ||
| 29 | + response.setContentType(MediaType.APPLICATION_JSON_VALUE); | ||
| 30 | + response.setCharacterEncoding("UTF-8"); | ||
| 31 | + objectMapper.writeValue(response.getWriter(), | ||
| 32 | + Result.fail(ResultCode.FORBIDDEN, "权限不足")); | ||
| 33 | + } | ||
| 34 | +} |
backend/src/main/java/com/xly/test4/common/security/RestAuthenticationEntryPoint.java
0 → 100644
| 1 | +package com.xly.test4.common.security; | ||
| 2 | + | ||
| 3 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 4 | +import com.xly.test4.common.response.Result; | ||
| 5 | +import com.xly.test4.common.response.ResultCode; | ||
| 6 | +import jakarta.servlet.http.HttpServletRequest; | ||
| 7 | +import jakarta.servlet.http.HttpServletResponse; | ||
| 8 | +import org.springframework.http.MediaType; | ||
| 9 | +import org.springframework.security.core.AuthenticationException; | ||
| 10 | +import org.springframework.security.web.AuthenticationEntryPoint; | ||
| 11 | +import org.springframework.stereotype.Component; | ||
| 12 | + | ||
| 13 | +import java.io.IOException; | ||
| 14 | + | ||
| 15 | +@Component | ||
| 16 | +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { | ||
| 17 | + | ||
| 18 | + private final ObjectMapper objectMapper; | ||
| 19 | + | ||
| 20 | + public RestAuthenticationEntryPoint(ObjectMapper objectMapper) { | ||
| 21 | + this.objectMapper = objectMapper; | ||
| 22 | + } | ||
| 23 | + | ||
| 24 | + @Override | ||
| 25 | + public void commence(HttpServletRequest request, | ||
| 26 | + HttpServletResponse response, | ||
| 27 | + AuthenticationException authException) throws IOException { | ||
| 28 | + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
| 29 | + response.setContentType(MediaType.APPLICATION_JSON_VALUE); | ||
| 30 | + response.setCharacterEncoding("UTF-8"); | ||
| 31 | + objectMapper.writeValue(response.getWriter(), | ||
| 32 | + Result.fail(ResultCode.UNAUTHENTICATED, "未认证")); | ||
| 33 | + } | ||
| 34 | +} |
backend/src/main/java/com/xly/test4/common/security/SecurityConfig.java
0 → 100644
| 1 | +package com.xly.test4.common.security; | ||
| 2 | + | ||
| 3 | +import org.springframework.context.annotation.Bean; | ||
| 4 | +import org.springframework.context.annotation.Configuration; | ||
| 5 | +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; | ||
| 6 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | ||
| 7 | +import org.springframework.security.config.http.SessionCreationPolicy; | ||
| 8 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| 9 | +import org.springframework.security.crypto.password.PasswordEncoder; | ||
| 10 | +import org.springframework.security.web.SecurityFilterChain; | ||
| 11 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
| 12 | + | ||
| 13 | +@Configuration | ||
| 14 | +@EnableMethodSecurity | ||
| 15 | +public class SecurityConfig { | ||
| 16 | + | ||
| 17 | + private final JwtAuthenticationFilter jwtAuthenticationFilter; | ||
| 18 | + private final RestAuthenticationEntryPoint entryPoint; | ||
| 19 | + private final RestAccessDeniedHandler accessDeniedHandler; | ||
| 20 | + | ||
| 21 | + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, | ||
| 22 | + RestAuthenticationEntryPoint entryPoint, | ||
| 23 | + RestAccessDeniedHandler accessDeniedHandler) { | ||
| 24 | + this.jwtAuthenticationFilter = jwtAuthenticationFilter; | ||
| 25 | + this.entryPoint = entryPoint; | ||
| 26 | + this.accessDeniedHandler = accessDeniedHandler; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + @Bean | ||
| 30 | + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { | ||
| 31 | + http | ||
| 32 | + .csrf(csrf -> csrf.disable()) | ||
| 33 | + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
| 34 | + .authorizeHttpRequests(req -> req | ||
| 35 | + .requestMatchers( | ||
| 36 | + "/api/auth/login", | ||
| 37 | + "/v3/api-docs/**", | ||
| 38 | + "/swagger-ui/**", | ||
| 39 | + "/swagger-ui.html", | ||
| 40 | + "/actuator/health" | ||
| 41 | + ).permitAll() | ||
| 42 | + .requestMatchers("/api/**").authenticated() | ||
| 43 | + .anyRequest().permitAll() | ||
| 44 | + ) | ||
| 45 | + .exceptionHandling(eh -> eh | ||
| 46 | + .authenticationEntryPoint(entryPoint) | ||
| 47 | + .accessDeniedHandler(accessDeniedHandler)) | ||
| 48 | + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | ||
| 49 | + return http.build(); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + @Bean | ||
| 53 | + public PasswordEncoder passwordEncoder() { | ||
| 54 | + return new BCryptPasswordEncoder(); | ||
| 55 | + } | ||
| 56 | +} |
backend/src/test/java/com/xly/test4/common/security/SecurityIntegrationIT.java
0 → 100644
| 1 | +package com.xly.test4.common.security; | ||
| 2 | + | ||
| 3 | +import com.xly.test4.common.response.Result; | ||
| 4 | +import com.xly.test4.support.TestJwtFactory; | ||
| 5 | +import org.junit.jupiter.api.Test; | ||
| 6 | +import org.springframework.beans.factory.annotation.Autowired; | ||
| 7 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | ||
| 8 | +import org.springframework.boot.test.context.SpringBootTest; | ||
| 9 | +import org.springframework.boot.test.context.TestConfiguration; | ||
| 10 | +import org.springframework.context.annotation.Bean; | ||
| 11 | +import org.springframework.http.HttpHeaders; | ||
| 12 | +import org.springframework.security.access.prepost.PreAuthorize; | ||
| 13 | +import org.springframework.test.web.servlet.MockMvc; | ||
| 14 | +import org.springframework.web.bind.annotation.GetMapping; | ||
| 15 | +import org.springframework.web.bind.annotation.RestController; | ||
| 16 | + | ||
| 17 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| 18 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | ||
| 19 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
| 20 | + | ||
| 21 | +@SpringBootTest | ||
| 22 | +@AutoConfigureMockMvc | ||
| 23 | +class SecurityIntegrationIT { | ||
| 24 | + | ||
| 25 | + @Autowired | ||
| 26 | + private MockMvc mockMvc; | ||
| 27 | + | ||
| 28 | + @Autowired | ||
| 29 | + private JwtTokenProvider tokenProvider; | ||
| 30 | + | ||
| 31 | + @TestConfiguration | ||
| 32 | + static class ProbeConfig { | ||
| 33 | + @Bean | ||
| 34 | + ProbeController probeController() { | ||
| 35 | + return new ProbeController(); | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + @RestController | ||
| 40 | + static class ProbeController { | ||
| 41 | + @GetMapping("/api/_probe/secured") | ||
| 42 | + @PreAuthorize("hasAuthority('usr:user:create')") | ||
| 43 | + public Result<String> secured() { | ||
| 44 | + return Result.success("ok"); | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + @Test | ||
| 49 | + void unauthenticatedRequestToSecuredEndpoint_returns401AndCode40101() throws Exception { | ||
| 50 | + mockMvc.perform(get("/api/_probe/secured")) | ||
| 51 | + .andExpect(status().isUnauthorized()) | ||
| 52 | + .andExpect(jsonPath("$.code").value(40101)) | ||
| 53 | + .andExpect(jsonPath("$.message").value("未认证")); | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + @Test | ||
| 57 | + void invalidJwt_returns401AndCode40101() throws Exception { | ||
| 58 | + mockMvc.perform(get("/api/_probe/secured") | ||
| 59 | + .header(HttpHeaders.AUTHORIZATION, "Bearer not-a-real-token")) | ||
| 60 | + .andExpect(status().isUnauthorized()) | ||
| 61 | + .andExpect(jsonPath("$.code").value(40101)); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + @Test | ||
| 65 | + void validJwtMissingRequiredAuthority_returns403AndCode40301() throws Exception { | ||
| 66 | + String token = TestJwtFactory.normalUserToken(tokenProvider, 99, "alice"); | ||
| 67 | + mockMvc.perform(get("/api/_probe/secured") | ||
| 68 | + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) | ||
| 69 | + .andExpect(status().isForbidden()) | ||
| 70 | + .andExpect(jsonPath("$.code").value(40301)) | ||
| 71 | + .andExpect(jsonPath("$.message").value("权限不足")); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + @Test | ||
| 75 | + void validAdminJwt_returns200() throws Exception { | ||
| 76 | + String token = TestJwtFactory.adminToken(tokenProvider); | ||
| 77 | + mockMvc.perform(get("/api/_probe/secured") | ||
| 78 | + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) | ||
| 79 | + .andExpect(status().isOk()) | ||
| 80 | + .andExpect(jsonPath("$.code").value(200)) | ||
| 81 | + .andExpect(jsonPath("$.data").value("ok")); | ||
| 82 | + } | ||
| 83 | +} |
backend/src/test/java/com/xly/test4/support/TestJwtFactory.java
0 → 100644
| 1 | +package com.xly.test4.support; | ||
| 2 | + | ||
| 3 | +import com.xly.test4.common.security.CurrentUser; | ||
| 4 | +import com.xly.test4.common.security.JwtTokenProvider; | ||
| 5 | + | ||
| 6 | +import java.util.List; | ||
| 7 | + | ||
| 8 | +public final class TestJwtFactory { | ||
| 9 | + | ||
| 10 | + private TestJwtFactory() { | ||
| 11 | + } | ||
| 12 | + | ||
| 13 | + public static String adminToken(JwtTokenProvider provider) { | ||
| 14 | + return adminToken(provider, 1); | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + public static String adminToken(JwtTokenProvider provider, Integer userId) { | ||
| 18 | + return provider.issue(CurrentUser.builder() | ||
| 19 | + .userId(userId) | ||
| 20 | + .userName("admin") | ||
| 21 | + .brandsId("BR-DEFAULT") | ||
| 22 | + .subsidiaryId("SUB-DEFAULT") | ||
| 23 | + .authorities(List.of("usr:user:create", "usr:user:update", | ||
| 24 | + "usr:user:list", "usr:user:assign-role")) | ||
| 25 | + .build()); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + public static String normalUserToken(JwtTokenProvider provider, Integer userId, String userName) { | ||
| 29 | + return provider.issue(CurrentUser.builder() | ||
| 30 | + .userId(userId) | ||
| 31 | + .userName(userName) | ||
| 32 | + .brandsId("BR-DEFAULT") | ||
| 33 | + .subsidiaryId("SUB-DEFAULT") | ||
| 34 | + .authorities(List.of()) | ||
| 35 | + .build()); | ||
| 36 | + } | ||
| 37 | +} |