Spring Security权限控制系列(三)

环境:Springboot2.4.12 + Spring Security 5.4.9


本篇主要内容:自定义异常处理

上一篇:《Spring Security权限控制系列(二)

注意记得不要忘记关闭CSRF功能,由于之前的案例演示开启了CSRF,忘记关闭,导致在本篇案例中在登录时总是403状态码,点登录后通过调试发现请求的url是总是/error(我自定义登录页面并没有添加_csrf隐藏域字段)。

默认异常原理

基于前面两篇的内容我们发现只要没有无权限访问接口,就会报错误,错误信息如下:

登录成功后五权限访问接口时默认的返回错误信息

错误的用户名或密码时

接下来我们看看系统默认是如何提供该错误页面信息的

  • 错误的用户名密码

当登录时填写的错误用户名或密码时,再次返回了登录页面,并且携带了错误信息。接下来通过源码查看这部分路径。

当前配置:

public class SecurityConfig extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthentication()
      .passwordEncoder(NoOpPasswordEncoder.getInstance())
      .withUser("guest").password("123456").roles("ADMIN")
      .and()
      .withUser("test").password("666666").roles("USERS") ;
  }
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() ;
    http.authorizeRequests().antMatchers("/resources/**", "/cache/**").permitAll() ;
    http.authorizeRequests().antMatchers("/demos/**").hasRole("USERS") ;
    http.authorizeRequests().antMatchers("/api/**").hasRole("ADMIN") ;
    http.formLogin().loginPage("/custom/login") ;
  }
}

上面我们自定义了登录页面/custom/login,所以我们的过滤器链中有个核心的过滤器 UsernamePasswordAuthenticationFilter 该过滤器专门用来处理POST提交的登录URI,我们这里自定义了所以该过滤器拦截的是/custom/login,该过滤器在判断当前请求的时候会先判断是不是POST方式提交的,然后判断URI,所以我们在浏览器直接访问该uri的时候是不会发生任何认证逻辑处理的。

登录认证的流程:

  1. UsernamePasswordAuthenticationFilter#attemptAuthentication
  2. ProviderManager#authenticate
  3. AuthenticationProvider#authenticate

在第三步中首先判断的是用户名是否存在,如果不存在则会抛出BadCredentialsException 异常。

public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider {
  public Authentication authenticate(Authentication authentication) {
    try {
      user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
    } catch (UsernameNotFoundException ex) {
      // 通过国际化资源获取key = AbstractUserDetailsAuthenticationProvider.badCredentials
      // 的错误信息,如果没有自定义,则默认显示Bad credentials。
      // 该异常信息抛到了ProviderManager中
      throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    }
  }
}

ProviderManager处理异常

// 这里需要注意,在默认的情况下,我们当前的认证出来逻辑已经是在父ProviderManager中进行处理了
public class ProviderManager {
  public Authentication authenticate(Authentication authentication) {
    AuthenticationException lastException = null;
    // ...
    for (AuthenticationProvider provider : getProviders()) {
      try {
        result = provider.authenticate(authentication);
      } catch (AuthenticationException ex) {
        lastException = ex;
      }
    }
    // ...
    // 注意这里其实继续将异常抛给了子ProviderManager对象
    throw lastException;
  }
}

ProviderManager处理异常

public class ProviderManager {
  public Authentication authenticate(Authentication authentication) {
    AuthenticationException lastException = null;
		AuthenticationException parentException = null;
    // ...
    if (result == null && this.parent != null) {
      try {
        parentResult = this.parent.authenticate(authentication);
        result = parentResult;
      } catch (AuthenticationException ex) {
        // 进入该处
        parentException = ex;
        lastException = ex;
      }
    }
    // ...
    throw lastException;
  }
}

过滤器UsernamePasswordAuthenticationFilter接收到异常,该异常是有该过滤器的父类中进行处理。

public abstract class AbstractAuthenticationProcessingFilter {
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
  }
  private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
      Authentication authenticationResult = attemptAuthentication(request, response);
    } catch (AuthenticationException ex) {
      unsuccessfulAuthentication(request, response, ex);
    }
  }
  protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
    SecurityContextHolder.clearContext();
    // ...
    // 默认failureHandler = SimpleUrlAuthenticationFailureHandler
    // 这里也就是我们自定义的一个功能点
    this.failureHandler.onAuthenticationFailure(request, response, failed);
  }
}
public class SimpleUrlAuthenticationFailureHandler {
  public void onAuthenticationFailure(...) {
    // 将异常保存到Session对象中
    saveException(request, exception);
    // 最后直接Redirect调整到登录页面
    // defaultFailureUrl = /custom/login?error
    this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
  }
  protected final void saveException(HttpServletRequest request, AuthenticationException exception) {
    HttpSession session = request.getSession(false);
    if (session != null || this.allowSessionCreation) {
      // AUTHENTICATION_EXCEPTION = SPRING_SECURITY_LAST_EXCEPTION
      // 在页面中就可以通过Session获取异常的信息了
      // 在上一篇的文章中自定义登录页面中就有从该session中获取异常信息
      request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception);
    }
  }
}

以上就是Spring Security在处理登录失败的情景下如何进行处理的,同时我们也知道了为 UsernamePasswordAuthenticationFilter(父类) 配置AuthenticationFailureHandler是一个自定义的扩展点,我们可以在自定义的SecurityConfig中配置该失败句柄。

  • 无权限的异常

在上面的自定义配置中我们配置了两个用户

  1. guest ADMIN
  2. test USERS

/demos/** 一类的请求必须拥有 USERS 权限(角色)

/api/** 一类的请求必须拥有 ADMIN 权限(角色)

接下来通过guest用户登录后,访问/demos/home接口查看默认的错误显示

该授权检查的流程:

  1. FilterSecurityInterceptor#invoke
  2. AbstractSecurityInterceptor#beforeInvocation
  3. AbstractSecurityInterceptor#attemptAuthorization

在上面的流程中主要核心方法是attemptAuthorization尝试授权操作。

public abstract class AbstractSecurityInterceptor {
  protected InterceptorStatusToken beforeInvocation(Object object) {
    // ...
    attemptAuthorization(object, attributes, authenticated);
    // ...
  }
  private void attemptAuthorization(...) {
    try {
      // accessDecisionManager = AffirmativeBased
      this.accessDecisionManager.decide(authenticated, object, attributes);
    } catch (AccessDeniedException ex) {
      // ...
      // 异常抛给了子类处理
      throw ex;
    }
  }
}
public class AffirmativeBased extends AbstractAccessDecisionManager {
  // 该方法开始判断当前登录的用户信息是否具有相应的权限信息
  public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
    int deny = 0;
    for (AccessDecisionVoter voter : getDecisionVoters()) {
      int result = voter.vote(authentication, object, configAttributes);
      switch (result) {
        case AccessDecisionVoter.ACCESS_GRANTED:
          return;
        case AccessDecisionVoter.ACCESS_DENIED:
          deny++;
          break;
        default:
          break;
      }
    }
    // 当拒绝次数 > 0 那么将会抛出AccessDeniedException异常
    // 默认的异常信息会先从国际化资源中获取key = AbstractAccessDecisionManager.accessDenied
    // 如果没有配置,则默认信息:Access is denied
    if (deny > 0) {
      throw new AccessDeniedException(this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
    }
  }
}

最终异常AccessDeniedException并没在FilterSecurityInterceptor中进行处理,那么该异常就会被过滤器链中的ExceptionTranslationFilter中得到处理

public class ExceptionTranslationFilter extends GenericFilterBean {
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
  }
  private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
      chain.doFilter(request, response);
    } catch (Exception ex) {
      Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
      RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
      if (securityException == null) {
        securityException = (AccessDeniedException) this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
      }
      // 处理异常
      handleSpringSecurityException(request, response, chain, securityException);
    }
  }
  private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,FilterChain chain, RuntimeException exception) throws IOException, ServletException {
    if (exception instanceof AuthenticationException) {
      handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
    } else if (exception instanceof AccessDeniedException) {
      // 处理被拒绝的异常
      handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
    }
  }
  private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
    // ...
    // accessDeniedHandler = AccessDeniedHandlerImpl
    // 访问拒绝句柄的默认实现
    // 这里也就成为了我们的一个自定义处理点
    this.accessDeniedHandler.handle(request, response, exception);
  }
}
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
  public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
    if (this.errorPage == null) {
      // 默认这里的errorPage = null ,所以执行这里的逻辑
      // 这设置响应状态码403
      response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
      return;
    }
    // Put exception into request scope (perhaps of use to a view)
    request.setAttribute(WebAttributes.ACCESS_DENIED_403, accessDeniedException);
    // Set the 403 status code.
    response.setStatus(HttpStatus.FORBIDDEN.value());
    request.getRequestDispatcher(this.errorPage).forward(request, response);
  }
}

到此你应该了解到了,当我们没有权限访问资源时默认是如何处理的,同时也了解到了如何进行自定义异常处理句柄。

自定义异常配置

上面介绍了错误产生的原理及了解到了自定义异常处理句柄的方法,接下来通过自定义的方式展示错误信息。

  • 错误的用户名密码
protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable() ;
  http.authorizeRequests().antMatchers("/resources/**", "/cache/**").permitAll() ;
  http.authorizeRequests().antMatchers("/demos/**").hasRole("USERS") ;
  http.authorizeRequests().antMatchers("/api/**").hasRole("ADMIN") ;
  http
    .formLogin()
    .failureHandler(new AuthenticationFailureHandler() {
      @Override
      public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8") ;
        PrintWriter out = response.getWriter() ;
        out.println("{\"code\": -1, \"message\": \"" + exception.getMessage() + "\"}") ;
        out.close();
      }
    })
    .loginPage("/custom/login") ;
}

我们也可以将上面的AuthenticationFailureHandler 定义为一个Bean对象这样方便我们做其它的一些操作。

登录测试

  • 无权限的异常

上面介绍了当没有权限访问指定的资源时错误产生的原理及了解到了自定义拒绝访问句柄的方法,接下来通过自定义的方式展示错误信息。

1 自定义访问拒绝页面的方式

在如下位置新建denied.html页面

// 自定义Controller
@Controller
public class ErrorController {
  @GetMapping("/access/denied")
  public String denied() {
    return "denied" ;
  }
}
// 自定义配置
protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable() ;
  http.authorizeRequests().antMatchers("/resources/**", "/cache/**").permitAll() ;
  http.authorizeRequests().antMatchers("/demos/**").hasRole("USERS") ;
  http.authorizeRequests().antMatchers("/api/**").hasRole("ADMIN") ;
  http
    .formLogin()
    .failureHandler(new AuthenticationFailureHandler() {
      @Override
      public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8") ;
        PrintWriter out = response.getWriter() ;
        out.println("{\"code\": -1, \"message\": \"" + exception.getMessage() + "\"}") ;
        out.close();
      }
    })
    .loginPage("/custom/login") ;
  // 自定义访问拒绝页面
  http.exceptionHandling().accessDeniedPage("/access/denied") ;
}

简单的页面内容

<h1>Access Denied</h1>

测试

2 自定义403错误页面

将上面的http.exceptionHandling().accessDeniedPage("/access/denied") 代码注释了

然后在下面位置新建403.html页面

简单的页面内容

<h1>Denied Access This is page</h1>

测试

3 自定义访问拒绝句柄的方式

protected void configure(HttpSecurity http) throws Exception {
  http.csrf().disable() ;
  http.authorizeRequests().antMatchers("/resources/**", "/cache/**").permitAll() ;
  http.authorizeRequests().antMatchers("/demos/**").hasRole("USERS") ;
  http.authorizeRequests().antMatchers("/api/**").hasRole("ADMIN") ;
  http
    .formLogin()
    .failureHandler(new AuthenticationFailureHandler() {
      @Override
      public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setContentType("application/json;charset=UTF-8") ;
        PrintWriter out = response.getWriter() ;
        out.println("{\"code\": -1, \"message\": \"" + exception.getMessage() + "\"}") ;
        out.close();
      }
    })
    .loginPage("/custom/login") ;
  // 自定义访问拒绝页面
  // http.exceptionHandling().accessDeniedPage("/access/denied") ;
  http.exceptionHandling().accessDeniedHandler(new AccessDeniedHandler() {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
      response.setContentType("application/json;charset=UTF-8") ;
      PrintWriter out = response.getWriter() ;
      out.println("{\"code\": -1, \"message\": \"" + accessDeniedException.getMessage() + "\"}") ;
      out.close();
    }
  }) ;
}

测试

总结:

  1. 认证失败后的处理原理及自定义配置
  2. 授权失败后的处理原理及自定义配置

到此本篇内容结束。下一篇将介绍:

  1. 核心过滤器创建原理
  2. 自定义过滤器

SpringBoot项目中Redis之管道技术
SpringCloud Hystrix实现资源隔离应用
springboot mybatis jpa 实现读写分离
Springboot中Redis事务的使用及Lua脚本
Spring Cloud 中断路器 Circuit Breaker的应用
SpringBoot开发自己的Starter
Springboot整合RabbitMQ死信队列详解
SpringBoot RabbitMQ消息可靠发送与接收
SpringMVC核心组件HandlerMapping你清楚了吗?
Spring Cloud 微服务日志收集管理Elastic Stack完整详细版
Spring Cloud链路追踪zipkin及整合Elasticsearch存储
SpringBoot一个提升N倍性能的操作
Springboot中接口参数校验N种方法你会几个?

举报
评论 0