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() : "操作失败");
    }
}
相关推荐
苹果酱05679 分钟前
「Mysql优化大师一」mysql服务性能剖析工具
java·vue.js·spring boot·mysql·课程设计
武昌库里写JAVA13 分钟前
【MySQL】7.0 入门学习(七)——MySQL基本指令:帮助、清除输入、查询等
spring boot·spring·毕业设计·layui·课程设计
黑胡子大叔的小屋6 小时前
基于springboot的海洋知识服务平台的设计与实现
java·spring boot·毕业设计
计算机毕设孵化场7 小时前
计算机毕设-基于springboot的校园社交平台的设计与实现(附源码+lw+ppt+开题报告)
spring boot·课程设计·计算机毕设论文·计算机毕设ppt·计算机毕业设计选题推荐·计算机选题推荐·校园社交平台
苹果醋37 小时前
Golang的文件加密工具
运维·vue.js·spring boot·nginx·课程设计
小马爱打代码9 小时前
Spring Boot 中 Map 的最佳实践
java·spring boot·spring
全栈开发帅帅10 小时前
基于springboot+vue实现的博物馆游客预约系统 (源码+L文+ppt)4-127
java·spring boot·后端
m0_7482556510 小时前
Springboot基于Web的景区疫情预警系统设计与实现5170q(程序+源码+数据库+调试部署+开发环境)
前端·数据库·spring boot
平行线也会相交11 小时前
云图库平台(三)——后端用户模块开发
数据库·spring boot·mysql·云图库平台
lxyzcm11 小时前
深入理解C++23的Deducing this特性(上):基础概念与语法详解
开发语言·c++·spring boot·设计模式·c++23