Commit ad1477979d1a2d2d5625d15b0260f8953dd936d8

Authored by zichun
1 parent 0d074e78

feat(usr): Spring Security + JWT filter 链 + 401/403 Result 转译 REQ-USR-001

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 +}