一文带你读懂Spring Security 6.0的实现原理

导言

Spring Security是一个功能强大且高度且可定制的身份验证和访问控制框架,除了标准的身份认证和授权之外,它还支持点击劫持,CSRF,XSS,MITM(中间人)等常见攻击手段的保护,并提供密码编码,LDAP认证,Session管理,Remember Me认证,JWT,OAuth 2.0等功能特性。

由于安全领域本身的复杂性和丰富的安全特性支持,以及Spring Security高度的可定制性,使得它成为一个庞大且复杂的框架。每次升级可能带来的破坏性更新,加上网络上的陈旧教程,更是加重了Spring Security非常难用的印象。很多新手可能跟作者一样,首次引入Spring Security框架之后,突然发现很多页面无法访问,感到无所适从。

为此,本文将基于Spring Boot 3.1.x依赖的Spring Security 6.1.x版本,深入探讨Spring Security的架构和实现原理。本文将着重解释Spring Security的设计思想,而不会过多涉及具体的实现细节。文章的目标是让读者在阅读完本文之后,能够对整个Spring Security框架有个清晰的理解,并在面对问题时知道如何着手排查。另外,本文重点关注Spring Security的总体架构,以及身份认证(Authentication)和鉴权控制(Authorization)的实现。

【版本兼容性】Spring Security 6引入了很多破坏性的更新,包括废弃代码的删除,方法重命名,全新的配置DSL等,但是架构和基本原理还是保持不变的。本文在讲解过程中会尽量指出当前版本跟老版本的差异,尤其是涉及到兼容性问题的时候。

【阅读提示】本文的篇幅较长,并且包含了部分源码分析,时间有限的情况下,可以重点阅读架构图部分。

Java Web应用的Security实现基本思路

大家可以尝试思考下,安全相关的校验和处理,应该处于应用的哪个部分呢?答案是,应该放在所有请求的入口,因为它是跟具体的业务逻辑无关的,在Spring MVC世界里就是@Controller之前。

在JakartaEE(JavaEE的新版)规范中,Filter和Servlet都符合这个前置要求。然而,Spring的Web应用基本上只包含一个DispatcherServelt,主要用于请求分发,缺乏安全相关的支持和合适的扩展机制。而Filter运行在Servlet之前,而规范本身就支持配置多个Filter。因此,在请求到达Servlet之前,先通过Filter进行安全验证就是一个非常合理的实现方式。这样可以在请求进入业务逻辑之前,对请求进行拦击,然后进行必要的安全性检查和处理。

这也是Spring Security的实现方式。本质上,Spring Security的实现原理很简单,就是提供了一个用于安全验证的Filter。假如我们自己实现一个简化版的Filter,它的大概逻辑应该是这样的:

Java 复制代码
public class SimpleSecurityFilter extends HttpFilter {
    @Override
    protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        UsernamePasswordToken token = extractUsernameAndPasswordFrom(request);  // (1)
        if (notAuthenticated(token)) {  // (2)
            // 用户名密码错误
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 401.
            return;
        }
        if (notAuthorized(token, request)) { // (3)
            // 当前登录用户的权限不足
            response.setStatus(HttpServletResponse.SC_FORBIDDEN); // HTTP 403
            return;
        }
        // 通过了身份验证和权限校验,继续执行其它Filter,最终到达Servlet
        chain.doFilter(request, response); // (4)
    }
}
  1. 从HTTP请求中获取用户名和密码,来源包括标准的Basic Auth HTTP Header,表单字段或者cookie等等。
  2. 身份认证,也就是校验用户名和密码。
  3. 认证通过后,需要检查当前登录的用户有没有访问当前HTTP请求的权限,也就是鉴权逻辑。
  4. 权限校验也通过后,就继续执行其它Filter,所有Filter都通过后,进入Servlet,最终到达具体的Controller。

FilterChain

在安全领域,由于攻防手段的多样性和认证鉴权方式的复杂性,将所有功能都放在一个Filter中会导致该Filter迅速演变为一个庞大而复杂的类。

因此,在实际应用场景中,我们常常将这个庞大的Filter拆分成多个小Filter,并将它们链接在一起。每个Filter都只负责特定领域的功能,比如CsrfFilterAuthenticationFilterAuthorizationFilter等。

这种概念被称为FilterChain,实际上JarkataEE规范也有相识的概念。通过使用FilterChain,你就可以以插拔的方式添加或移除特定功能的Filter,而无需改动现有的代码。

Spring Security框架的基本架构和原理

上一节其实已经说明了Spring Security框架的基本思路,下面我们深入分析其实现原理和架构。

实现原理

一个应用引入了Spring Security Starter包后,再启动应用,你会发现控制台多了下面这条日志,说明已经开启了Security特性。

kotlin 复制代码
2023-07-12T10:05:23.168+08:00  INFO 680540 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@46e3559f, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@3b83459e, org.springframework.security.web.context.SecurityContextHolderFilter@26837057, org.springframework.security.web.header.HeaderWriterFilter@2d74c81b, org.springframework.security.web.csrf.CsrfFilter@3a17b2e3, org.springframework.security.web.authentication.logout.LogoutFilter@5f5827d0, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@4ed5a1b0, org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@3b332962, org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@32118208, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@67b355c8, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@991cbde, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@dd4aec3, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@414f87a9, org.springframework.security.web.access.ExceptionTranslationFilter@59939293, org.springframework.security.web.access.intercept.AuthorizationFilter@f438904]

从这条日志可以观察到,Spring Security通过DefaultSecurityFilterChain类来完成安全相关的功能,而该类本身又由其它Filter组成。默认情况下,Spring Security Starter引入了15个Filter,下面我们简要介绍下其中几个重要的Filter:

  1. CsrfFilter :这个Filter用于防止跨站点请求伪造攻击,这也是导致所有POST请求都失败的原因。基于Token验证的API服务可以选择关闭CsrfFilter,而一般Web页面需要开启。
  2. BasicAuthenticationFilter :支持HTTP的标准Basic Auth的身份验证模块。
  3. UsernamePasswordAuthenticationFilter:支持Form表单形式的身份验证模块。
  4. DefaultLoginPageGeneratingFilter和DefaultLogoutPageGeneratingFilter:用于自动生成登录页面和注销页面。
  5. AuthorizationFilter: 这个Filter负责授权模块。值得注意的是,在老版本中鉴权模块是FilterSecurityInterceptor.

这些Filter构成了Spring Security的核心功能,通过它们,我们可以实现身份验证、授权、防护等安全特性。根据应用的需求,我们可以选择启用或禁用特定的Filter,以定制和优化安全策略。

SecurityFilterChain

DefaultSecurityFilterChain类实现了SecurityFilterChain接口,我们打开这个接口的源码,会发现它只有两个方法,matches用于匹配特定的Http请求(比如特定规则的URL),getFilters 用于获取可用的所有Security Filter。

Java 复制代码
public interface SecurityFilterChain {
    boolean matches(HttpServletRequest request); // 规则匹配
    List<Filter> getFilters(); // 该FilterChain下的所有Security Filter
}

从这段代码可以得出两个结论:

  1. 不同的Http请求可以对应不同的SecurityFilterChain(通过matches方法)。
  2. SecurityFilterChain不是我们以为的JakartaEE的Servlet Filter实现,它仅仅是一个包含多个Filter的容器,本身不负责调度和执行。它只是一个配置项,用于指定一组Filter,以实现特定的安全需求。

DelegatingFilterProxy

实际上,JakartaEE层面上的Filter实现是DelegatingFilterProxy类,它在Spring Security中起到了一个重要的桥梁作用,连接了Servlet容器和Spring容器。Servlet容器不了解Spring定义的Beans,而Spring Security的大部分组件及其依赖都是注册到Spring容器中的Bean。

DelegatingFilterProxy核心代码的主要工作就是从WebApplicationContext获取指定名称的Filter Bean,然后委托给这个Bean的doFilter方法。以下是简化后的伪代码:

Java 复制代码
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    Filter delegateToUse = this.delegate;
    if (delegateToUse == null) {
        synchronized (this.delegateMonitor) {
            delegateToUse = this.delegate;
            if (delegateToUse == null) {
                WebApplicationContext wac = findWebApplicationContext();
                // 获取Filter Bean并初始化
                delegateToUse = initDelegate(wac);
            }
            this.delegate = delegateToUse;
        }
    }
    // 委托给的delegate对象完成实际的doFilter
    invokeDelegate(delegateToUse, request, response, filterChain);
}

protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
    // Bean名称配置在SecurityFilterAutoConfiguration.DEFAULT_FILTER_NAME = "springSecurityFilterChain"
    String targetBeanName = getTargetBeanName();
    // 从容器中获取指定名称的Filter类型Bean
    Filter delegate = wac.getBean(targetBeanName, Filter.class);
    if (isTargetFilterLifecycle()) {
        delegate.init(getFilterConfig());
    }
    return delegate;
}

通过这种方式,DelegatingFilterProxy实现了将Servlet容器中的Filter请求委托给Spring容器中的具体Filter Bean处理,从而实现了Servlet容器和Spring容器之间的无缝连接。

FilterChainProxy

而这个被委托的Filter Bean的类型就是FilterChainProxy,是在WebSecurityConfiguration中配置的:

Java 复制代码
// name = "springSecurityFilterChain"
@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
public Filter springSecurityFilterChain() throws Exception {
    // 配置SecurityFilterChain
    boolean hasFilterChain = !this.securityFilterChains.isEmpty();
    if (!hasFilterChain) {
        this.webSecurity.addSecurityFilterChainBuilder(() -> {
            this.httpSecurity.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated());
            this.httpSecurity.formLogin(Customizer.withDefaults());
            this.httpSecurity.httpBasic(Customizer.withDefaults());
            return this.httpSecurity.build();
        });
    }
    for (SecurityFilterChain securityFilterChain : this.securityFilterChains) {
        this.webSecurity.addSecurityFilterChainBuilder(() -> securityFilterChain);
    }
    // WebSecurity自定义配置
    for (WebSecurityCustomizer customizer : this.webSecurityCustomizers) {
        customizer.customize(this.webSecurity);
    }
    // FilterChainProxy最终是由WebSecurity构建出来的
    return this.webSecurity.build();
}

从上面代码可以发现,FilterChainProxy对象最终是由WebSecurity根据SecurityFilterChain和其它一些配置构建出来的。

FilterChainProxy主要作用就是查找匹配当前Http请求规则的SecurityFilterChain,然后将工作委派给SecurityFilterChain的所有Filter。简化后的伪代码如下所示:

Java 复制代码
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    // 获取匹配的所有Filter
    List<Filter> filters = getFilters(request); 
    // 按顺序执行Filter
    Filter nextFilter = this.filters.get(this.currentPosition - 1);
    nextFilter.doFilter(request, response, this);
}

private List<Filter> getFilters(HttpServletRequest request) {
    for (SecurityFilterChain chain : this.filterChains) {
        // 返回匹配规则的SecurityFilterChain的Filter列表
        if (chain.matches(request)) { 
            return chain.getFilters();
        }
    }
    return null;
}

【Tips】FilterChainProxy可以认为是整个Spring Security处理请求的一个起点,如果你遇到Security相关问题,又不清楚是具体哪个Filter导致的,就可以从这里开始Debug。

基本架构

从上一节的内容,我们可以得出下面这一副架构图(图中蓝色和橘红色的部分代表Security Security)。从图中可以看出,Spring Security框架通过DelegatingFilterProxy建立起了Servlet容器和Spring容器的链接,FilterChainProxy基于匹配规则(比如URL匹配),决定使用哪个SecurityFilterChain。而SecurityFilterChain又由零到多个Filter组成,这些Filter完成实际的功能。

Security Filter和配置DSL

Spring Security是基于Jakarta EE的Filter实现的,而在此基础上,它提供了一套自身的Filter机制,相当于两层的Filter嵌套。为了不混淆这两种Filter,我们把Spring Security框架提供的Filter称为Security Filter。在下文中,我们所提及的配置,扩展和自定义的Filter都指的是Security Filter,如果没有特别说明,都默认指的是Security Filter。

通过一系列Security Filter,Spring Security提供了丰富的开箱即用的安全功能,包括身份认证,鉴权,Csrf等等。每个功能都是通过一个或者多个Security Filter实现的。有些复杂的Filter,例如身份认证和鉴权,拥有自己的特定架构,并且会依赖Filter的顺序和执行过程中的上下文信息,这也是导致Spring Security在使用上相对复杂的原因之一。

Spring Securiy的基本配置都是通过自定义SecurityFilterChain的Bean来实现的。下面是一个示例配置,它提供了自定义的登录页面,并且针对不同的URL配置了不同的角色权限,这些配置方法实际上就是配置不同的Security Filter,更详细的解释会在后面讲解具体特性的时候时展开说明。

Java 复制代码
@Bean
static SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
    // 鉴权相关配置
    http.authorizeHttpRequests((requests) ->
            request.requestMatchers("/admin").hasAuthority("ROLE_ADMIN") // "/admin"要求有"ROLE_ADMIN"角色权限
                    .requestMatchers("/hello").hasRole("USER") // "/hello"要求有"ROLE_USER"角色权限
                    .anyRequest().authenticated()); // 其它只需要身份认证通过即可,不需要其它特殊权限
    // 登录相关配置
    http.formLogin(formLogin -> formLogin
            .loginPage("/authentication") // 自定义登录页面,不再使用内置的自动生成页面
            .permitAll() // 允许自定义页面的匿名访问,不需要认证和鉴权
    );
    return http.build(); // 返回构建的SecurityFilterChain实例
}

【版本兼容性】Spring Security 6.0在配置方面引入了许多改变。在之前的老版本中,可以选择废弃的WebSecurityConfigurerAdapter进行配置,但从6.0版本开始,这个废弃类已经被删除了。而目前很多老项目以及网上的教程仍在使用WebSecurityConfigurerAdapter。 另外,配置DLS也发生了变化。Spring Security 6.0采用了基于Lambda表达式的DSL配置方式,取代了之前的纯链式调用方式,使得配置更加灵活和直观。一些方法名称也进行了修改,例如antMatchers替换为requestMatchers

除了Spring Boot的专有配置,Spring Security自身也提供了默认配置,这些默认配置在HttpSecurityConfiguration#httpSecurity方法中,它默认添加了很多Security Filter,核心代码如下:

Java 复制代码
@Bean(HTTPSECURITY_BEAN_NAME)
@Scope("prototype")
HttpSecurity httpSecurity() throws Exception {
    // ... //
    http
        .csrf(withDefaults())
        .addFilter(webAsyncManagerIntegrationFilter)
        .exceptionHandling(withDefaults())
        .headers(withDefaults())
        .sessionManagement(withDefaults())
        .securityContext(withDefaults())
        .requestCache(withDefaults())
        .anonymous(withDefaults())
        .servletApi(withDefaults())
        .apply(new DefaultLoginPageConfigurer<>());
    http.logout(withDefaults());
    // ... //
    return http;
}

以上解释了Spring Security的实现原理和基本架构,而具体到特定的Security Filter,又有各种的框架,下面将展开说明认证和鉴权两个核心模块。

Authentication身份认证

身份认证有很多种方式,大致可以分为以下4类:

  1. 标准的账号密码认证:这是很多网站都支持的方式,也是大家最熟悉的认证模式;
  2. 调用第三方服务或内部其它API进行认证:当服务自身无法直接获取用户的密码时,需要借助第三方服务或者内部API进行认证;
  3. 基于Token的认证:这是API服务一般使用的认知方式,通过令牌来进行身份验证;
  4. OAuth2或其它OpenID认证:这种方式广泛用于允许用户使用其它平台的身份信息进行登录,例如微信登录,Google登录等。

Spring Security支持大部分的认证方式,但不同的认证方式需要配置不同的Bean及其依赖Bean,否则很容易遇到各种异常和空指针。

本文重点讨论标准的账号密码认证方式。

实现原理

如果你使用的是Spring Boot,那么Spring Boot Starter Security默认就配置了Form表单和Basic认证方式,其配置代码如下所示:

Java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {
    @Configuration(proxyBeanMethods = false)
    @ConditionalOnDefaultWebSecurity
    static class SecurityFilterChainConfiguration {
        @Bean
        @Order(SecurityProperties.BASIC_AUTH_ORDER)
        SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
            http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()); // 所有URL都需要认证用户
            http.formLogin(withDefaults()); // 支持form表单认证,默认配置提供了自动生成的登录和注销页面
            http.httpBasic(withDefaults()); // 支持HTTP Basic Authentication
            return http.build();
        }

    }
    // ...其它配置...
}

为了讨论方便,我们用下面的配置覆盖Spring Boot默认的配置,只支持Form表单认证方式,讨论它具体是如何实现的。

Java 复制代码
@Configuration()  
public class MySecurityConfig {
    @Bean
    SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()); // (1)
        http.formLogin(withDefaults()); // (2)
        return http.build();
    }
}
  1. authorizeHttpRequests方法用于配置每个请求的权限控制,这里要求所有请求都要通过认证后才能访问。实际上,这个方法配置的更多是鉴权相关的内容,跟身份认证的关联较小,它本质上是增加了一个AuthorizationFilter用于鉴权,具体细节在鉴权部分会详细说明。
  2. http.formLogin方法提供了Form表单认证的方式,withDefaults方法是Form表单认证的默认配置。这段配置的作用就是增加了用于账号密码认证的UsernamePasswordAuthenticationFilter,以及自动生成登录页面和注销页面的DefaultLogoutPageGeneratingFilterDefaultLogoutPageGeneratingFilter共3个Security Filter。值得注意的是,登录页面和注销页面这两个Filter是配合DefaultLoginPageConfigurer配置一起注册的。如果你通过formLogin.loginPage提供了自定义的登录页面,那么这两个Filter就不会被注册。

在本节中,我们主要讨论身份认证的实现,因此,接下来将详细探究Form表单认证方式中UsernamePasswordAuthenticationFilter的实现。

AbstractAuthenticationProcessingFilter

对于Filter,我们重点分析它的doFilter方法的源码。实际上,它继承了抽象类AbstractAuthenticationProcessingFilter,而这个抽象类的doFilter是一个模板方法,定义了整个认证流程。其核心流程非常简单,伪代码如下:

Java 复制代码
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    // 首先判断该请求是否是认证请求或者登录请求
    if (!requiresAuthentication(request, response)) { // (1)
        chain.doFilter(request, response);
        return;
    }
    try {
        Authentication authenticationResult = attemptAuthentication(request, response); // (2) 实际认证逻辑
        // 认证成功
        successfulAuthentication(request, response, chain, authenticationResult); // (3)
    }
    catch (AuthenticationException ex) {
        // 认证失败
        unsuccessfulAuthentication(request, response, ex); // (4)
    }
}
  1. 首先requiresAuthentication方法用于判断当前请求是否为认证请求或者登录请求,例如通常是POST /login。只有在登录认证的情况下,才需要通过这个Filter;
  2. attempAuthentication方法是实际的认证逻辑,这是一个抽象方法,具体的逻辑由子类重写实现。它的规范行为是,如果认证成功,应该返回认证结果Authentication,否则以抛出异常AuthenticationException的方式表示认证失败;
  3. successfulAuthentication认证成功后,该方法会将Authentication对象放到Security Context中,这是非常关键的一步,后续需要认证结果的时候都是从Security Context获取的,比如鉴权Filter 。此外,该方法还会处理其它一些相关功能,比如RememberMe,事件发布,最后再调用AuthenticationSuccessHandler
  4. unsuccessfulAuthentication :在认证失败后,它会清空Security Context,调用RememberMe相关服务和AuthenticationFailureHandler来处理认证失败后的回调逻辑,比如跳转到错误页面。

Authentication模型

在这里,我们涉及到了一个非常重要的数据模型------Authentication,它是一个接口类型,它既是对认证结果的一个抽象表示,同时也是对认证请求的一个抽象,通常也被称为认证Token。它的方法都比较抽象,定义如下:

Java 复制代码
public interface Authentication extends Principal, Serializable {
    // 当前认证用户拥有的权限列表
    Collection<? extends GrantedAuthority> getAuthorities();
    // 用户的一个身份标识,通常就是用户名
    Object getPrincipal();
    // 可用于证明用户身份的一个凭证,通常就是用户密码
    Object getCredentials();
    // 当前用户是否认证通过
    boolean isAuthenticated();
    // 更新用户的认证状态
    void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
    // 获取附加的详情信息,比如原始的Http请求体等。
    Object getDetails();
}

具体的Authentication实现一般都命名为XXXToken,大部分都继承自抽象类AbstractAuthenticationToken,比如表示标准的用户名密码认证结果的UsernamePasswordAuthenticationToken,表示匿名登录用户认证结果的AnonymousAuthenticationToken等等,你也可以完全实现自己的Authentication

attempAuthentication方法

接下来,我们看下UsernamePasswordAuthenticationFilter的认证具体实现方法attempAuthentication,它的源码如下:

Java 复制代码
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException {
    // 默认只支持POST请求
    if (this.postOnly && !request.getMethod().equals("POST")) {
        throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
    }
    // 从form表单获取用户名和密码
    String username = obtainUsername(request);
    username = (username != null) ? username.trim() : "";
    String password = obtainPassword(request);
    password = (password != null) ? password : "";
    // 构建一个用于认证的请求
    UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
            password);
    // 附加详细信息,比如请求体,有些认证方式需要除了用户名密码外更多的信息
    setDetails(request, authRequest);
    // 委托给AuthenticationManager做具体的认证
    return this.getAuthenticationManager().authenticate(authRequest);
}

这个方法非常简单,它主要进行一些前置校验工作,从请求体中获取用户名和密码,并构建认证请求对象。然后,剩余的认证工作都是委托给AuthenticationManager接口来完成的,该接口的定义如下:

Java 复制代码
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

AuthenticationManager和AuthenticationProvider

AuthenticationManager接口只有一个方法,它的入参和出参都是Authentication对象。通常情况下,入参提供了必要的认证信息,例如用户名和密码。而在认证成功后,该方法会返回认证结果,并附加认证状态,用户拥有的权限列表等信息。如果认证失败,它会抛出AuthenticationException异常类的子类,其中包括DisabledExceptionLockedExceptionBadCredentialsException等账号相关的异常。

AuthenticationManager接口定义了Spring Security的认证行为。你可以提供自定义的实现,Spring Security也提供了一个通用的实现类ProviderManagerProviderManager将具体的认证工作委托给一系列的AuthenticationProvider

每个AuthenticationProvider对应不同的认证方式。比如最常见的用户名密码的认证实现是DaoAuthenticationProvider,而JwtAuthenticationProvider提供了JWT Token的认证。你可以通过添加不同的AuthenticationProvider的方式,在同一个服务内支持多种类型的认证方式,比如需要调用其它API检验密码的情况,就需要自定义AuthenticationProvider

此外,ProviderManager还可以配置父级AuthenticationManager,当这个ProviderManager的所有AuthenticationProvider都不支持所需的认证方式时,它会继续委托给父级的AuthenticationManager,而该父级通常也是一个ProviderManager类型。

UserDetailsService和PasswordEncoder

DaoAuthenticationProvider是最常用的认证实现之一,它通过UserDetailsServicePasswordEncoder来验证用户名和密码。

UserDetailsService的作用是查找用户信息UserDetails,这些信息包括用户密码,状态,权限列表等。用户信息可以存储在内存,数据库或者其它任何地方。Spring Security默认的配置是内存存储,对应的UserDetailsService实现是InMemoryUserDetailsManager,而数据库存储则对应JdbcUserDetailsManager

UserDetailsService获取到用户密码后,需要通过PasswordEncoder来验证密码的正确性。因为密码一般都不应该以明文形式存储,实际存储的是按一定规则编码后的文本,Spring Security支持多种编码方式,例如bcryptargon2scryptpbkdf2等。你可以配置PasswordEncoder Bean来选择不同的编码方式。都是请注意,内置的编码方式默认对编码后的文本有一个格式要求,就是必须有类似{bcrypt}的前缀来表示编码方式。

基本架构

架构图

上一节中,我们讲述了用户名密码认证的实现细节,现在,让我们以用户名密码认证方式为例,从整体上来看下身份认证的架构和流程。它的整体架构如下:

  1. 当一个HTTP请求进来后,UsernamePasswordAuthenticationFilter会从HTTP请求体中获取用户名和密码,然后使用这些信息创建一个UsernamePasswordAuthenticationToken对象作为认证请求的参数。
  2. 接下来,AuthenticationManager(其实现类是ProviderManager)负责对接受到的UsernamePasswordAuthenticationToken进行认证。
  3. ProviderManager会遍历配置的所有AuthenticationProvider,查找支持UsernamePasswordAuthenticationToken类型的AuthenticationProvider,然后委托其进行实际的认证工作,而在这里,匹配的就是DaoAuthenticationProvider
  4. DaoAuthenticationProvider首先调用UserDetailService获取用户信息,然后将获取到的密码(通常是编码后的密码)委托给PasswordEncoder进行验证。如果认证失败,DaoAuthenticationProvider会抛出AuthenticationException的子类表示认证失败。
  5. 当认证成功时,AuthenticationManager会返回一个UsernamePasswordAuthenticationToken对象作为认证结果,这个对象除了包含用户的基本信息外,最重要的是认证通过状态以及该用户拥有的权限列表,这些信息在后续的鉴权模块会用到。
  6. 认证结果会被放入SecurityContext,这样后续的模块(包括鉴权和用户业务模块等)如果需要这个结果(包括用户信息和权限列表),就可以通过以下方法获取:SecurityContextHolder.getContext().getAuthentication()

组件替换

这个架构非常灵活,大部分组件都是可配置和可替换的,自底向上,我们分别可以替换以下组件来满足特定需求:

  1. UserDetailsService:根据用户名查找用户信息的组件,默认配置的是内存存储InMemoryUserDetailsManager,你也可以配置为内置的数据库存储JdbcUserDetailsManager,但是它有很多默认的约定要遵守,对未来的扩展也不够灵活。通常会根据公司的规范要求或数据库存储的方式提供自定义的实现。
  2. PasswordEncoder:对密码进行编码的组件,建议根据公司的编码要求或当前数据库中已使用的编码来配置。如果没有特殊要求,建议采用默认的BCryptPasswordEncoder
  3. AuthenticationProvider: 为了安全需要,公司内部很多应用是不允许直接访问用户的密码的,而通常会提供一个认证的API。此时,就需要自定义AuthenticationProvider,它的核心逻辑就是调用API做认证,然后把结果再包装成Authentication返回给AuthenticationManager
  4. AuthenticationManager:它的默认实现ProviderManager适用于大部分场景,通常不需要替换,除非你不想引入太多的概念。
  5. UsernamePasswordAuthenticationFilter:如果你不想引入过多的概念和复杂度,可以提供自己的Security Filter,从而完全脱离该框架。但是需要确保认证结果模型Authentication仍然被正确处理,并且将结果通过方法SecurityContextHolder.getContext().setAuthentication放入Security Context中。

【Tips】从整个Security框架的角度来看,认证模块的核心概念只有两个,分别是认证结果AuthenticationSecurity Context。其它概念都可以认为是认证模块的内部实现细节。

鉴权模块Authorization

认证模块证明了用户的身份,但显然普通用户不应该可以随意访问管理页面或敏感资源,因此还需要有个模块来确保只有授权的用户才能执行特定的操作,这个模块称之为鉴权或者授权(Authorization)。

当你通过HttpSecurity.authorizeHttpRequests方法来配置请求的访问权限控制时,就会自动添加鉴权的Security Filter:AuthorizationFilter,它是整个SecurityFilterChain的最后一个Filter。

【版本兼容性】在Spring Security 6.0版本中,鉴权模块发生了很大变化。以前的版本中,鉴权模块使用FilterSecurityInterceptor,而6.0版本之后,这个被废弃了,取而代之的是AuthorizationFilter。同时,还有一些相关的依赖组件,如AccessDecisionManagerAccessDecisionVoter也被AuthorizationManger替换了。因此,本节的内容只限于6.0以及之后的版本。

实现原理

我们先看下鉴权模块的入口,也就是AuthorizationFilterdoFilter方法:

Java 复制代码
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
        throws ServletException, IOException {
    // ...其它非核心逻辑... //
    try {
        AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request); // (1)
        this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
        if (decision != null && !decision.isGranted()) { // (2)
            throw new AccessDeniedException("Access Denied");
        }
        chain.doFilter(request, response);
    }
    finally {
        request.removeAttribute(alreadyFilteredAttributeName);
    }
}

private Authentication getAuthentication() {
    Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); // (3)
    if (authentication == null) {
        throw new AuthenticationCredentialsNotFoundException(
                "An Authentication object was not found in the SecurityContext");
    }
    return authentication;
}
  1. 这个方法本身很简单,核心逻辑都委托给了AuthorizationManagerAuthorizationManager会校验Authentication的权限,并返回鉴权的结果AuthorizationDecision
  2. 如果当前认证用户没有访问权限,就会抛出AccessDeniedException异常,表示拒绝访问。
  3. 待校验的Authentication是从Security Context获取的,通常是在前面的认证阶段设置的。在这里,实际上传给AuthorizationManager的是一个获取Authentication的方法,而不是Authentication本身,这样就把实际的获取操作延后到了真正进行授权的时候,这在某些场景下可以提高性能,比如permitAll,实际上它根本用不到Authentication

AuthorizationManager

AuthorizationManager才是真正执行鉴权逻辑的类,最常用的实现类是AuthorityAuthorizationManager,它的实现逻辑很简单,它会调用AuthenticationgetAuthorities方法,获取当前登录用户的权限列表,然后将这些权限与请求需要的权限进行匹配。

实际上,选择使用哪个AuthorizationManager是开发手动设置的。我们来分析一个常用的权限配置代码片段:

Java 复制代码
@Bean
static SecurityFilterChain mySecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((requests) -> // (1)
            requests
                    .requestMatchers("/admin").hasAuthority("ROLE_ADMIN") // (2)
                    .requestMatchers("/hello").hasRole("USER") // (3)
                    .anyRequest().authenticated()); // (4)
    // ... 其它配置 ... //
    return http.build();
}
  1. 调用authorizeHttpRequests方法就相当于打开了鉴权模块,它会注册AuthorizationFilterSecurityFilterChain的最后。
  2. 对于匹配/admin的请求,要求有ROLE_ADMIN权限。hasAuthority的底层就是配置了一个要求ROLE_ADMIN权限的AuthorityAuthorizationManager对象。
  3. 对于匹配/hello的请求,要求有USER角色,等价于ROLE_USER权限。hasRole会自动在角色名称前面加上前缀ROLE_hasRole的底层就是配置了一个要求ROLE_USER权限的AuthorityAuthorizationManager对象。
  4. 对于其它的请求,只要通过身份认证就可以访问,不需要特定的权限。类似的,authenticated方法的底层配置了一个AuthenticatedAuthorizationManager对象。

在Spring Security中,很多初学者都容易混淆RoleAuthority的区别,实际上在技术实现层面上,这两者没有本质区别,底层都仅仅是一个表示权限的字符串标识符。更多的区别在于权限管理的概念上,一般情况下,Authority表示细粒度的操作权限,比如ADD_USERDELETE_USER等,通常是动词;而Role则会与实际业务角色想对应,比如管理员ADMIN,普通员工STAFF等,通常是名称。此外,一般一个Role会对应多个Authority,同时角色之间可以存在继承关系,比如ADMIN可以继承STAFF的所有权限。

写作我们来看下hasAuthority的源码,以分析它是如何配置AuthorizeManager的:

Java 复制代码
public AuthorizationManagerRequestMatcherRegistry hasAuthority(String authority) {
    return access( // (3)
      withRoleHierarchy( //(2)
          AuthorityAuthorizationManager.hasAuthority(authority) // (1)
      )
    );
}

public static <T> AuthorityAuthorizationManager<T> hasAuthority(String authority) {
    Assert.notNull(authority, "authority cannot be null");
    return new AuthorityAuthorizationManager<>(authority);
}

public AuthorizationManagerRequestMatcherRegistry access(
        AuthorizationManager<RequestAuthorizationContext> manager) {
    Assert.notNull(manager, "manager cannot be null");
    return AuthorizeHttpRequestsConfigurer.this.addMapping(this.matchers, manager);
}

private AuthorityAuthorizationManager<RequestAuthorizationContext> withRoleHierarchy(           
AuthorityAuthorizationManager<RequestAuthorizationContext> manager) {
    manager.setRoleHierarchy(AuthorizeHttpRequestsConfigurer.this.roleHierarchy.get());
    return manager;
}
  1. AuthorityAuthorizationManager.hasAuthority方法简单地创建了一个要求特定authority权限的AuthorityAuthorizationManager实例。
  2. withRoleHierarchy是一个装饰器方法,它打开了角色继承的功能。角色继承允许一个角色继承另一个角色的所有权限,从而简化权限配置。
  3. 最后,access方法将这个AuthorityAuthorizationManager实例注册到权限控制中。

access方法是公开的,你可以自己实现一个AuthorizationManager,然后通过这个方法进行注册。例如,我们可以提供一个拒绝所有请求的实现:

scss 复制代码
http.authorizeHttpRequests((requests) ->
    requests.anyRequest().access((authentication, object) -> null));

【Tips】通过自定义AuthorizationManager,我们可以完全接管鉴权的逻辑,实现更加灵活和复杂的权限控制。

基本架构

相比认证模块,鉴权模块不需要太多的灵活性和扩展性需求,因此它的架构相对简单。

同样,我们以一个标准的鉴权流程为例,来看整体的架构和流程图。

  1. 一个HTTP请求进来,经过了一系列Security Filter后,最终来到AuthorizationFilter,进而调用AuthorizationManager#check方法进行权限校验。
  2. 实际的校验工作继续委托给AuthoritiesAuthorizationManager
  3. AuthoritiesAuthorizationManager先从Security Context中获取到Authentication对象(这个对象一般是前面的某个认证Filter设置的),然后基于其Authorites权限列表构建GrantedAuthority列表,用于权限项的匹配。
  4. 最终会返回一个AuthorizationDecision表示权限校验结果。

总结

本文重点分析了Spring Security的源码和架构,帮助读者理解其实现原理。由于篇幅有限,本文只覆盖了身份认证和鉴权模块的核心逻辑,很多特性没有涉及,包括Session管理,Remember Me服务,异常分支和错误处理等等,不过有了上述的基础知识,读者完全可以自己分析源码并深入理解这些特性。

FAQ

认证和鉴权失败抛出的异常是如何处理的?

当发生认证或鉴权失败时,Spring Security有专门的Security Filter ExceptionTranslationFilter来捕获并处理这些异常。如果是认证异常错误AuthenticationException及其子类,会触发AuthenticationEntryPoint#commence方法,而如果是鉴权错误AccessDeniedException及其子类,则会触发AccessDeniedHandler#handle方法。

一个请求被Security拒绝了,应该如何Debug排查?

如果遇到身份认证错误,建议直接Debug相关Filter的doFilter方法,比如Form表单登录的Filter就是UsernamePasswordAuthenticationFilter;而如果是鉴权错误,可以从AuthorizationFilter开始Debug。

但需要注意的是,出于安全考虑,Security相关的错误通常不会提供明确的错误信息,甚至不会显示错误信息,而是直接跳转到登录页面,比如CsrfFilter可能会导致这种情况。在这种情况下,可以从第一个Filter开始Debug,启动日志搜索Will secure any request with,就可以找到所有Security Filter列表。或者直接从入口FilterChainProxy#doFilter开始Debug。

SecurityFilterChain的配置方法底层是如何实现的?

SecurityFilterChain是通过HttpSecurity提供的一套DSL进行配置的。诸如formLogincsrfauthorizeHttpRequests等方法的逻辑都类似,参数都是一个lambda表达式,用于做各种自定义配置。而每个方法都会对应一个特定的配置类,比如FormLoginConfigurerCsrfConfigurer等,在执行HttpSecurity#build方法的时候,会调用这些配置类的configure方法,该方法的作用就是根据用户的自定义配置,创建一个或者多个Security Filter,并将其注册到SecurityFilterChain

此外,开发者还可以通过HttpSecurity.addFilter方法直接添加自定义的Security Filter。而对于复杂且有许多配置选项的Filter,也可以自定义SecurityConfigurerAdapter类,并通过HttpSecurity#apply方法来配置和注册Filter。

Spring Security Starter有哪些默认配置?

Spring Security Starter默认配置在spring-boot-autoconfigure-x.x.x包下的文件META-INF\spring\org.springframework.boot.autoconfigure.AutoConfiguration.imports中可以找到。而具体的核心配置类有HttpSecurityConfigurationSpringBootWebSecurityConfiguration

Spring Security版本跟本文的不一样,遇到问题如何排查?

每次Spring Security升级,尤其是大版本升级,都可能引入破坏性或者不兼容的更新。不过,基于Filter和SecuiryFilterChain的框架和架构通常是不会改变的。但是,通常会废弃掉老的配置方法,引入新的配置,某些特定模块的实现也有可能完全替换,比如6.0的鉴权模块AuthorizationFilter就完全替换了老的鉴权模块。

你可以先从Security Filter列表开始排查,也可以通过入口FilterChainProxy#doFilter来Debug。

Spring Security整体太复杂了,能不能不使用它,而完全自己实现?

Security是个一个非常复杂的领域,很多开发者对其了解不深。使用Spring Security不仅提供了大部分的安全特性,还包含了很多安全领域的最佳实践。自己从头实现安全功能成本很高,并可能缺乏一些重要的安全特性。不过Spring Security的复杂设计以及频繁的破坏性更新,的确给开发带来了很大的学习成本和维护成本。

Spring Security的架构非常灵活,因此作者的建议是,不需要完全照搬整体框架,对于不同的应用类型和场景,可以选择性地引入部分功能。比如Admin应用可以提供自定义的AuthenticationProvider,而API服务完全可以自定义Securiy Filter,只要维护好Security Context的Authentication,就可以很好的集成到Spring Security框架里,同时开发的学习和维护成本也能降到最低。

相关推荐
艾菜籽1 小时前
Spring MVC入门补充2
java·spring·mvc
为java加瓦4 小时前
Spring 方法注入机制深度解析:Lookup与Replace Method原理与应用
java·数据库·spring
无名客04 小时前
SpringCloud中的网关(Gateway)的作用是什么?
spring·spring cloud·gateway
hrrrrb6 小时前
【Spring Security】Spring Security 概念
java·数据库·spring
小信丶6 小时前
Spring 中解决 “Could not autowire. There is more than one bean of type“ 错误
java·spring
hello 早上好13 小时前
深入 Spring 依赖注入底层原理
数据库·sql·spring
cxyxiaokui00120 小时前
🔍 为什么我的日志在事务回滚后也没了?——揭秘 REQUIRES_NEW 的陷阱
java·后端·spring
跟着珅聪学java20 小时前
spring boot 整合 activiti 教程
android·java·spring
Java水解21 小时前
Spring JDBC与KingbaseES深度集成:构建高性能国产数据库应用实战
后端·spring
低音钢琴1 天前
【SpringBoot从初学者到专家的成长15】MVC、Spring MVC与Spring Boot:理解其差异与联系
spring boot·spring·mvc