一、问题描述
在开发员工管理系统(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() : "操作失败");
}
}