Spring Security / Servlet Application / 大图景

下一篇:Spring Security / Servlet Application / 鉴权

样例代码:Spring Security Sample

参考:Spring Security Reference


1. Servlet Filter

filter chain

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

delegating filter proxy

伪代码

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】

filter chain proxy

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 序列

security filter chain

SecurityFilterChain 中的 Filter 通常也是 Spring Bean
但没有直接将它们注册到 DelegatingFilterProxy,而是注册到了 FilterChainProxy

是 FilterChainProxy 对象遍历调用的 Filter.doFilter(),不是 SecurityFilterChain 对象
SecurityFilterChain 只是用于分组存放多个 Filter 的容器

相对于将 Filter 直接注册到 Servlet 容器或 DelegatingFilterProxy
将其通过 SecurityFilterChain 注册到 FilterChainProxy 有下面这些好处

  1. 为所有 Spring Security 的 Servlet 支持,提供了一个统一的起始位置
    如果你想对 Spring Security 的 Servlet 支持做故障检测
    可以在 FilterChainProxy 中加 debug 断点
  2. FilterChainProxy 作为使用 Spring Security 的中心
    可以完成一些统一工作,这些工作应该和桥接 Servlet 和 Spring 容器这个关注点相分离
    DelegatingFilterProxy 只做桥接工作
    其它统一工作交给 FilterChainProxy 来做,例如:
    • 清理 SecurityContext 避免内存泄漏
    • 使用 Spring Security 的 HttpFirewall 来保护应用,统一防范某些已知类型的攻击
  3. 可以更灵活地选择什么时候使用哪个 Filter
    在 Servlet 容器中,只能基于 URL 来决定是否调用某个 Filter
    (虽然也可以把更灵活的匹配逻辑写到 doFilter() 中,但那样就把匹配的逻辑和 Filter 的功能耦合在了一起)
    而 FilterChainProxy 可以利用 RequestMatcher 接口
    根据 HttpServletRequest 中的任何东西来决定是否调用某个 Filter
    并且还可以决定是否调用某个 SecurityFilterChain
    这样就可以为应用的不同业务分别配置相互隔离的整套安全策略

每个继承了 WebSecurityConfigurerAdapter 的配置类对应一套安全策略
即对应一个 SecurityFilterChain

multi-security filter chain

一个经过 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 放过的【访问资源的请求】进行权限校验,校验不通过时抛异常
FilterSecurityInterceptorExceptionTranslationFilter 后边
因此它抛出的异常能被 ExceptionTranslationFilter 捕获并统一处理

例如 UsernamePasswordAuthenticationFilter
只负责处理用户通过 POST 提交的用户名密码的校验工作,校验过程中抛出的异常,自己处理并拦截
只要是 GET 请求,或者 URI 不是提交用户名密码的 URI(默认为:/login),则一律放过
像下边这些问题,一律不管

  • GET 请求是否有访问资源的权限
  • 没有访问权限时,是重定向到登录页还是返回没有权限的错误信息
  • 如果 GET 请求是登录页的 URL,如何防止递归拦截

可以在 FilterSecurityInterceptor 之前的 Filter 中做一些防止递归拦截的处理
例如下边实例中,重定向到登录页 /login 的 GET 请求
可以在 FilterSecurityInterceptor 之前被 DefaultLoginPageGeneratingFilter 处理

6. Handling Security Exceptions

ExceptionTranslationFilter
将 AccessDeniedException 和 AuthenticationException 转换为 HTTP 响应

exception translation filter

  1. ExceptionTranslationFilter 调用 FilterChain.doFilter(request, response) 执行之后的逻辑
  2. 如果用户还没有鉴权(即捕获到 AuthenticationException),那么会进入鉴权流程
    1. 清理上下文
      SecurityContextHolder.getContext().setAuthentication(null);
    2. 将 HttpServletRequest 保存到 RequestCache
      当用户成功鉴权后,使用 RequestCache 来重放之前的请求
      this.requestCache.saveRequest(request, response);
    3. 使用 AuthenticationEntryPoint 向客户端请求用户凭证(如密码)
      例如,重定向到登录页,或者发送一个 WWW-Authenticate header
  3. 如果捕捉到的是 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);
    }
    // ...
}

DefaultLoginPageGeneratingFilterFilterSecurityInterceptor 之前
可以防止登录页请求被当前应用当做普通未鉴权的请求而递归地拦截
但如果登录页本来就不是由当前应用提供,例如重定向到 Nginx 的静态 Vue.js 单页应用
那么就不存在这个问题,也就不需要 DefaultLoginPageGeneratingFilter 发挥作用

如果登录页是由当前应用提供,但是不使用默认登录页,即 DefaultLoginPageGeneratingFilter URL 没匹配上
此时,为了防止递归地拦截,就需要自己配置 SpringSecurity 来忽略对登录页 URI 的鉴权拦截


下一篇:Spring Security / Servlet Application / 鉴权

样例代码:Spring Security Sample

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值