下一篇:Spring Security / Servlet Application / 鉴权
1. Servlet Filter
public class Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
}
因为 Servlet Filter 只能影响下游的 Filter 或 Servlet
所以 Servlet Filter 的顺序很重要
2. DelegatingFilterProxy
Spring 提供了一个 Servlet Filter 实现:DelegatingFilterProxy
它是 Servlet 容器生命周期与 Spring 的 ApplicationContext 之间的桥梁
Servlet 容器允许按照 Servlet 标准注册 Filter
但无法发现 Spring 定义的 Bean
可以使用标准的 Servlet 机制将 DelegatingFilterProxy 注册到 Servlet 容器
然后将所有工作委托给实现了 Filter 接口的 Spring Bean
伪代码
public class Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// 懒加载被注册为 Spring Bean 的【Bean Filter0】
// 下边的 delegate 是指向【Bean Filter0】的引用
Filter delegate = getFilterBean(someBeanName);
// 将工作委托给【Bean Filter0】
delegate.doFilter(request, response);
}
}
使用 DelegatingFilterProxy 的另一个好处是,可以延迟加载 Filter Bean 实例
这很重要,因为 Servlet 容器需要在启动前注册好 Filter 实例
但 Spring 一般会使用 ContextLoaderListener 来加载 Spring Bean
而 ContextLoaderListener 会在 Filter 实例需要被注册到 Spring 容器时才会开始加载
3. FilterChainProxy
Spring Security 的 Servlet 支持,都是由 FilterChainProxy 来完成的
FilterChainProxy 是 Spring Security 提供的一个特殊的 Filter
它会通过 SecurityFilterChain 把工作委托给多个 Filter 实例
因为 FilterChainProxy 是一个 Spring Bean
因此一般会将其包装在 DelegatingFilterProxy 中
此时 FilterChainProxy 就是之前提到的【Bean Filter0】
package org.springframework.web.filter;
public class DelegatingFilterProxy extends GenericFilterBean {
// ...
@Nullable
private WebApplicationContext webApplicationContext;
@Nullable
private String targetBeanName;
private boolean targetFilterLifecycle = false;
// 通常为 FilterChainProxy
@Nullable
private volatile Filter delegate;
// ...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException { // 251
// Lazily initialize the delegate if necessary.
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
synchronized (this.delegateMonitor) {
delegateToUse = this.delegate;
if (delegateToUse == null) {
WebApplicationContext wac = findWebApplicationContext();
if (wac == null) {
throw new IllegalStateException("No WebApplicationContext found: " +
"no ContextLoaderListener or DispatcherServlet registered?");
}
delegateToUse = initDelegate(wac);
}
this.delegate = delegateToUse;
}
}
// Let the delegate perform the actual doFilter operation.
invokeDelegate(delegateToUse, request, response, filterChain);
}
// ...
protected Filter initDelegate(WebApplicationContext wac) throws ServletException { // 335
String targetBeanName = getTargetBeanName();
Assert.state(targetBeanName != null, "No target bean name set");
/*
通过名称 targetBeanName 从 Spring 容器中获取 Bean
targetBeanName 通常为 "springSecurityFilterChain"
delegate 通常为 FilterChainProxy
*/
Filter delegate = wac.getBean(targetBeanName, Filter.class);
if (isTargetFilterLifecycle()) {
delegate.init(getFilterConfig());
}
return delegate;
}
protected void invokeDelegate(
Filter delegate, ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException { // 356
// 将工作委托给 delegate,delegate 通常为 FilterChainProxy
delegate.doFilter(request, response, filterChain);
}
// ...
}
4. SecurityFilterChain
一个 FilterChainProxy 对应多个 SecurityFilterChain
FilterChainProxy 根据请求选择一个 SecurityFilterChain
一个 SecurityFilterChain 对应着一个 Filter 序列
SecurityFilterChain 中的 Filter 通常也是 Spring Bean
但没有直接将它们注册到 DelegatingFilterProxy,而是注册到了 FilterChainProxy
是 FilterChainProxy 对象遍历调用的 Filter.doFilter(),不是 SecurityFilterChain 对象
SecurityFilterChain 只是用于分组存放多个 Filter 的容器
相对于将 Filter 直接注册到 Servlet 容器或 DelegatingFilterProxy
将其通过 SecurityFilterChain 注册到 FilterChainProxy 有下面这些好处
- 为所有 Spring Security 的 Servlet 支持,提供了一个统一的起始位置
如果你想对 Spring Security 的 Servlet 支持做故障检测
可以在 FilterChainProxy 中加 debug 断点 - FilterChainProxy 作为使用 Spring Security 的中心
可以完成一些统一工作,这些工作应该和桥接 Servlet 和 Spring 容器这个关注点相分离
DelegatingFilterProxy 只做桥接工作
其它统一工作交给 FilterChainProxy 来做,例如:- 清理 SecurityContext 避免内存泄漏
- 使用 Spring Security 的 HttpFirewall 来保护应用,统一防范某些已知类型的攻击
- 可以更灵活地选择什么时候使用哪个 Filter
在 Servlet 容器中,只能基于 URL 来决定是否调用某个 Filter
(虽然也可以把更灵活的匹配逻辑写到 doFilter() 中,但那样就把匹配的逻辑和 Filter 的功能耦合在了一起)
而 FilterChainProxy 可以利用 RequestMatcher 接口
根据 HttpServletRequest 中的任何东西来决定是否调用某个 Filter
并且还可以决定是否调用某个 SecurityFilterChain
这样就可以为应用的不同业务分别配置相互隔离的整套安全策略
每个继承了 WebSecurityConfigurerAdapter 的配置类对应一套安全策略
即对应一个 SecurityFilterChain
一个经过 FilterChainProxy 的请求最多只会匹配到其中一个 SecurityFilterChain
如果一个请求可以被多个 SecurityFilterChain 匹配到,那么会调用第一个匹配到的 SecurityFilterChain
各个 SecurityFilterChain 相互独立
甚至可以配置一个没有 Filter 的 SecurityFilterChain
表示某些请求只做 FilterChainProxy 的公共处理,不做额外的安全工作
5. Security Filters
FilterChainProxy 通过 SecurityFilterChain 获取对应的 Filter 序列
这些 Filter 的顺序很重要,通常这些顺序由 Spring 根据其类型固定,客户端程序员不用更改
下边按顺序列出 Spring Security Filter,从上到下为执行的先后顺序
ChannelProcessingFilter
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
Saml2WebSsoAuthenticationRequestFilter
X509AuthenticationFilter
AbstractPreAuthenticatedProcessingFilter
CasAuthenticationFilter
OAuth2LoginAuthenticationFilter
Saml2WebSsoAuthenticationFilter
UsernamePasswordAuthenticationFilter
OpenIDAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
ConcurrentSessionFilter
DigestAuthenticationFilter
BearerTokenAuthenticationFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
JaasApiIntegrationFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
OAuth2AuthorizationCodeGrantFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
SwitchUserFilter
鉴权授权失败的拦截,可以统一由 ExceptionTranslationFilter
通过捕获异常来完成
由上边的顺序可见,所有不同类型的鉴权授权的 Filter
都在 ExceptionTranslationFilter
前边执行
因此它们抛异常 ExceptionTranslationFilter
是捕获不到的
一般的,Filter
负责捕获并处理【鉴权请求】中产生的异常(即验证用户凭证有效性的请求),其它请求放过
FilterSecurityInterceptor
中统一对 Filter
放过的【访问资源的请求】进行权限校验,校验不通过时抛异常
FilterSecurityInterceptor
在 ExceptionTranslationFilter
后边
因此它抛出的异常能被 ExceptionTranslationFilter
捕获并统一处理
例如 UsernamePasswordAuthenticationFilter
只负责处理用户通过 POST 提交的用户名密码的校验工作,校验过程中抛出的异常,自己处理并拦截
只要是 GET 请求,或者 URI 不是提交用户名密码的 URI(默认为:/login),则一律放过
像下边这些问题,一律不管
- GET 请求是否有访问资源的权限
- 没有访问权限时,是重定向到登录页还是返回没有权限的错误信息
- 如果 GET 请求是登录页的 URL,如何防止递归拦截
可以在 FilterSecurityInterceptor
之前的 Filter
中做一些防止递归拦截的处理
例如下边实例中,重定向到登录页 /login 的 GET 请求
可以在 FilterSecurityInterceptor
之前被 DefaultLoginPageGeneratingFilter
处理
6. Handling Security Exceptions
ExceptionTranslationFilter
将 AccessDeniedException 和 AuthenticationException 转换为 HTTP 响应
- ExceptionTranslationFilter 调用 FilterChain.doFilter(request, response) 执行之后的逻辑
- 如果用户还没有鉴权(即捕获到 AuthenticationException),那么会进入鉴权流程
- 清理上下文
SecurityContextHolder.getContext().setAuthentication(null); - 将 HttpServletRequest 保存到 RequestCache
当用户成功鉴权后,使用 RequestCache 来重放之前的请求
this.requestCache.saveRequest(request, response); - 使用 AuthenticationEntryPoint 向客户端请求用户凭证(如密码)
例如,重定向到登录页,或者发送一个 WWW-Authenticate header
- 清理上下文
- 如果捕捉到的是 AccessDeniedException
则使用 AccessDeniedHandler 拒绝请求(通常是返回错误码和错误信息)
this.accessDeniedHandler.handle(request, response, exception);
如果捕获到的既不是 AccessDeniedException 也不是 AuthenticationException
ExceptionTranslationFilter 不会做任何处理,直接再次抛出原来的异常
package org.springframework.security.web.access;
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
// ...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException { // 119
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
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);
}
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);
}
}
// ...
}
实例
UsernamePasswordAuthenticationFilter
继承 AbstractAuthenticationProcessingFilter
用户提交用户名密码的请求,如果用户名密码校验失败,鉴权失败抛异常
此时异常由 UsernamePasswordAuthenticationFilter
自己捕获,自己拦截
没让请求来到 FilterSecurityInterceptor
当用户请求资源,但用户还没有鉴权时
UsernamePasswordAuthenticationFilter
会放过
最终由 FilterSecurityInterceptor
拦截并抛异常,由 ExceptionTranslationFilter
捕获异常并处理
此时,会分配一个 AnonymousAuthenticationToken
类型的 Authentication
[Principal=anonymousUser,
Credentials=[PROTECTED],
Authenticated=true,
Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=1BE921A4E1EC226D398A854AD102FB44],
Granted Authorities=[ROLE_ANONYMOUS]]
此时,因为请求是 GET 请求,不是 POST 请求,因此不会进行鉴权,直接放过
package org.springframework.security.web.authentication;
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
// ...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException { // 216
if (!requiresAuthentication(request, response)) {
// 请求不是 POST 方法,这里会直接放过,不进行下边的鉴权逻辑
chain.doFilter(request, response);
return;
}
// ...
}
// ...
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { // 256
if (this.requiresAuthenticationRequestMatcher.matches(request)) {
return true;
}
if (this.logger.isTraceEnabled()) {
this.logger
.trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher));
}
return false;
}
// ...
}
UsernamePasswordAuthenticationFilter
放过后,请求会来到 FilterSecurityInterceptor
FilterSecurityInterceptor
继承 AbstractSecurityInterceptor
package org.springframework.security.web.access.intercept;
public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
// ...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException { // 80
// 第一步
invoke(new FilterInvocation(request, response, chain));
}
// ...
public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException { // 102
if (isApplied(filterInvocation) && this.observeOncePerRequest) {
// filter already applied to this request and user wants us to observe
// once-per-request handling, so don't re-do security checking
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
return;
}
// first time this request being called, so perform security checking
if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
}
// 第二步,调用 super.beforeInvocation(filterInvocation)
InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
try {
filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
}
finally {
super.finallyInvocation(token);
}
super.afterInvocation(token, null);
}
// ...
}
public abstract class AbstractSecurityInterceptor
implements InitializingBean, ApplicationEventPublisherAware, MessageSourceAware {
// ...
protected InterceptorStatusToken beforeInvocation(Object object) { // 179
// ...
// 虽然未鉴权,但这里得到的并不为空,是一个 AnonymousAuthenticationToken 对象
if (SecurityContextHolder.getContext().getAuthentication() == null) {
credentialsNotFound(this.messages.getMessage("AbstractSecurityInterceptor.authenticationNotFound",
"An Authentication object was not found in the SecurityContext"), object, attributes);
}
// authenticated是一个AnonymousAuthenticationToken对象
Authentication authenticated = authenticateIfRequired();
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Authorizing %s with attributes %s", object, attributes));
}
// Attempt authorization
// attemptAuthorization() 授权时,发现匿名用户没有访问资源的权限,抛出拒绝访问的异常 AccessDeniedException
attemptAuthorization(object, attributes, authenticated);
// ...
}
private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
Authentication authenticated) { // 236
try {
// 匿名用户没有访问资源的权限,抛出拒绝访问的异常 AccessDeniedException
this.accessDecisionManager.decide(authenticated, object, attributes);
}
catch (AccessDeniedException ex) {
if (this.logger.isTraceEnabled()) {
this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,
attributes, this.accessDecisionManager));
}
else if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
}
publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
throw ex;
}
}
// ...
}
上边 FilterSecurityInterceptor
抛出的 AccessDeniedException
异常被 ExceptionTranslationFilter
捕获
package org.springframework.security.web.access;
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
// ...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException { // 119
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// 捕获 FilterSecurityInterceptor 抛出的 AccessDeniedException
// Try to extract a SpringSecurityException from the stacktrace
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);
}
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)
handleSpringSecurityException(request, response, chain, securityException);
}
}
// ...
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, RuntimeException exception) throws IOException, ServletException { // 167
if (exception instanceof AuthenticationException) {
handleAuthenticationException(request, response, chain, (AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
// 捕获 FilterSecurityInterceptor 抛出的 AccessDeniedException
handleAccessDeniedException(request, response, chain, (AccessDeniedException) exception);
}
}
// ...
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, AccessDeniedException exception) throws ServletException, IOException { // 184
// authentication 为 AnonymousAuthenticationToken 对象
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// isAnonymous=true 是匿名用户
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
authentication), exception);
}
/*
1. AccessDeniedException 拒绝访问,即没有访问权限
2. isAnonymous=true 表示是匿名用户
这种情况会调用 sendStartAuthentication() 开始鉴权
*/
sendStartAuthentication(request, response, chain,
new InsufficientAuthenticationException(
this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
"Full authentication is required to access this resource")));
}
else {
// 如果不是匿名用户且没有访问权限,则会调用 accessDeniedHandler.handle() 返回拒绝访问的提示
if (logger.isTraceEnabled()) {
logger.trace(
LogMessage.format("Sending %s to access denied handler since access is denied", authentication),
exception);
}
this.accessDeniedHandler.handle(request, response, exception);
}
}
// ...
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException { // 207
/*
开始鉴权
1. 清理上下文
2. 缓存请求,以便鉴权成功后重放
3. 通过authenticationEntryPoint向客户端请求凭证
authenticationEntryPoint是LoginUrlAuthenticationEntryPoint对象
会重定向到登录页,重定向URI:LoginUrlAuthenticationEntryPoint.loginFormUrl
*/
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContextHolder.getContext().setAuthentication(null);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
// ...
}
重定向到 /login 的 GET 请求,因为不是 POST 请求,也会被 UsernamePasswordAuthenticationFilter
放过
但在请求到达 FilterSecurityInterceptor
之前,会先进入 DefaultLoginPageGeneratingFilter
DefaultLoginPageGeneratingFilter
会返回登录页
因此,重定向到登录页的请求,不会因为没有鉴权而被递归地再次重定向到登录页
package org.springframework.security.web.authentication.ui;
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
// ...
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException { // 222
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException { // 227
boolean loginError = isErrorPage(request);
boolean logoutSuccess = isLogoutSuccess(request);
if (isLoginUrlRequest(request) || loginError || logoutSuccess) {
// 只要是登录页、登录错误页、登出成功页,都会拦截并返回登录页
String loginPageHtml = generateLoginPageHtml(request, loginError, logoutSuccess);
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
return;
}
chain.doFilter(request, response);
}
// ...
}
DefaultLoginPageGeneratingFilter
在FilterSecurityInterceptor
之前
可以防止登录页请求被当前应用当做普通未鉴权的请求而递归地拦截
但如果登录页本来就不是由当前应用提供,例如重定向到 Nginx 的静态 Vue.js 单页应用
那么就不存在这个问题,也就不需要DefaultLoginPageGeneratingFilter
发挥作用如果登录页是由当前应用提供,但是不使用默认登录页,即
DefaultLoginPageGeneratingFilter
URL 没匹配上
此时,为了防止递归地拦截,就需要自己配置 SpringSecurity 来忽略对登录页 URI 的鉴权拦截