环境:Springboot2.4.12 + Spring Security 5.4.9
本篇主要内容:自定义异常处理
上一篇:《Spring Security权限控制系列(二)》
注意:记得不要忘记关闭CSRF功能,由于之前的案例演示开启了CSRF,忘记关闭,导致在本篇案例中在登录时总是403状态码,点登录后通过调试发现请求的url是总是/error(我自定义登录页面并没有添加_csrf隐藏域字段)。
默认异常原理
基于前面两篇的内容我们发现只要没有无权限访问接口,就会报错误,错误信息如下:
登录成功后五权限访问接口时默认的返回错误信息
错误的用户名或密码时
接下来我们看看系统默认是如何提供该错误页面信息的。
错误的用户名密码
当登录时填写的错误用户名或密码时,再次返回了登录页面,并且携带了错误信息。接下来通过源码查看这部分路径。
当前配置:
public class SecurityConfig extends WebSecurityConfigurerAdapter {
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") ;
}
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的时候是不会发生任何认证逻辑处理的。
登录认证的流程:
- UsernamePasswordAuthenticationFilter#attemptAuthentication。
- ProviderManager#authenticate。
- 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中配置该失败句柄。
无权限的异常
在上面的自定义配置中我们配置了两个用户:
- guest ADMIN
- test USERS
- /demos/** 一类的请求必须拥有 USERS 权限(角色)。
- /api/** 一类的请求必须拥有 ADMIN 权限(角色)。
接下来通过guest用户登录后,访问/demos/home接口查看默认的错误显示。
该授权检查的流程:
- FilterSecurityInterceptor#invoke。
- AbstractSecurityInterceptor#beforeInvocation。
- 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() {
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对象这样方便我们做其它的一些操作。
登录测试:
无权限的异常
上面介绍了当没有权限访问指定的资源时错误产生的原理及了解到了自定义拒绝访问句柄的方法,接下来通过自定义的方式展示错误信息。
自定义访问拒绝页面的方式
在如下位置新建denied.html页面。
// 自定义Controller
public class ErrorController {
("/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() {
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>
测试:
自定义403错误页面
将上面的http.exceptionHandling().accessDeniedPage("/access/denied") 代码注释了。
然后在下面位置新建403.html页面。
简单的页面内容。
<h1>Denied Access This is page</h1>
测试:
自定义访问拒绝句柄的方式
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() {
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() {
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();
}
}) ;
}
测试:
总结:
- 认证失败后的处理原理及自定义配置。
- 授权失败后的处理原理及自定义配置。