【SpringSecurity系列2】基于SpringSecurity实现前后端分离无状态Rest API的权限控制原理分析
源码传送门:
https://github.com/ningzuoxin/zxning-springsecurity-demos/tree/master/T01-springsecurity-stateless
一、前言
在上一篇,我们实现了基于 SpringSecurity 实现前后端分离无状态 Rest API 的权限控制,在本篇我们将对其原理进行分析,从而加深对 SpringSecurity 的认识。
二、原理分析
1、SpringSecurity 中的过滤器及功能分析
SpringSecurity 的各种强大功能,是借助于多个过滤器组成一个过滤器链来实现的。我们在 DEBUG 查看 SpringSecurity 源码时,经常是跳来跳去,最后就跳晕了,没办法耐心多尝试几次吧。
以下是我列出的一些 SpringSecurity 的过滤器,根据名称我们能大概猜到他们的作用,我们选取几个重要的分析一下。
org.springframework.security.web.session.DisableEncodeUrlFilter
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter
org.springframework.security.web.context.SecurityContextPersistenceFilter
org.springframework.security.web.header.HeaderWriterFilter
org.springframework.security.web.authentication.logout.LogoutFilter
com.ning.config.TokenAuthenticationFilter
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
org.springframework.security.web.savedrequest.RequestCacheAwareFilter
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter
org.springframework.security.web.authentication.AnonymousAuthenticationFilter
org.springframework.security.web.session.SessionManagementFilter
org.springframework.security.web.access.ExceptionTranslationFilter
org.springframework.security.web.access.intercept.FilterSecurityInterceptor
(1)SecurityContextPersistenceFilter
SecurityContextPersistenceFilter 的主要作用是在执行过滤器链的 doFilter 方法之前,从 SecurityContextRepository 中加载 SecurityContext。在执行完滤器链的 doFilter 方法之后,将 SecurityContext 保存到 SecurityContextRepository。
对应源码如下:
// 从 SecurityContextRepository 中加载 SecurityContext
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
// 将 SecurityContext 保存到 SecurityContextRepository
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
这里要说明下几个重要接口的作用:
SecurityContext:用于存取 Authentication,SpringSecurity 实现权限控制所必须的认证信息和权限集合,都是存储在 Authentication 中。
SecurityContextRepository:用于加载和保存 SecurityContext,它有三个实现类,分别是 HttpSessionSecurityContextRepository(从 HttpSession 中存取 SecurityContext)、RequestAttributeSecurityContextRepository(从
HttpServletRequest 中存取 SecurityContext)、NullSecurityContextRepository(不存储 SecurityContext)
SecurityContextHolder:将 SecurityContext 与当前执行线程相关联。它有四种不同的策略,分别是 ThreadLocalSecurityContextHolderStrategy (保存在 ThreadLocal 中,默认策略)、InheritableThreadLocalSecurityContextHolderStrategy(保存在 InheritableThreadLocal 中)、
GlobalSecurityContextHolderStrategy(全局)、CustomStrategy(自定义实现 SecurityContextHolderStrategy 接口)。
【PS】个人理解 SecurityContextRepository 和 SecurityContextHolder 的不同在于,SecurityContextRepository 是用于不同请求之间 SecurityContext 的存取策略,而 SecurityContextHolder 是用于同一个请求 SecurityContext 的存取。个人见解,如有不对请各位大佬指正。
(2)LogoutFilter
LogoutFilter 主要是用于处理退出的逻辑,默认的退出请求为 /logout。它涉及两个比较重要的接口 LogoutHandler 和 LogoutSuccessHandler。
LogoutHandler:处理退出逻辑,主要是清除 SecurityContext 和 清除 SecurityContext 中的 Authentication。
LogoutSuccessHandler:处理退出成功后的逻辑,默认的实现类是在退出成功后将请求重定向。
(3)UsernamePasswordAuthenticationFilter
UsernamePasswordAuthenticationFilter 主要是用于处理经由表单提交的用户名和密码的登录逻辑,默认的登录请求为 POST 方法的 /login 请求。在整个过滤器链中,真正执行的是其父类 AbstractAuthenticationProcessingFilter 的 doFilter 方法,而 AbstractAuthenticationProcessingFilter
的认证逻辑,是执行的其子类的 attemptAuthentication 方法后返回 Authentication。若认证成功则执行 successfulAuthentication 方法,认证失败则执行 unsuccessfulAuthentication 方法。对应源码如下:
// 交由子类执行具体的认证
Authentication authenticationResult = this.attemptAuthentication(request, response);
// 认证成功后处理逻辑
this.successfulAuthentication(request, response, chain, authenticationResult);
// 认证失败后处理逻辑
this.unsuccessfulAuthentication(request, response, var5);
(4)ExceptionTranslationFilter
ExceptionTranslationFilter 的作用是专门用于处理和 SpringSecurity 相关的异常,主要是 AuthenticationException 和 AccessDeniedException。若是 AuthenticationException,将执行 AuthenticationEntryPoint 的 commence 方法,默认情况
会进入 LoginUrlAuthenticationEntryPoint 中,会将请求重定向到 /login 执行登录认证。若是 AccessDeniedException,会判断当前的认证信息是否为匿名用户,不是匿名用户则交由 AccessDeniedHandler 处理,默认情况下会响应 403 的错误码,若是匿名用户,
则执行 AuthenticationEntryPoint 的 commence 方法,默认情况下也是重定向到 /login 执行登录认证。看下相应源码:
if (exception instanceof AuthenticationException) {
this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) {
this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
}
// 默认情况进入 LoginUrlAuthenticationEntryPoint 请求重定向到 /login 执行登录认证
this.authenticationEntryPoint.commence(request, response, reason);
// 交由 AccessDeniedHandler 处理非匿名用户的 AccessDeniedException
this.accessDeniedHandler.handle(request, response, exception);
(5)FilterSecurityInterceptor
FilterSecurityInterceptor 中在执行 invoke 方法前,会先执行 beforeInvocation 方法。在 beforeInvocation 方法中,会根据配置的 SpringSecurity 属性,对当前用户的认证信息和权限集合进行校验,若校验失败则会抛出 AuthenticationException 或者 AccessDeniedException。
2、请求受保护资源,跳转登录流程分析
在上一小节中,我们分析了几个重要的过滤器的大致功能,下面我们来具体分析下,在未登录认证前,请求受保护资源,跳转到登录的具体流程。
1、拿上一篇《【SpringSecurity系列1】基于SpringSecurity实现前后端分离无状态Rest API的权限控制》的代码举例,我们先在 FilterSecurityInterceptor 打个断点,然后发送请求 http://localhost:8080/index。
2、我们跟随 DEBUG 进入 FilterSecurityInterceptor 类中的 beforeInvocation 方法,我们可以看到 ConfigAttribute 只有一个 “authenticated”,而它匹配的范围是 “any request”。这是因为我们在 WebSecurityConfig 中配置了
“http.authorizeRequests().antMatchers(“/login”).permitAll().anyRequest().authenticated()”。
3、继续往下,我们看到这个时候的 Authentication 是一个匿名用户,对应的 principal 是 “anonymousUser”,这是因为在经过 AnonymousAuthenticationFilter 后,SpringSecurity 帮我们创建了一个匿名用户放在了上下文中。
4、接着会进入 attemptAuthorization 方法,对权限做进一步的校验。经过 AccessDecisionVoter 投票器的结果,若 deny 数大于0,则会抛出 AccessDeniedException “org.springframework.security.access.AccessDeniedException: Access is denied”。
5、抛出的 AccessDeniedException 会被 ExceptionTranslationFilter 捕获,从而进入 handleSpringSecurityException 类中的 handleSpringSecurityException 方法逻辑。由于此时上下文中是一个匿名用户(anonymousUser),因此会执行 sendStartAuthentication 方法,
然后调用 authenticationEntryPoint 的 commence 方法。(此时的 authenticationEntryPoint 是默认的 LoginUrlAuthenticationEntryPoint)
6、在 LoginUrlAuthenticationEntryPoint 的 commence 方法中,我们可以看到最终的 redirectUrl 是 “http://localhost:8080/login”。到此时,请求就由 /index 重定向到了 /login。
7、那么在请求被重定向到 /login 后,又是怎样跳转到默认的登录页面的呢?我们先得修改下 WebSecurityConfig 中的配置将 .and().formLogin().loginPage(“/login”) 改为 .and().formLogin(),让 SpringSecurity 跳转默认的登录页面。改完配置后,我们重启项目。
8、项目重启完后,我们可以在控制台看到多了 DefaultLoginPageGeneratingFilter 和 DefaultLogoutPageGeneratingFilter 两个过滤器,顾名思义跳转默认登录页面的逻辑,就应该是在 DefaultLoginPageGeneratingFilter 中了。
9、最后,我就直接贴出 DefaultLoginPageGeneratingFilter 跳转默认登录页面的源码了,比较简单:
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
10、到此请求受保护资源,跳转登录的流程分析完毕。
3、请求未授权资源,跳转异常处理流程分析
1、在使用账号密码登录(经过身份认证),访问未授权资源时,我们经过 DEBUG 可以看到,在 FilterSecurityInterceptor 中 Authentication 是我们登录后的用户,对应的 principal 是 “org.springframework.security.core.userdetails.User [Username=admin, Password=[PROTECTED]…”。
2、请求在顺利经过 FilterSecurityInterceptor 的 beforeInvocation 方法后,会继续进入后续的过滤器链,并最终进入 ApplicationFilterChain 的 this.servlet.service(request, response); 方法。
3、我们可以看到此时的 servlet 正是传说中的 DispatcherServlet,对于熟悉 Spring MVC 的同学就明白,之后将会经历请求分发,并最终找到对应的 Controller 进行方法的反射调用,此处我就不展开分析了。从 DispatcherServlet 到找对最终的 Controller 的代码还是比较复杂的,感兴趣的同学可以多 DEBUG 几次。
4、我在这里就直接讲结论了,由于我们在 Controller 的方法上,配置了 @PreAuthorize(“hasRole(‘home’)”)。框架在找到目标对象 IndexController 后,会使用 Cglib 创建出代理对象,通过 AOP 的方式进行权限的判断,关键的类是 MethodSecurityInterceptor。
5、在 MethodSecurityInterceptor 类中的 beforeInvocation 方法,我们可以看到此时的 ConfigAttribute 是 “[authorize: ‘hasRole(‘home’)’, filter: ‘null’, filterTarget: ‘null’]”。和身份认证的一样,经过 AccessDecisionVoter 投票器的结果,若 deny 数大于0,则会抛出 AccessDeniedException。
6、抛出的 AccessDeniedException 会被 ExceptionTranslationFilter 捕获,从而进入 handleSpringSecurityException 类中的 handleSpringSecurityException 方法逻辑。由于此时上下文中是经过身份认证的用户,故而会交由 AccessDeniedHandler 会处理相应异常。
7、默认的 AccessDeniedHandlerImpl 会将请求响应改为 403,对应源码如下:
response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase());
8、到此请求未授权资源,跳转异常处理的流程分析完毕。
三、配置的原理分析。
在上一篇《【SpringSecurity系列1】基于SpringSecurity实现前后端分离无状态Rest API的权限控制》,我们修改了一些配置,从而使 SpringSecurity 满足了前后端分离架构的需要,那么我们这么配置的依据是什么呢?
1、为什么改写 Get 方法的 /login 请求?
经过上文的分析,我们知道默认的 /login 请求会跳转到 DefaultLoginPageGeneratingFilter 生成的登录页面,这不符合前后端分离的需要。所以,我们自定义了 /login 请求,并向客户端响应 JSON。
2、为什么需要 TokenAuthenticationSuccessHandler 和 TokenAuthenticationFailureHandler
分析了 UsernamePasswordAuthenticationFilter 的源码之后,我们知道经过对账号密码校验后,会返回 Authentication。由于 SpringSecurity 默认是基于 session 对 Authentication 进行管理,为了达到实现前后端分离架构
的需要,我们需要自己实现对 Authentication 的管理。因此我们定义了 AuthenticationRepository,在身份认证成功后,我们创建出一个 token 并和 Authentication 关联起来存储在 AuthenticationRepository 中,若是身份
认证失败,我们则在 TokenAuthenticationFailureHandler 向客户端响应登录失败的 JSON 提示。
3、为什么需要 TokenLogoutSuccessHandler
默认的 SpringSecurity 配置是在 DefaultLogoutPageGeneratingFilter 处理退出请求 /logout,并向客户端响应一个退出页面。因此,我们需要创建 TokenLogoutSuccessHandler,在退出成功后,向客户端响应 JSON。
4、为什么需要 TokenAccessDeniedHandler
经过上文的分析,我们知道默认的 AccessDeniedHandlerImpl 会将请求响应改为 403。所以,我们需要在 TokenAccessDeniedHandler 中向客户端响应 JSON。
5、为什么需要 TokenAuthenticationFilter 并在 UsernamePasswordAuthenticationFilter 之前
TokenAuthenticationFilter 源码很简单,就是根据 header 中的 token,从 AuthenticationRepository 中找到对应的 Authentication 并放入 SecurityContext。
6、为什么需要配置 csrf().disable()
为了防止 CSRF,默认 SpringSecurity 开启防 CSRF 配置,会在 CsrfFilter 中对登录表单进行 token 校验。
四、总结
经过本篇的分析,我们对 SpringSecurity 会有更深刻的理解,在源码分析的过程中,DEBUG 跳来跳去会令人头痛,大家多多尝试,习惯就好了 O(∩_∩)O 哈哈~
在下一篇,我们将尝试使用 Spring Webflux 集成 SpringSecurity 来实现同样的效果,大家多多关注哦~
【打个广告】推荐下个人的基于 SpringCloud 开源项目,供大家学习参考,欢迎大家留言进群交流
Gitee:https://gitee.com/ningzxspace/exam-ning-springcloud-v1
Github:https://github.com/ningzuoxin/exam-ning-springcloud-v1