Spring Security 6.3 权限异常处理实战解析

一、问题描述

在开发员工管理系统(EMS)时,遇到一个权限异常处理的问题:

java 复制代码
2024-12-24T20:33:42.891+08:00 ERROR 17144 --- [ems] [nio-8080-exec-2] 
com.ems.handler.GlobalExceptionHandler : 全局异常信息:Access Denied

明明在 SecurityConfig 中配置了 CustomerAccessDeniedHandler 来处理权限不足异常,但实际运行时异常却被 GlobalExceptionHandler 捕获并处理了(日志显示:"全局异常信息:Access Denied"),导致自定义的权限处理器失效。

二、核心代码分析

1. 权限不足处理器

java 复制代码
@Component
@Slf4j
public class CustomerAccessDeniedHandler implements AccessDeniedHandler{
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {

        log.error("权限不足,URI:{},异常:{}", request.getRequestURI(), accessDeniedException.getMessage());
        // 发生这个行为,做响应处理,给一个响应的结果
        response.setContentType("application/json;charset=utf-8");
        // 构建输出流对象
        ServletOutputStream outputStream = response.getOutputStream();
        // 调用fastjson工具,进行Result对象序列化
        String error = JSON.toJSONString(Result.error("权限不足,请联系管理员"));
        outputStream.write(error.getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}

2. Security 配置类

java 复制代码
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity //开启SpringSecurity的自定义配置(在SpringBoot项目中可以省略)
@EnableMethodSecurity // 开启方法级安全注解
public class SecurityConfig {

    // 自定义的用于认证的过滤器,进行jwt的校验操作
    private final JwtTokenOncePerRequestFilter jwtTokenFilter;

    // 认证用户无权限访问资源的处理器
    private final CustomerAccessDeniedHandler customerAccessDeniedHandler;

    // 客户端进行认证数据的提交时出现异常,或者是匿名用户访问受限资源的处理器
    private final AnonymousAuthenticationHandler anonymousAuthentication;

    // 用户认证校验失败处理器
    private final LoginFailureHandler loginFailureHandler;


    /**
     * 创建BCryptPasswordEncoder注入容器,用于密码加密
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * 登录时调用AuthenticationManager.authenticate执行一次校验
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        // 添加自定义异常处理类
        http.exceptionHandling(configurer -> {
            configurer.accessDeniedHandler(customerAccessDeniedHandler) // 配置认证用户无权限访问资源的处理器
                    .authenticationEntryPoint(anonymousAuthentication); // 配置匿名用户未认证的处理器
        });

        // 配置关闭csrf机制
        http.csrf(AbstractHttpConfigurer::disable);
        // 用户认证校验失败处理器
        http.formLogin(conf -> conf.failureHandler(loginFailureHandler));
        // STATELESS(无状态):表示应用程序是无状态的,不创建会话
        http.sessionManagement(conf -> conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        // 配置放行路径
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers(
                        "/swagger-ui/**",
                        "/swagger-ui.html",
                        "/swagger-resources/**",
                        "/v3/api-docs/**",
                        "/webjars/**",
                        "/doc.html",
                        "/emp/login"  // 修改登录接口路径
                ).permitAll()
                .anyRequest().authenticated()
        );
        // 配置过滤器的执行顺序
        http.addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }


}

3. JWT认证过滤器

java 复制代码
// 每一个servlet请求,只执行一次
@Component
@Slf4j
public class JwtTokenOncePerRequestFilter extends OncePerRequestFilter {

    @Autowired
    private JwtProperties jwtProperties;

    @Autowired
    private LoginFailureHandler loginFailureHandler;

    // 添加白名单路径列表
    private final String[] whitelist = {
            "/emp/login",
            "/swagger-ui/**",
            "/swagger-ui.html",
            "/swagger-resources/**",
            "/v3/api-docs/**",
            "/webjars/**",
            "/doc.html"
    };

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. 判断当前请求是否在白名单中
        String uri = request.getRequestURI();
        if (isWhitelisted(uri)) {
            filterChain.doFilter(request, response);
            return;
        }
        try {
            this.validateToken(request);
        } catch (AuthenticationException e) {
            loginFailureHandler.onAuthenticationFailure(request, response, e);
            return;
        }
        filterChain.doFilter(request, response);
    }

    // 判断请求路径是否在白名单中
    private boolean isWhitelisted(String uri) {
        for (String pattern : whitelist) {
            if (pattern.endsWith("/**")) {
                // 处理通配符路径
                String basePattern = pattern.substring(0, pattern.length() - 3);
                if (uri.startsWith(basePattern)) {
                    return true;
                }
            } else if (pattern.equals(uri)) {
                // 精确匹配
                return true;
            }
        }
        return false;
    }

    private void validateToken(HttpServletRequest request) {
        // 说明:登录了,再次请求其他需要认证的资源
        String token = request.getHeader("Authorization");
        if (ObjectUtils.isEmpty(token)) { // header没有token
            token = request.getParameter("Authorization");
        }
        if (ObjectUtils.isEmpty(token)) {
            throw new CustomerAuthenticationException("token为空");
        }
        // 校验token
        EmpLogin empLogin;
        try {
            log.info("jwt校验:{}", token);
            Claims claims = JwtUtil.parseJWT(jwtProperties.getSecretKey(), token);
            String loginUserString = claims.get(JwtClaimsConstant.EMP_LOGIN).toString();
            // 把json字符串转为对象
            empLogin = JSON.parseObject(loginUserString, EmpLogin.class);
            log.info("当前员工id:{}", empLogin.getEmp().getId());
            BaseContext.setCurrentId(empLogin.getEmp().getId());
        } catch (Exception ex) {
            throw new CustomerAuthenticationException("token校验失败");
        }
        // 把校验后的用户信息再次放入到SpringSecurity的上下文中
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(empLogin, null,empLogin.getAuthorities()); // 已认证的 Authentication 对象,包含用户的权限信息
        SecurityContextHolder.getContext().setAuthentication(authentication);
        System.out.println(empLogin.getAuthorities());
    }
}

三、问题原因

问题出在 Spring Security 的异常处理流程上:

bash 复制代码
Security过滤器链 --->  DispatcherServlet  --->  Controller  ----> AOP权限校验 ---->  全局异常处理

Spring Security 的权限校验机制

Spring Security 提供了两种权限校验方式:

  • URL级别校验(配置式):在过滤器链中进行
java 复制代码
// 1. URL级别的权限检查(配置式)
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated()
);
  • 方法级别校验(注解式):(@PreAuthorize)通过 AOP 实现
java 复制代码
@PreAuthorize("hasAuthority('ems:news.select')")
@GetMapping("/getNews")
public String getNews() {
    return "news列表";
}

问题根源

在这里我使用的是方法级别的权限检查,方法级的权限注解(@PreAuthorize)是通过 AOP 实现的,AOP 抛出的异常会被 Spring MVC 的异常处理机制捕获,因此@RestControllerAdvice 的优先级高于 Security 的异常处理器

三、解决方案

修改全局异常处理器

java 复制代码
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    
    @ExceptionHandler(Exception.class)
    public Result ex(Exception ex){
        // 权限异常转交给Security处理
        if(ex instanceof AccessDeniedException) {
            log.info("捕获到权限异常,转交给Security处理");
            throw (AccessDeniedException)ex;
        }
        log.error("全局异常信息:{}", ex.getMessage());
        return Result.error(StringUtils.hasLength(ex.getMessage()) ? 
            ex.getMessage() : "操作失败");
    }
}
相关推荐
贫民窟的勇敢爷们2 小时前
SpringBoot整合AOP切面编程实战,实现日志统一记录+接口权限校验
java·spring boot·spring
吾疾唯君医5 小时前
Java SpringBoot集成积木报表实操记录
java·spring boot·spring·导出excel·积木报表·数据文件下载
正儿八经的少年8 小时前
Spring Boot 两种激活配置方式的作用与区别
java·spring boot·后端
疯狂成瘾者9 小时前
Spring Boot 项目中的 SMTP 邮件验证码服务技术解析
java·spring boot·后端
啃臭10 小时前
AOP和反射
java·spring boot
河阿里11 小时前
SpringBoot:Spring Task定时任务完整使用教学
java·spring boot·spring
五阿哥永琪13 小时前
从0开始做一个导出功能,完整流程
spring boot
java1234_小锋14 小时前
SpringBoot可以同时处理多少请求?
java·spring boot·后端
海棠Flower未眠14 小时前
Spring Boot 3 + JPA多模块系统对MySQL和DORIS进行多数据源集成实战(荣耀典藏版)
spring boot·后端·mysql
北风朝向15 小时前
Spring Boot 集成 Open WebUI 实现 AI 流式对话
人工智能·spring boot·状态模式