一、Spring Security异常分类
Spring Security中的异常主要分为两大类
- 认证异常(
AuthenticationException
),这个是所有认证异常的父类 - 权限异常(
AccessDeniedException
),这个是所有权限异常的父类
除此之外系统中遇到的异常则抛出,交给spring去处理,比如全局异常处理。
认证异常(AuthenticationException
)所有类
权限异常(AccessDeniedException
)所有类
二、原理分析
该过滤器主要处理上面说的两类异常AuthenticationException和AccessDeniedException,其他异常则会继续抛出,交给上一层容器去处理。
在WebSecurityConfigurerAdapter的getHttp
方法中进行HttpSecurity
初始化的时候,就调用了exceptionHandling()
方法去配置ExceptionTranslationFilter
过滤器:
protected final HttpSecurity getHttp() throws Exception { // ... if (!this.disableDefaults) { applyDefaultConfiguration(this.http); // ... } configure(this.http); return this.http; } private void applyDefaultConfiguration(HttpSecurity http) throws Exception { http.csrf(); http.addFilter(new WebAsyncManagerIntegrationFilter()); http.exceptionHandling(); http.headers(); http.sessionManagement(); http.securityContext(); http.requestCache(); http.anonymous(); http.servletApi(); http.apply(new DefaultLoginPageConfigurer<>()); http.logout(); }
exceptionHandling()
方法就是调用ExceptionHandlingConfigurer
去配置,ExceptionTranslationFilter
源码如下
@Override public void configure(H http) { // 获取认证失败时的处理器 AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(http); // 创建ExceptionTranslationFilter过滤器并传入entryPoint ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(entryPoint, getRequestCache(http)); // 获取权限异常处理器 AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http); exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler); // 注册到spring容器中 exceptionTranslationFilter = postProcess(exceptionTranslationFilter); // 添加到spring security过滤器链中 http.addFilter(exceptionTranslationFilter); }
AuthenticationEntryPoint
实例是通过getAuthenticationEntryPoint
方法获取到的:
AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { // 默认情况下,系统的authenticationEntryPoint属性值为null AuthenticationEntryPoint entryPoint = this.authenticationEntryPoint; if (entryPoint == null) { entryPoint = createDefaultEntryPoint(http); } return entryPoint; } private AuthenticationEntryPoint createDefaultEntryPoint(H http) { // defaultEntryPointMappings -> LinkedHashMap<RequestMatcher, AuthenticationEntryPoint>, // 即一个请求匹配器对应一个认证失败处理器,针对不同的请求,可以给出不同的认证失败处理器 if (this.defaultEntryPointMappings.isEmpty()) { return new Http403ForbiddenEntryPoint(); } if (this.defaultEntryPointMappings.size() == 1) { return this.defaultEntryPointMappings.values().iterator().next(); } // 如果defaultEntryPointMappings变量中有多项,则使用DelegatingAuthenticationEntryPoint代理类,在代理类中, // 会遍历defaultEntryPointMappings中的每一项,查看当前请求是否满足其RequestMatcher,如果满足,则使用对应的 // 认证失败处理器来处理 DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint( this.defaultEntryPointMappings); entryPoint.setDefaultEntryPoint(this.defaultEntryPointMappings.values().iterator().next()); return entryPoint; }
当新建一个spring security项目时,不做任何配置时,在WebSecurityConfigurerAdapter#configure(HttpSecurity)方法中默认会配置表单登录和HTTP基本认证,而在这个配置过程中,会分别向defaultEntryPointMappings中添加认证失败处理器:
表单登录在AbstractAuthenticationFilterConfigurer#registerAuthenticationEntryPoint方法中向defaultEntryPointMappings变量添加的处理器,对应的AuthenticationEntryPoint实例是LoginUrlAuthenticationEntryPoint,默认情况下访问需要认证才能访问的页面时,会自动跳转到登录页面,就是通过LoginUrlAuthenticationEntryPoint实现的。
HTTP基本认证在HttpBasicConfigurer#registerDefaultEntryPoint方法中向defaultEntryPointMappings变量添加处理器,对应的AuthenticationEntryPoint实例是BasicAuthenticationEntryPoint。
所以默认情况下,defaultEntryPointMappings变量中将存在两个认证失败处理器。
AccessDeniedHandler
实例是通过getAccessDeniedHandler
方法获取到的:
// 获取流程和AuthenticationEntryPoint基本上一模一样 AccessDeniedHandler getAccessDeniedHandler(H http) { AccessDeniedHandler deniedHandler = this.accessDeniedHandler; if (deniedHandler == null) { deniedHandler = createDefaultDeniedHandler(http); } return deniedHandler; } private AccessDeniedHandler createDefaultDeniedHandler(H http) { // 不同的是,默认情况下这里的defaultDeniedHandlerMappings变量是空的,所以最终获取到的实例是AccessDeniedHandlerImpl。 // 在AccessDeniedHandlerImpl#handle方法中处理鉴权失败的情况,如果存在错误页面,就跳转到错误页面,并设置响应码为403; // 如果没有错误页面,则直接给出错误响应即可 if (this.defaultDeniedHandlerMappings.isEmpty()) { return new AccessDeniedHandlerImpl(); } if (this.defaultDeniedHandlerMappings.size() == 1) { return this.defaultDeniedHandlerMappings.values().iterator().next(); } return new RequestMatcherDelegatingAccessDeniedHandler(this.defaultDeniedHandlerMappings, new AccessDeniedHandlerImpl()); }
AuthenticationEntryPoint
和AccessDeniedHandler
都有了之后,接下来就是ExceptionTranslationFilter
中的处理逻辑了。
默认情况下,ExceptionTranslationFilter过滤器在整个spring security过滤器链中排名倒数第二,倒数第一是FilterSecurityInterceptor。在FilterSecurityInterceptor中将会对用户的身份进行校验,如果用户身份不合法,就会抛出异常,抛出来的异常,刚好就在ExceptionTranslationFilter过滤器中进行处理了:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { // 用一个try/catch块将chain.doFilter包裹起来,如果后面有异常抛出,就直接在这里捕获到了 try { // 直接执行了chain.doFilter方法,让当前请求继续执行剩下的过滤器(即FilterSecurityInterceptor) chain.doFilter(request, response); } catch (IOException ex) { throw ex; } catch (Exception ex) { // throwableAnalyzer对象是一个异常分析器,由于异常在抛出的过程中可能被层层转包,需要还原最初的异常, // 通过throwableAnalyzer.determineCauseChain方法可以获得整个异常链 Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex); // 获取到异常链后调用getFirstThrowableOfType方法查看异常链中是否有认证失败类型的异常AuthenticationException, // 注意查找顺序,先找认证异常,再找鉴权异常,如果存在这两种类型的异常,则调用handleSpringSecurityException方法 // 进行异常处理,否则将异常抛出交给上层容器去处理 RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer .getFirstThrowableOfType(AuthenticationException.class, causeChain); if (securityException == null) { securityException = (AccessDeniedException) this.throwableAnalyzer .getFirstThrowableOfType(AccessDeniedException.class, causeChain); } if (securityException == null) { rethrow(ex); } if (response.isCommitted()) { throw new ServletException("Unable to handle the Spring Security Exception " + "because the response is already committed.", ex); } handleSpringSecurityException(request, response, chain, securityException); } } private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException { // 判断异常类型是不是AuthenticationException,如果是,则进入handleAuthenticationException方法中处理认证失败 if (exception instanceof AuthenticationException) { handleAuthenticationException(request, response, chain, (AuthenticationException) exception); } // 如果异常类型是AccessDeniedException,则进入handleAccessDeniedException方法中处理 else if (exception instanceof AccessDeniedException) { handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception); } } private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException { sendStartAuthentication(request, response, chain, exception); } private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException { // 从SecurityContextHolder中取出当前认证主体 Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 判断是否是匿名用户 boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication); // 如果是匿名用户,或者当前认证是通过remember-me完成的,那么也认为是认证异常,需要重新创建一个 // InsufficientAuthenticationException类型的异常对象,然后进入sendStartAuthentication // 方法进行处理,否则就认为是鉴权异常,调用accessDeniedHandler.handle方法进行处理 if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) { sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException( this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource"))); } else { this.accessDeniedHandler.handle(request, response, exception); } } protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException { // 清除SecurityContextHolder的认证主体,因为现有的认证不再被认为是有效的 SecurityContext context = SecurityContextHolder.createEmptyContext(); SecurityContextHolder.setContext(context); // 保存当前请求 this.requestCache.saveRequest(request, response); // 调用authenticationEntryPoint.commence方法完成认证失败处理 this.authenticationEntryPoint.commence(request, response, reason); } // 至此,AuthenticationEntryPoint和AccessDeniedHandler在这里就都派上用场了
三、如何自定义异常处理
Spring security中默认提供的异常处理器不一定满足项目的需求,那这时一般都会在Spring Security 的 自定义配置类( WebSecurityConfigurerAdapter )中使用HttpSecurity 提供的 exceptionHandling() 方法用来提供异常处理。该方法构造出 ExceptionHandlingConfigurer 异常处理配置类。该配置类提供了两个实用接口:
AuthenticationEntryPoint 该类用来统一处理 AuthenticationException 异常
AccessDeniedHandler 该类用来统一处理 AccessDeniedException 异常
我们只要实现并配置这两个异常处理类即可实现对 Spring Security 认证授权相关的异常进行统一的自定义处理。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and().formLogin() //开启异常处理 .and().exceptionHandling() //处理认证异常 .authenticationEntryPoint((request,response,e)->{ if(e instanceof LockedException){ //e是走的父类AuthenticationException,针对指定子类异常可以自定义一些逻辑 } response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("没有认证"); }) //处理授权异常 .accessDeniedHandler((request,response,e)->{ response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("授权异常"); }) .and().csrf().disable(); } }
当然,开发者也可以为不同的接口配置不同的异常处理器:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { AntPathRequestMatcher matcher1 = new AntPathRequestMatcher("/qq/**"); AntPathRequestMatcher matcher2 = new AntPathRequestMatcher("/wx/**"); http.authorizeRequests() .antMatchers("/wx/**").hasRole("wx") .antMatchers("/qq/**").hasRole("qq") .anyRequest().authenticated() .and() .exceptionHandling() .defaultAuthenticationEntryPointFor((request, response, authException) -> { response.setContentType("text/html;charset=utf-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("请登录, QQ用户"); }, matcher1) .defaultAuthenticationEntryPointFor((request, response, accessDeniedException) -> { response.setContentType("text/html;charset=utf-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("请登录, WX用户"); }, matcher2) .defaultAccessDeniedHandlerFor((request, response, accessDeniedException) -> { response.setContentType("text/html;charset=utf-8"); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("权限不足, QQ用户"); }, matcher1) .defaultAccessDeniedHandlerFor((request, response, accessDeniedException) -> { response.setContentType("text/html;charset=utf-8"); response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write("权限不足, WX用户"); }, matcher2) .and() .formLogin() .and() .csrf().disable(); } }
配置完成后,不同接口将会给出不同的异常响应。
注意
如果是springboot+security项目,并且定义了全局异常,那上面的授权异常AccessDeniedException及子类和认证异常AuthenticationException及子类都会有优先被全局异常,导致security配置的异常处理不起作用,主要原因是执行先后的问题,这里放一张 图
可以看到当发生授权异常AccessDeniedException及子类和认证异常AuthenticationException及子类都会有优先被全局异常ControllerAdvice处理,所以如果还是想让security自己处理,我们在全局异常中捕获到上述异常,直接抛出就行,代码如下
/** * 全局异常处理 * spring+security项目中的异常 * 1、security中的两大异常授权异常(AccessDeniedException)+认证异常(AuthenticationException)交由security自己处理 * 2、其他异常自己处理 */ @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); //security的授权异常(AccessDeniedException及子类)抛出交由security AuthenticationEntryPoint 处理 @ExceptionHandler(AccessDeniedException.class) public void accessDeniedException(AccessDeniedException e) throws AccessDeniedException { throw e; } //security的认证异常(AuthenticationException及子类)抛出由security AccessDeniedHandler 处理 @ExceptionHandler(AuthenticationException.class) public void authenticationException(AuthenticationException e) throws AuthenticationException { throw e; } //未知异常 @ExceptionHandler(Exception.class) public Result otherException(Exception e) { e.printStackTrace(); logger.error("系统异常 全局拦截异常信息:{}",e.getMessage()); return Result.error(e.getMessage()); } }