Fork me on GitHub

2023年5月

读源码剖析 Spring Security 的实现原理

Spring Security 是一个轻量级的安全框架,可以和 Spring 项目很好地集成,提供了丰富的身份认证和授权相关的功能,而且还能防止一些常见的网络攻击。我在工作中有很多项目都使用了 Spring Security 框架,但基本上都是浅尝辄止,按照说明文档配置好就完事了,一直没有时间深入地研究过。最近在 Reflectoring 上看到了一篇文章 Getting started with Spring Security and Spring Boot,写得非常全面仔细,感觉是一篇不错的 Spring Security 入门文章,于是花了一点时间拜读了一番,结合着 官方文档 和 源码 系统地学习一下 Spring Security 的实现原理。

入门示例

我们先从一个简单的例子开始,这里我直接使用了 使用 Spring 项目脚手架 中的 Hello World 示例。为了让这个示例程序开启 Spring Security 功能,我们在 pom.xml 文件中引入 spring-boot-starter-security 依赖即可:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

启动程序,会在控制台日志中看到类似下面这样的信息:

2023-05-15 06:52:52.418  INFO 8596 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: eeb386a9-e16a-4b9b-bbc6-c054c8d263b0

这个是由 Spring Security 随机生成的密码。访问 /hello 页面,可以看到出现了一个登录页面:

login.png

输入用户名(默认为 user)和密码(控制台日志)登录成功后我们才能正常访问页面。默认的用户名和密码可以使用下面的配置进行修改:

spring.security.user.name=admin
spring.security.user.password=123456

为了后续更好地对 Spring Security 进行配置,理解 Spring Security 的实现原理,我们需要进一步学习 Spring Security 的三大核心组件:

  • 过滤器(Servlet Filters)
  • 认证(Authentication)
  • 授权(Authorization)

Servlet Filters:Spring Security 的基础

我们知道,在 Spring MVC 框架中,DispatcherServlet 负责对用户的 Web 请求进行分发和处理,在请求到达 DispatcherServlet 之前,会经过一系列的 Servlet Filters,这被称之为过滤器,主要作用是拦截请求并对请求做一些前置或后置处理。这些过滤器串在一起,形成一个过滤器链(FilterChain):

filterchain.png

我们可以在配置文件中加上下面的日志配置:

logging.level.org.springframework.boot.web.servlet.ServletContextInitializerBeans=TRACE

然后重新启动服务,会在控制台输出类似下面这样的日志(为了方便查看,我做了一点格式化):

2023-05-18 07:08:14.805 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'webMvcMetricsFilter'; order=-2147483647, 
    resource=class path resource [org/springframework/boot/actuate/autoconfigure/metrics/web/servlet/WebMvcMetricsAutoConfiguration.class]
2023-05-18 07:08:14.806 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'securityFilterChainRegistration'; order=-100, 
    resource=class path resource [org/springframework/boot/autoconfigure/security/servlet/SecurityFilterAutoConfiguration.class]
2023-05-18 07:08:14.808 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Servlet initializer bean 'dispatcherServletRegistration'; order=2147483647, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/DispatcherServletAutoConfiguration$DispatcherServletRegistrationConfiguration.class]
2023-05-18 07:08:14.810 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing Filter initializer bean 'errorPageSecurityFilter'; order=2147483647, 
    resource=class path resource [org/springframework/boot/autoconfigure/security/servlet/SpringBootWebSecurityConfiguration$ErrorPageSecurityFilterConfiguration.class]
2023-05-18 07:08:14.813 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Added existing ServletContextInitializer initializer bean 'servletEndpointRegistrar'; order=2147483647, 
    resource=class path resource [org/springframework/boot/actuate/autoconfigure/endpoint/web/ServletEndpointManagementContextConfiguration$WebMvcServletEndpointManagementContextConfiguration.class]
2023-05-18 07:08:14.828 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'characterEncodingFilter'; order=-2147483648, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfiguration.class]    
2023-05-18 07:08:14.831 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'formContentFilter'; order=-9900, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.class]
2023-05-18 07:08:14.834 TRACE 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Created Filter initializer for bean 'requestContextFilter'; order=-105, 
    resource=class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.class]
2023-05-18 07:08:14.842 DEBUG 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Mapping filters: 
        filterRegistrationBean urls=[/*] order=-2147483647, 
        springSecurityFilterChain urls=[/*] order=-100, 
        filterRegistrationBean urls=[/*] order=2147483647, 
        characterEncodingFilter urls=[/*] order=-2147483648, 
        formContentFilter urls=[/*] order=-9900, 
        requestContextFilter urls=[/*] order=-105
2023-05-18 07:08:14.844 DEBUG 10020 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : 
    Mapping servlets: dispatcherServlet urls=[/] 

这里显示了应用开启的所有 Filter 以及对应的自动配置类,可以看到 Spring Security 自动注入了两个 FilterRegistrationBean:

  • 来自配置类 SecurityFilterAutoConfiguration 的 securityFilterChainRegistration
  • 来自配置类 ErrorPageSecurityFilterConfiguration 的 errorPageSecurityFilter

DelegatingFilterProxy:Servlet Filter 与 Spring Bean 的桥梁

注意上面显示的并非 Filter 的名字,而是 FilterRegistrationBean 的名字,这是一种 RegistrationBean,它实现了 ServletContextInitializer 接口,用于在程序启动时,将 Filter 或 Servlet 注入到 ServletContext 中:

public abstract class RegistrationBean implements ServletContextInitializer, Ordered {

    @Override
    public final void onStartup(ServletContext servletContext) throws ServletException {
        ...
        register(description, servletContext);
    }

}

其中 securityFilterChainRegistration 的定义如下:

@Bean
@ConditionalOnBean(name = DEFAULT_FILTER_NAME)
public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(
        SecurityProperties securityProperties) {
    DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(
            DEFAULT_FILTER_NAME);
    registration.setOrder(securityProperties.getFilter().getOrder());
    registration.setDispatcherTypes(getDispatcherTypes(securityProperties));
    return registration;
}

这个 RegistrationBean 的类型为 DelegatingFilterProxyRegistrationBean,由它注入的 Filter 叫 DelegatingFilterProxy:

public class DelegatingFilterProxyRegistrationBean extends AbstractFilterRegistrationBean<DelegatingFilterProxy> {
    ...
}

这是一个非常重要的 Servlet Filter,它充当着 Servlet 容器和 Spring 上下文之间的桥梁,由于 Servlet 容器有着它自己的标准,在注入 Filter 时并不知道 Spring Bean 的存在,所以我们可以通过 DelegatingFilterProxy 来实现 Bean Filter 的延迟加载:

delegatingfilterproxy.png

看一下 DelegatingFilterProxy 的实现:

public class DelegatingFilterProxy extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 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);
    }
}

这段代码很容易理解,首先判断代理的 Bean Filter 是否存在,如果不存在则根据 findWebApplicationContext() 找到 Web 应用上下文,然后从上下文中获取 Bean Filter 并初始化,最后再调用该 Bean Filter。

FilterChainProxy:Spring Security 的统一入口

那么接下来的问题是,这个 DelegatingFilterProxy 代理的 Bean Filter 是什么呢?我们从上面定义 DelegatingFilterProxyRegistrationBean 的地方可以看出,代理的 Bean Filter 叫做 DEFAULT_FILTER_NAME,查看它的定义就知道,实际上就是 springSecurityFilterChain:

public static final String DEFAULT_FILTER_NAME = "springSecurityFilterChain";

那么这个 springSecurityFilterChain 是在哪定义的呢?我们可以在 WebSecurityConfiguration 配置类中找到答案:

public class WebSecurityConfiguration {

    @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = this.webSecurityConfigurers != null && !this.webSecurityConfigurers.isEmpty();
        boolean hasFilterChain = !this.securityFilterChains.isEmpty();
        if (!hasConfigurers && !hasFilterChain) {
            WebSecurityConfigurerAdapter adapter = this.objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            this.webSecurity.apply(adapter);
        }
        for (SecurityFilterChain securityFilterChain : this.securityFilterChains) {
            this.webSecurity.addSecurityFilterChainBuilder(() -> securityFilterChain);
            for (Filter filter : securityFilterChain.getFilters()) {
                if (filter instanceof FilterSecurityInterceptor) {
                    this.webSecurity.securityInterceptor((FilterSecurityInterceptor) filter);
                    break;
                }
            }
        }
        for (WebSecurityCustomizer customizer : this.webSecurityCustomizers) {
            customizer.customize(this.webSecurity);
        }
        return this.webSecurity.build();
    }
}

很显然,springSecurityFilterChain 经过一系列的安全配置,最后通过 this.webSecurity.build() 构建出来的,进一步深入到 webSecurity 的源码我们就可以发现它的类型是 FilterChainProxy:

public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter, WebSecurity>
        implements SecurityBuilder<Filter>, ApplicationContextAware, ServletContextAware {

    @Override
    protected Filter performBuild() throws Exception {

        int chainSize = this.ignoredRequests.size() + this.securityFilterChainBuilders.size();
        List<SecurityFilterChain> securityFilterChains = new ArrayList<>(chainSize);
        List<RequestMatcherEntry<List<WebInvocationPrivilegeEvaluator>>> requestMatcherPrivilegeEvaluatorsEntries = new ArrayList<>();
        for (RequestMatcher ignoredRequest : this.ignoredRequests) {
            SecurityFilterChain securityFilterChain = new DefaultSecurityFilterChain(ignoredRequest);
            securityFilterChains.add(securityFilterChain);
            requestMatcherPrivilegeEvaluatorsEntries
                    .add(getRequestMatcherPrivilegeEvaluatorsEntry(securityFilterChain));
        }
        for (SecurityBuilder<? extends SecurityFilterChain> securityFilterChainBuilder : this.securityFilterChainBuilders) {
            SecurityFilterChain securityFilterChain = securityFilterChainBuilder.build();
            securityFilterChains.add(securityFilterChain);
            requestMatcherPrivilegeEvaluatorsEntries
                    .add(getRequestMatcherPrivilegeEvaluatorsEntry(securityFilterChain));
        }

        FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
        if (this.httpFirewall != null) {
            filterChainProxy.setFirewall(this.httpFirewall);
        }
        if (this.requestRejectedHandler != null) {
            filterChainProxy.setRequestRejectedHandler(this.requestRejectedHandler);
        }
        filterChainProxy.afterPropertiesSet();

        Filter result = filterChainProxy;

        this.postBuildAction.run();
        return result;
    }
}

从 FilterChainProxy 的名字可以看出来,它也是一个代理类,它代理的类叫做 SecurityFilterChain,它包含了多个 Security Filters 形成一个过滤器链,这和 Servlet Filters 有点类似,只不过这些 Security Filters 都是普通的 Spring Bean:

securityfilterchain.png

使用 FilterChainProxy 来代理 Security Filters 相比于直接使用 Servlet Filters 或使用 DelegatingFilterProxy 来代理有几个明显的好处:

  1. FilterChainProxy 作为 Spring Security 对 Servlet 的支持入口,方便理解和调试;
  2. FilterChainProxy 可以对 Spring Security 做一些集中处理,比如统一清除 SecurityContext 防止内存泄漏,以及统一使用 HttpFirewall 对应用进行保护等;
  3. 支持多个 SecurityFilterChain,传统的 Servlet Filters 只能通过 URL 来匹配,使用 FilterChainProxy 可以配合 RequestMatcher 更灵活地控制调用哪个 SecurityFilterChain;

securityfilterchains.png

构建 SecurityFilterChain

上面讲到,FilterChainProxy 是通过 webSecurity 构建的,一个 FilterChainProxy 里包含一个或多个 SecurityFilterChain,那么 SecurityFilterChain 是由谁构建的呢?答案是 httpSecurity。我们可以在 SecurityFilterChainConfiguration 配置类中看到 SecurityFilterChain 的构建过程:

@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
static class SecurityFilterChainConfiguration {

    @Bean
    @Order(SecurityProperties.BASIC_AUTH_ORDER)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated();
        http.formLogin();
        http.httpBasic();
        return http.build();
    }
}

深入到 http.build() 的源码,可以看到过滤器链的默认实现为 DefaultSecurityFilterChain:

public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
        implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {

    @SuppressWarnings("unchecked")
    @Override
    protected DefaultSecurityFilterChain performBuild() {

        this.filters.sort(OrderComparator.INSTANCE);
        List<Filter> sortedFilters = new ArrayList<>(this.filters.size());
        for (Filter filter : this.filters) {
            sortedFilters.add(((OrderedFilter) filter).filter);
        }
        return new DefaultSecurityFilterChain(this.requestMatcher, sortedFilters);
    }
}

构建 Security Filters

通过上面的梳理,我们大概清楚了 SecurityFilterChain 的构建过程,接下来,我们继续看 Security Filters 的构建过程。我们知道,一个SecurityFilterChain 中包含了多个 Security Filters,那么这些 Security Filters 是从哪里来的呢?

在 HttpSecurity 的代码里可以找到这么几个方法:

  • public HttpSecurity addFilter(Filter filter)
  • public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter)
  • public HttpSecurity addFilterAfter(Filter filter, Class<? extends Filter> afterFilter)
  • public HttpSecurity addFilterAt(Filter filter, Class<? extends Filter> atFilter)

我们不妨在 addFilter 方法内下个断点,然后以调试模式启动程序,每次触发断点时,我们将对应的 Filter 记录下来,并通过堆栈找到该 Filter 是从何处添加的:

序号Filter来源
1WebAsyncManagerIntegrationFilterHttpSecurityConfiguration.httpSecurity()
2CsrfFilterCsrfConfigurer.configure()
3ExceptionTranslationFilterExceptionHandlingConfigurer.configure()
4HeaderWriterFilterHeadersConfigurer.configure()
5SessionManagementFilterSessionManagementConfigurer.configure()
6DisableEncodeUrlFilterSessionManagementConfigurer.configure()
7SecurityContextPersistenceFilterSecurityContextConfigurer.configure()
8RequestCacheAwareFilterRequestCacheConfigurer.configure()
9AnonymousAuthenticationFilterAnonymousConfigurer.configure()
10SecurityContextHolderAwareRequestFilterServletApiConfigurer.configure()
11DefaultLoginPageGeneratingFilterDefaultLoginPageConfigurer.configure()
12DefaultLogoutPageGeneratingFilterDefaultLoginPageConfigurer.configure()
13LogoutFilterLogoutConfigurer.configure()
14FilterSecurityInterceptorAbstractInterceptUrlConfigurer.configure()
15UsernamePasswordAuthenticationFilterAbstractAuthenticationFilterConfigurer.configure()
16BasicAuthenticationFilterHttpBasicConfigurer.configure()

除了第一个 WebAsyncManagerIntegrationFilter 是在创建 HttpSecurity 的时候直接添加的,其他的 Filter 都是通过 XXXConfigurer 这样的配置器添加的。我们继续深挖下去可以发现,生成这些配置器的地方有两个,第一个地方是在 HttpSecurityConfiguration 配置类中创建 HttpSecurity 时,如下所示:

class HttpSecurityConfiguration {

    @Bean(HTTPSECURITY_BEAN_NAME)
    @Scope("prototype")
    HttpSecurity httpSecurity() throws Exception {
        WebSecurityConfigurerAdapter.LazyPasswordEncoder passwordEncoder = new WebSecurityConfigurerAdapter.LazyPasswordEncoder(
                this.context);
        AuthenticationManagerBuilder authenticationBuilder = new WebSecurityConfigurerAdapter.DefaultPasswordEncoderAuthenticationManagerBuilder(
                this.objectPostProcessor, passwordEncoder);
        authenticationBuilder.parentAuthenticationManager(authenticationManager());
        authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
        HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
        // @formatter:off
        http
            .csrf(withDefaults())
            .addFilter(new WebAsyncManagerIntegrationFilter())
            .exceptionHandling(withDefaults())
            .headers(withDefaults())
            .sessionManagement(withDefaults())
            .securityContext(withDefaults())
            .requestCache(withDefaults())
            .anonymous(withDefaults())
            .servletApi(withDefaults())
            .apply(new DefaultLoginPageConfigurer<>());
        http.logout(withDefaults());
        // @formatter:on
        applyDefaultConfigurers(http);
        return http;
    }
}

另外一个地方则是在上面的 SecurityFilterChainConfiguration 配置类中使用 http.build() 构建 SecurityFilterChain 之前(参见上面 defaultSecurityFilterChain 的代码),至此,我们大概理清了所有的 Security Filters 是如何创建的,下面再以表格的形式重新整理下:

序号FilterhttpSecurity 配置
1WebAsyncManagerIntegrationFilterhttp.addFilter(new WebAsyncManagerIntegrationFilter())
2CsrfFilterhttp.csrf(withDefaults())
3ExceptionTranslationFilterhttp.exceptionHandling(withDefaults())
4HeaderWriterFilterhttp.headers(withDefaults())
5SessionManagementFilterhttp.sessionManagement(withDefaults())
6DisableEncodeUrlFilterhttp.sessionManagement(withDefaults())
7SecurityContextPersistenceFilterhttp.securityContext(withDefaults())
8RequestCacheAwareFilterhttp.requestCache(withDefaults())
9AnonymousAuthenticationFilterhttp.anonymous(withDefaults())
10SecurityContextHolderAwareRequestFilterhttp.servletApi(withDefaults())
11DefaultLoginPageGeneratingFilterhttp.apply(new DefaultLoginPageConfigurer<>())
12DefaultLogoutPageGeneratingFilterhttp.apply(new DefaultLoginPageConfigurer<>())
13LogoutFilterhttp.logout(withDefaults())
14FilterSecurityInterceptorhttp.authorizeRequests().anyRequest().authenticated()
15UsernamePasswordAuthenticationFilterhttp.formLogin()
16BasicAuthenticationFilterhttp.httpBasic()

其实,如果仔细观察我们的程序输出的日志,也可以看到 Spring Security 默认的过滤器链为 DefaultSecurityFilterChain,以及它注入的所有 Security Filters:

2023-05-17 08:16:18.173  INFO 3936 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with [
        org.springframework.security.web.session.DisableEncodeUrlFilter@1d6751e3, 
        org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@2d258eff, 
        org.springframework.security.web.context.SecurityContextPersistenceFilter@202898d7, 
        org.springframework.security.web.header.HeaderWriterFilter@2c26ba07, 
        org.springframework.security.web.csrf.CsrfFilter@52d3fafd, 
        org.springframework.security.web.authentication.logout.LogoutFilter@235c997d, 
        org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@5d5c41e5, 
        org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@50b93353, 
        org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@6dca31eb, 
        org.springframework.security.web.authentication.www.BasicAuthenticationFilter@22825e1e, 
        org.springframework.security.web.savedrequest.RequestCacheAwareFilter@2c719bd4, 
        org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@53aa38be, 
        org.springframework.security.web.authentication.AnonymousAuthenticationFilter@4a058df8, 
        org.springframework.security.web.session.SessionManagementFilter@42ea7565, 
        org.springframework.security.web.access.ExceptionTranslationFilter@77cb452c, 
        org.springframework.security.web.access.intercept.FilterSecurityInterceptor@8054fe2]

在某些低版本中,可能会显示 DefaultSecurityFilterChain: Will not secure any request 这样的日志,这可能是 Spring Security 的 BUG,升级到最新版本即可。

其中有几个 Security Filters 比较重要,是实现认证和授权的基础:

  • CsrfFilter:默认开启对所有接口的 CSRF 防护,关于 CSRF 的详细信息,可以参考 Configuring CSRF/XSRF with Spring Security;
  • DefaultLoginPageGeneratingFilter:用于生成 /login 登录页面;
  • DefaultLogoutPageGeneratingFilter:用于生成 /login?logout 登出页面;
  • LogoutFilter:当用户退出应用时被调用,它通过注册的 LogoutHandler 删除会话并清理 SecurityContext,然后通过 LogoutSuccessHandler 将页面重定向到 /login?logout;
  • UsernamePasswordAuthenticationFilter:实现基于用户名和密码的安全认证,当认证失败,抛出 AuthenticationException 异常;
  • BasicAuthenticationFilter:实现 Basic 安全认证,当认证失败,抛出 AuthenticationException 异常;
  • AnonymousAuthenticationFilter:如果 SecurityContext 中没有 Authentication 对象时,它自动创建一个匿名用户 anonymousUser,角色为 ROLE_ANONYMOUS;
  • FilterSecurityInterceptor:这是 Spring Security 的最后一个 Security Filters,它从 SecurityContext 中获取 Authentication 对象,然后对请求的资源做权限判断,当授权失败,抛出 AccessDeniedException 异常;
  • ExceptionTranslationFilter:用于处理过滤器链中抛出的 AuthenticationException 和 AccessDeniedException 异常,AuthenticationException 异常由 AuthenticationEntryPoint 来处理,AccessDeniedException 异常由 AccessDeniedHandler 来处理;

认证和授权

有了 Security Filters,我们就可以实现各种 Spring Security 的相关功能了。应用程序的安全性归根结底包括了两个主要问题:认证(Authentication) 和 授权(Authorization)。认证解决的是 你是谁? 的问题,而授权负责解决 你被允许做什么?,授权也被称为 访问控制(Access Control)。这一节将深入学习 Spring Security 是如何实现认证和授权的。

跳转到 /login 页面

让我们回到第一节的例子,当访问 /hello 时,可以看到浏览器自动跳转到了 /login 登录页面,那么 Spring Security 是如何实现的呢?为了一探究竟,我们可以将 Spring Security 的日志级别调到 TRACE:

logging.level.org.springframework.security=TRACE

这样我们就能完整地看到这个请求经过 Security Filters 的处理过程:

2023-05-20 09:37:38.558 DEBUG 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Securing GET /hello
2023-05-20 09:37:38.559 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DisableEncodeUrlFilter (1/17)
2023-05-20 09:37:38.559 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking WebAsyncManagerIntegrationFilter (2/17)
2023-05-20 09:37:38.560 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SecurityContextPersistenceFilter (3/17)
2023-05-20 09:37:38.561 TRACE 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
2023-05-20 09:37:38.561 TRACE 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Created SecurityContextImpl [Null authentication]
2023-05-20 09:37:38.562 DEBUG 6632 --- [nio-8080-exec-9] s.s.w.c.SecurityContextPersistenceFilter : Set SecurityContextHolder to empty SecurityContext
2023-05-20 09:37:38.562 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking HeaderWriterFilter (4/17)
2023-05-20 09:37:38.562 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking CorsFilter (5/17)
2023-05-20 09:37:38.566 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking CsrfFilter (6/17)
2023-05-20 09:37:38.567 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.csrf.CsrfFilter         : Did not protect against CSRF since request did not match CsrfNotRequired [TRACE, HEAD, GET, OPTIONS]
2023-05-20 09:37:38.568 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking LogoutFilter (7/17)
2023-05-20 09:37:38.571 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.logout.LogoutFilter            : Did not match request to Ant [pattern='/logout', POST]        
2023-05-20 09:37:38.573 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking UsernamePasswordAuthenticationFilter (8/17)
2023-05-20 09:37:38.574 TRACE 6632 --- [nio-8080-exec-9] w.a.UsernamePasswordAuthenticationFilter : Did not match request to Ant [pattern='/login', POST]
2023-05-20 09:37:38.576 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DefaultLoginPageGeneratingFilter (9/17)
2023-05-20 09:37:38.578 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking DefaultLogoutPageGeneratingFilter (10/17)
2023-05-20 09:37:38.582 TRACE 6632 --- [nio-8080-exec-9] .w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [Ant [pattern='/logout', GET]]
2023-05-20 09:37:38.583 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking BasicAuthenticationFilter (11/17)
2023-05-20 09:37:38.584 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.www.BasicAuthenticationFilter  : Did not process authentication request since failed to find username and password in Basic Authorization header
2023-05-20 09:37:38.587 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking RequestCacheAwareFilter (12/17)
2023-05-20 09:37:38.588 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.s.HttpSessionRequestCache        : No saved request
2023-05-20 09:37:38.590 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SecurityContextHolderAwareRequestFilter (13/17)      
2023-05-20 09:37:38.591 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking AnonymousAuthenticationFilter (14/17)
2023-05-20 09:37:38.592 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter  : Set SecurityContextHolder to AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
2023-05-20 09:37:38.593 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking SessionManagementFilter (15/17)
2023-05-20 09:37:38.593 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking ExceptionTranslationFilter (16/17)
2023-05-20 09:37:38.594 TRACE 6632 --- [nio-8080-exec-9] o.s.security.web.FilterChainProxy        : Invoking FilterSecurityInterceptor (17/17)
2023-05-20 09:37:38.596 TRACE 6632 --- [nio-8080-exec-9] edFilterInvocationSecurityMetadataSource : Did not match request to EndpointRequestMatcher includes=[health], excludes=[], includeLinks=false - [permitAll] (1/2)
2023-05-20 09:37:38.610 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Did not re-authenticate AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] before authorizing
2023-05-20 09:37:38.619 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Authorizing filter invocation [GET /hello] with attributes [authenticated]
2023-05-20 09:37:38.626 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.expression.WebExpressionVoter  : Voted to deny authorization
2023-05-20 09:37:38.632 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.i.FilterSecurityInterceptor    : Failed to authorize filter invocation [GET /hello] with attributes [authenticated] using AffirmativeBased [DecisionVoters=[org.springframework.security.web.access.expression.WebExpressionVoter@f613067], AllowIfAllAbstainDecisions=false]
2023-05-20 09:37:38.640 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.a.ExceptionTranslationFilter     : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] 
to authentication entry point since access is denied

org.springframework.security.access.AccessDeniedException: Access is denied
        at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73) ~[spring-security-core-5.7.8.jar:5.7.8]

2023-05-20 09:37:38.691 DEBUG 6632 --- [nio-8080-exec-9] o.s.s.w.s.HttpSessionRequestCache        : Saved request http://localhost:8080/hello to session
2023-05-20 09:37:38.693 DEBUG 6632 --- [nio-8080-exec-9] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using And [Not [RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]], MediaTypeRequestMatcher [contentNegotiationStrategy=org.springframework.web.accept.HeaderContentNegotiationStrategy@4b95451, matchingMediaTypes=[application/xhtml+xml, image/*, text/html, text/plain], useEquals=false, ignoredMediaTypes=[*/*]]]       
2023-05-20 09:37:38.701 DEBUG 6632 --- [nio-8080-exec-9] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@168ad26f
2023-05-20 09:37:38.709 DEBUG 6632 --- [nio-8080-exec-9] o.s.s.web.DefaultRedirectStrategy        : Redirecting to http://localhost:8080/login
2023-05-20 09:37:38.712 TRACE 6632 --- [nio-8080-exec-9] o.s.s.w.header.writers.HstsHeaderWriter  : Not injecting HSTS header since it did not match request to [Is Secure]
2023-05-20 09:37:38.720 DEBUG 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Did not store empty SecurityContext
2023-05-20 09:37:38.730 DEBUG 6632 --- [nio-8080-exec-9] w.c.HttpSessionSecurityContextRepository : Did not store empty SecurityContext
2023-05-20 09:37:38.731 DEBUG 6632 --- [nio-8080-exec-9] s.s.w.c.SecurityContextPersistenceFilter : Cleared SecurityContextHolder to complete request

这个过程中有两点比较重要:第一点是经过 AnonymousAuthenticationFilter 时,将当前用户设置为 anonymousUser,角色为 ROLE_ANONYMOUS;第二点是经过 FilterSecurityInterceptor 时,校验当前用户是否有访问 /hello 页面的权限,在上面的 defaultSecurityFilterChain 中,可以看到 http.authorizeRequests().anyRequest().authenticated() 这样的代码,这说明 Spring Security 默认对所有的页面都开启了鉴权,所以会抛出 AccessDeniedException 异常,而这个异常被 ExceptionTranslationFilter 拦截,并将这个异常交给 LoginUrlAuthenticationEntryPoint 处理,从而重定向到 /login 页面,整个过程的示意图如下:

redirect-login.png

接下来,浏览器开始访问重定向后的 /login 页面,这时请求又会再一次经历一系列的 Security Filters,和上面的 /hello 请求不一样的是,/login 请求经过 DefaultLoginPageGeneratingFilter 时,会生成上面我们看到的登录页面并结束整个调用链:

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        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);
    }
}

AuthenticationManager:剖析认证流程

接下来,输入用户名和密码并提交,请求会再一次经历 Security Filters,这一次,请求在 UsernamePasswordAuthenticationFilter 这里被拦截下来,并开始了用户名和密码的认证过程:

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username.trim() : "";
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
                password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

这里将遇到 Spring Security 中处理认证的核心接口:AuthenticationManager:

public interface AuthenticationManager {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

这个接口只有一个 authenticate() 方法,它的入参是一个未认证的 Authentication,从 UsernamePasswordAuthenticationFilter 的代码中可以看到使用了 UsernamePasswordAuthenticationToken,它的返回有三种情况:

  • 如果认证成功,则返回认证成功后的 Authentication(通常带有 authenticated=true);
  • 如果认证失败,则抛出 AuthenticationException 异常;
  • 如果无法判断,则返回 null;

AuthenticationManager 接口最常用的一个实现是 ProviderManager 类,它包含了一个或多个 AuthenticationProvider 实例:

public class ProviderManager implements AuthenticationManager {

    private List<AuthenticationProvider> providers;
}

AuthenticationProvider 有点像 AuthenticationManager,但它有一个额外的方法 boolean supports(Class<?> authentication):

public interface AuthenticationProvider {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}

Spring Security 会遍历列表中所有的 AuthenticationProvider,并通过 supports() 方法来选取合适的 AuthenticationProvider 实例来实现认证,从上文中我们知道,UsernamePasswordAuthenticationFilter 在认证时使用的 Authentication 类型为 UsernamePasswordAuthenticationToken,对于这个 Authentication,默认使用的 AuthenticationProvider 是 DaoAuthenticationProvider,它继承自抽象类 AbstractUserDetailsAuthenticationProvider:

public abstract class AbstractUserDetailsAuthenticationProvider
        implements AuthenticationProvider, InitializingBean, MessageSourceAware {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = determineUsername(authentication);
        UserDetails user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
        
        this.preAuthenticationChecks.check(user);
        additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        this.postAuthenticationChecks.check(user);
        
        Object principalToReturn = user;
        if (this.forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }
}

其中,最关键的代码有两行,第一行是通过 retrieveUser() 方法获取 UserDetails:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    @Override
    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        return loadedUser;
    }
}

进入 retrieveUser() 方法内部,可以看到它是通过 UserDetailsService 的 loadUserByUsername() 方法来获取 UserDetails 的,而这个 UserDetailsService 默认实现是 InMemoryUserDetailsManager:

public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = this.users.get(username.toLowerCase());
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(),
                user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities());
    }
}

它的实现非常简单,就是从 users 这个 Map 中直接获取 UserDetails,那么 users 这个 Map 又是从哪来的呢? 答案就是我们在配置文件中配置的 spring.security.user,我们可以从自动配置类 UserDetailsServiceAutoConfiguration 中找到 InMemoryUserDetailsManager 的初始化代码:

public class UserDetailsServiceAutoConfiguration {

    @Bean
    @Lazy
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
            ObjectProvider<PasswordEncoder> passwordEncoder) {
        SecurityProperties.User user = properties.getUser();
        List<String> roles = user.getRoles();
        return new InMemoryUserDetailsManager(User.withUsername(user.getName())
            .password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
            .roles(StringUtils.toStringArray(roles))
            .build());
    }
}

另一行关键代码是通过 additionalAuthenticationChecks() 方法对 UserDetails 和 UsernamePasswordAuthenticationToken 进行校验,一般来说,就是验证密码是否正确:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        
        String presentedPassword = authentication.getCredentials().toString();
        if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            this.logger.debug("Failed to authenticate since password does not match stored value");
            throw new BadCredentialsException(this.messages
                    .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
        }
    }
}

一旦用户名和密码都验证通过,就调用 createSuccessAuthentication() 方法创建并返回一个认证成功后的 Authentication,然后经过一系列的后处理,整个认证的流程如下所示:

usernamepasswordauthenticationfilter.png

其中,SecurityContextHolder 将认证成功后的 Authentication 保存到安全上下文中供后续 Filter 使用;AuthenticationSuccessHandler 用于定义一些认证成功后的自定义逻辑,默认实现为 SimpleUrlAuthenticationSuccessHandler,它返回一个重定向,将浏览器转到登录之前用户访问的页面。

在我的测试中,SimpleUrlAuthenticationSuccessHandler 貌似并没有触发,新版本的逻辑有变动?

AccessDecisionManager:剖析授权流程

其实,在上面分析重定向 /login 页面的流程时已经大致了解了实现授权的逻辑,请求经过 FilterSecurityInterceptor 时,校验当前用户是否有访问页面的权限,如果没有,则会抛出 AccessDeniedException 异常。FilterSecurityInterceptor 的核心代码如下:

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
    
    public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {

        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
    }
}

可以看到,主要逻辑就包含在 beforeInvocation()、finallyInvocation() 和 afterInvocation() 这三个方法中,而对授权相关的部分则位于 beforeInvocation() 方法中:

public abstract class AbstractSecurityInterceptor
        implements InitializingBean, ApplicationEventPublisherAware, MessageSourceAware {

    protected InterceptorStatusToken beforeInvocation(Object object) {
        
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        
        Authentication authenticated = authenticateIfRequired();
        
        // Attempt authorization
        attemptAuthorization(object, attributes, authenticated);
        
        if (this.publishAuthorizationSuccess) {
            publishEvent(new AuthorizedEvent(object, attributes, authenticated));
        }

        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
    }

    private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,
            Authentication authenticated) {
        try {
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
            throw ex;
        }
    }
}

在这里,我们遇到了 Spring Security 实现授权的核心接口:AccessDecisionManager,Spring Security 就是通过该接口的 decide() 方法来决定用户是否有访问某个资源的权限。AccessDecisionManager 接口的默认实现为 AffirmativeBased,可以从 AbstractInterceptUrlConfigurer 中找到它的踪影:

public abstract class AbstractInterceptUrlConfigurer<C extends AbstractInterceptUrlConfigurer<C, H>, H extends HttpSecurityBuilder<H>>
        extends AbstractHttpConfigurer<C, H> {
    
    private AccessDecisionManager createDefaultAccessDecisionManager(H http) {
        AffirmativeBased result = new AffirmativeBased(getDecisionVoters(http));
        return postProcess(result);
    }
}

AffirmativeBased 实例中包含一个或多个 AccessDecisionVoter,它通过遍历所有的 AccessDecisionVoter 依次投票决定授权是否允许,只要有一个 AccessDecisionVoter 拒绝,则抛出 AccessDeniedException 异常:

public class AffirmativeBased extends AbstractAccessDecisionManager {

    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    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;
            }
        }
        if (deny > 0) {
            throw new AccessDeniedException(
                    this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }
        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
}

默认情况下,AffirmativeBased 实例中只有一个 AccessDecisionVoter,那就是 WebExpressionVoter:

public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {

    @Override
    public int vote(Authentication authentication, FilterInvocation filterInvocation,
            Collection<ConfigAttribute> attributes) {
        
        WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
        
        EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
                this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);

        boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
        if (granted) {
            return ACCESS_GRANTED;
        }
        return ACCESS_DENIED;
    }
}

WebExpressionVoter 将授权转换为 SpEL 表达式,检查授权是否通过,就是看执行 SpEL 表达式的结果是否为 true,这里的细节还有很多,详细内容还是参考 官方文档 吧。

参考

更多

Spring Security 的安全防护

Spring Security 自定义配置

Spring Security 单元测试

基于 Argo CD 的 GitOps 实践笔记

GitOps 这个概念最早是由 Weaveworks 的 CEO Alexis Richardson 在 2017 年提出的,它是一种全新的基于 Git 仓库来管理 Kubernetes 集群和交付应用程序的方式。它包含以下四个基本原则:

  1. 声明式(Declarative):整个系统必须通过声明式的方式进行描述,比如 Kubernetes 就是声明式的,它通过 YAML 来描述系统的期望状态;
  2. 版本控制和不可变(Versioned and immutable):所有的声明式描述都存储在 Git 仓库中,通过 Git 我们可以对系统的状态进行版本控制,记录了整个系统的修改历史,可以方便地回滚;
  3. 自动拉取(Pulled automatically):我们通过提交代码的形式将系统的期望状态提交到 Git 仓库,系统从 Git 仓库自动拉取并做出变更,这种被称为 Pull 模式,整个过程不需要安装额外的工具,也不需要配置 Kubernetes 的认证授权;而传统的 CI/CD 工具如 Jenkins 或 CircleCI 等使用的是 Push 模式,这种模式一般都会在 CI 流水线运行完成后通过执行命令将应用部署到系统中,这不仅需要安装额外工具(比如 kubectl),还需要配置 Kubernetes 的授权,而且这种方式无法感知部署状态,所以也就无法保证集群状态的一致性了;
  4. 持续调谐(Continuously reconciled):通过在目标系统中安装一个 Agent,一般使用 Kubernetes Operator 来实现,它会定期检测实际状态与期望状态是否一致,一旦检测到不一致,Agent 就会自动进行修复,确保系统达到期望状态,这个过程就是调谐(Reconciliation);这样做的好处是将 Git 仓库作为单一事实来源,即使集群由于误操作被修改,Agent 也会通过持续调谐自动恢复。

其实,在提出 GitOps 概念之前,已经有另一个概念 IaC (Infrastructure as Code,基础设施即代码)被提出了,IaC 表示使用代码来定义基础设施,方便编辑和分发系统配置,它作为 DevOps 的最佳实践之一得到了社区的广泛关注。关于 IaC 和 GitOps 的区别,可以参考 The GitOps FAQ。

Argo CD 快速入门

基于 GitOps 理念,很快诞生出了一批 声明式的持续交付(Declarative Continuous Deployment) 工具,比如 Weaveworks 的 Flux CD 和 Intuit 的 Argo CD,虽然 Weaveworks 是 GitOps 概念的提出者,但是从社区的反应来看,似乎 Argo CD 要更胜一筹。

Argo 项目最初是由 Applatix 公司于 2017 年创建,2018 年这家公司被 Intuit 收购,Argo 项目就由 Intuit 继续维护和演进。Argo 目前包含了四个子项目:Argo Workflows、Argo Events、Argo CD 和 Argo Rollouts,主要用于运行和管理 Kubernetes 上的应用程序和任务,所有的 Argo 项目都是基于 Kubernetes 控制器和自定义资源实现的,它们组合在一起,提供了创建应用程序和任务的三种模式:服务模式、工作流模式和基于事件的模式。2020 年 4 月 7 日,Argo 项目加入 CNCF 开始孵化,并于 2022 年 12 月正式毕业,成为继 Kubernetes、Prometheus 和 Envoy 之后的又一个 CNCF 毕业项目。

这一节我们将学习 Argo CD,学习如何通过 Git 以及声明式描述来部署 Kubernetes 资源。

安装 Argo CD

Argo CD 提供了两种 安装形式:多租户模式(Multi-Tenant) 和 核心模式(Core)。多租户模式提供了 Argo CD 的完整特性,包括 UI、SSO、多集群管理等,适用于多个团队用户共同使用;而核心模式只包含核心组件,不包含 UI 及多租户功能,适用于集群管理员独自使用。

使用比较多的是多租户模式,官方还为其提供了两份部署配置:非高可用配置 install.yaml 和高可用配置 ha/install.yaml,生产环境建议使用高可用配置,开发和测试环境可以使用非高可用配置,下面就使用非高可用配置来安装 Argo CD。

首先,创建一个 argocd 命名空间:

$ kubectl create namespace argocd
namespace/argocd created

接着将 Argo CD 部署到该命名空间中:

$ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
customresourcedefinition.apiextensions.k8s.io/applications.argoproj.io created
customresourcedefinition.apiextensions.k8s.io/applicationsets.argoproj.io created
customresourcedefinition.apiextensions.k8s.io/appprojects.argoproj.io created
serviceaccount/argocd-application-controller created
serviceaccount/argocd-applicationset-controller created
serviceaccount/argocd-dex-server created
serviceaccount/argocd-notifications-controller created
serviceaccount/argocd-redis created
serviceaccount/argocd-repo-server created
serviceaccount/argocd-server created
role.rbac.authorization.k8s.io/argocd-application-controller created
role.rbac.authorization.k8s.io/argocd-applicationset-controller created
role.rbac.authorization.k8s.io/argocd-dex-server created
role.rbac.authorization.k8s.io/argocd-notifications-controller created
role.rbac.authorization.k8s.io/argocd-server created
clusterrole.rbac.authorization.k8s.io/argocd-application-controller created
clusterrole.rbac.authorization.k8s.io/argocd-server created
rolebinding.rbac.authorization.k8s.io/argocd-application-controller created
rolebinding.rbac.authorization.k8s.io/argocd-applicationset-controller created
rolebinding.rbac.authorization.k8s.io/argocd-dex-server created
rolebinding.rbac.authorization.k8s.io/argocd-notifications-controller created
rolebinding.rbac.authorization.k8s.io/argocd-redis created
rolebinding.rbac.authorization.k8s.io/argocd-server created
clusterrolebinding.rbac.authorization.k8s.io/argocd-application-controller created
clusterrolebinding.rbac.authorization.k8s.io/argocd-server created
configmap/argocd-cm created
configmap/argocd-cmd-params-cm created
configmap/argocd-gpg-keys-cm created
configmap/argocd-notifications-cm created
configmap/argocd-rbac-cm created
configmap/argocd-ssh-known-hosts-cm created
configmap/argocd-tls-certs-cm created
secret/argocd-notifications-secret created
secret/argocd-secret created
service/argocd-applicationset-controller created
service/argocd-dex-server created
service/argocd-metrics created
service/argocd-notifications-controller-metrics created
service/argocd-redis created
service/argocd-repo-server created
service/argocd-server created
service/argocd-server-metrics created
deployment.apps/argocd-applicationset-controller created
deployment.apps/argocd-dex-server created
deployment.apps/argocd-notifications-controller created
deployment.apps/argocd-redis created
deployment.apps/argocd-repo-server created
deployment.apps/argocd-server created
statefulset.apps/argocd-application-controller created
networkpolicy.networking.k8s.io/argocd-application-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-applicationset-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-dex-server-network-policy created
networkpolicy.networking.k8s.io/argocd-notifications-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-redis-network-policy created
networkpolicy.networking.k8s.io/argocd-repo-server-network-policy created
networkpolicy.networking.k8s.io/argocd-server-network-policy created

另外,还可以通过 Helm 来部署 Argo CD,这里是社区维护的 Helm Charts。

通过 Web UI 访问 Argo CD

Argo CD 部署好之后,默认情况下,API Server 从集群外是无法访问的,这是因为 API Server 的服务类型是 ClusterIP:

$ kubectl get svc argocd-server -n argocd
NAME            TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
argocd-server   ClusterIP   10.111.209.6   <none>        80/TCP,443/TCP               23h

我们可以使用 kubectl patch 将其改为 NodePort 或 LoadBalancer:

$ kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "NodePort"}}'

修改之后,Kubernetes 会为 API Server 随机分配端口:

$ kubectl get svc argocd-server -n argocd
NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
argocd-server   NodePort   10.111.209.6   <none>        80:32130/TCP,443:31205/TCP   23h

这时我们就可以通过 localhost:32130 或 localhost:31205 来访问 API Server 了:

argocd-login.png

可以看到 API Server 需要登录才能访问,初始用户名为 admin,初始密码在部署时随机生成,并保存在 argocd-initial-admin-secret 这个 Secret 里:

$ kubectl get secrets argocd-initial-admin-secret -n argocd -o yaml
apiVersion: v1
data:
  password: SlRyZDYtdEpOT1JGcXI3QQ==
kind: Secret
metadata:
  creationTimestamp: "2023-05-04T00:14:19Z"
  name: argocd-initial-admin-secret
  namespace: argocd
  resourceVersion: "17363"
  uid: 0cce4b4a-ff9d-44b3-930d-48bc5530bef0
type: Opaque

密码以 BASE64 形式存储,可以使用下面的命令快速得到明文密码:

$ kubectl get secrets argocd-initial-admin-secret -n argocd --template={{.data.password}} | base64 -d
JTrd6-tJNORFqr7A

输入用户名和密码登录成功后,进入 Argo CD 的应用管理页面:

argocd-ui.png

除了修改服务类型,官方还提供了两种方法暴露 API Server:一种是 使用 Ingress 网关,另一种是使用 kubectl port-forward 命令进行端口转发:

$ kubectl port-forward svc argocd-server -n argocd 8080:443

通过 CLI 访问 Argo CD

我们也可以使用命令行客户端来访问 Argo CD,首先使用 curl 命令下载:

$ curl -LO https://github.com/argoproj/argo-cd/releases/download/v2.7.1/argocd-linux-amd64

然后使用 install 命令安装:

$ sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd

使用 argocd version 查看 Argo CD 的版本信息,验证安装是否成功:

$ argocd version
argocd: v2.7.1+5e54351
  BuildDate: 2023-05-02T16:54:25Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: clean
  GoVersion: go1.19.8
  Compiler: gc
  Platform: linux/amd64
FATA[0000] Argo CD server address unspecified

由于此时还没有配置服务端,所以 argocd version 只显示了 Argo CD 客户端的版本信息,没有服务端的版本信息。通过上一节的方法将 API Server 暴露之后,就可以使用 argocd login 连接服务端:

$ argocd login localhost:32130
WARNING: server certificate had error: x509: certificate signed by unknown authority. Proceed insecurely (y/n)? y
Username: admin
Password:
'admin:login' logged in successfully
Context 'localhost:32130' updated

登录成功后,再次查看版本:

$ argocd version
argocd: v2.7.1+5e54351
  BuildDate: 2023-05-02T16:54:25Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: clean
  GoVersion: go1.19.8
  Compiler: gc
  Platform: linux/amd64
argocd-server: v2.7.1+5e54351.dirty
  BuildDate: 2023-05-02T16:35:40Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: dirty
  GoVersion: go1.19.6
  Compiler: gc
  Platform: linux/amd64
  Kustomize Version: v5.0.1 2023-03-14T01:32:48Z
  Helm Version: v3.11.2+g912ebc1
  Kubectl Version: v0.24.2
  Jsonnet Version: v0.19.1

部署应用

这一节我们将学习如何通过 Argo CD 来部署一个 Kubernetes 应用,官方在 argoproj/argocd-example-apps 仓库中提供了很多示例应用可供我们直接使用,这里我们将使用其中的 guestbook 应用。

通过 Web UI 部署应用

最简单的方法是通过 Argo CD 提供的可视化页面 Web UI 来部署应用,打开 Argo CD 的应用管理页面,点击 + NEW APP 按钮,弹出新建应用的对话框:

new-app.png

对话框中的选项比较多,但是我们只需要填写红框部分的内容即可,包括:

  • 通用配置

    • 应用名称:guestbook
    • 项目名称:default
    • 同步策略:Manual
  • 源配置

    • Git 仓库地址:https://github.com/argoproj/argocd-example-apps.git
    • 分支:HEAD
    • 代码路径:guestbook
  • 目标配置

    • 集群地址:https://kubernetes.default.svc
    • 命名空间:default

其他的选项暂时可以不用管,如果想了解具体内容可以参考官方文档 Sync Options,填写完成后,点击 CREATE 按钮即可创建应用:

new-app-not-sync.png

因为刚刚填写的同步策略是手工同步,所以我们能看到应用的状态还是 OutOfSync,点击应用详情:

new-app-not-sync-detail.png

可以看到 Argo CD 已经从 Git 仓库的代码中解析出应用所包含的 Kubernetes 资源了,guestbook 应用包含了一个 Deployment 和 一个 Service,点击 SYNC 按钮触发同步,Deployment 和 Service(以及它们关联的资源)被成功部署到 Kubernetes 集群中:

new-app-sync.png

测试完成后,点击 DELETE 删除应用,该应用关联的资源将会被级联删除。

通过 CLI 部署应用

我们还可以通过 Argo CD 提供的命令行工具来部署应用,经过上一节的步骤,我们已经登录了 API Server,我们只需要执行下面的 argocd app create 命令即可创建 guestbook 应用:

$ argocd app create guestbook \
  --repo https://github.com/argoproj/argocd-example-apps.git \
  --path guestbook \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default
application 'guestbook' created

也可以使用 YAML 文件声明式地创建 Argo CD 应用:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: guestbook
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/argoproj/argocd-example-apps.git
    targetRevision: HEAD
    path: guestbook
  destination:
    server: https://kubernetes.default.svc
    namespace: guestbook

我们创建一个 YAML 文件,包含以上内容,然后执行 kubectl apply -f 即可,和 Web UI 操作类似,刚创建的应用处于 OutOfSync 状态,我们可以使用 argocd app get 命令查询应用详情进行确认:

$ argocd app get guestbook
Name:               argocd/guestbook
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
URL:                https://localhost:32130/applications/guestbook
Repo:               https://github.com/argoproj/argocd-example-apps.git
Target:
Path:               guestbook
SyncWindow:         Sync Allowed
Sync Policy:        <none>
Sync Status:        OutOfSync from  (53e28ff)
Health Status:      Missing

GROUP  KIND        NAMESPACE  NAME          STATUS     HEALTH   HOOK  MESSAGE
       Service     default    guestbook-ui  OutOfSync  Missing
apps   Deployment  default    guestbook-ui  OutOfSync  Missing

接着我们执行 argocd app sync 命令,手工触发同步:

$ argocd app sync guestbook
TIMESTAMP                  GROUP        KIND   NAMESPACE                  NAME    STATUS    HEALTH        HOOK  MESSAGE
2023-05-06T08:22:53+08:00            Service     default          guestbook-ui  OutOfSync  Missing
2023-05-06T08:22:53+08:00   apps  Deployment     default          guestbook-ui  OutOfSync  Missing
2023-05-06T08:22:53+08:00            Service     default          guestbook-ui    Synced  Healthy
2023-05-06T08:22:54+08:00            Service     default          guestbook-ui    Synced   Healthy              service/guestbook-ui created
2023-05-06T08:22:54+08:00   apps  Deployment     default          guestbook-ui  OutOfSync  Missing              deployment.apps/guestbook-ui created
2023-05-06T08:22:54+08:00   apps  Deployment     default          guestbook-ui    Synced  Progressing              deployment.apps/guestbook-ui created

Name:               argocd/guestbook
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
URL:                https://localhost:32130/applications/guestbook
Repo:               https://github.com/argoproj/argocd-example-apps.git
Target:
Path:               guestbook
SyncWindow:         Sync Allowed
Sync Policy:        <none>
Sync Status:        Synced to  (53e28ff)
Health Status:      Progressing

Operation:          Sync
Sync Revision:      53e28ff20cc530b9ada2173fbbd64d48338583ba
Phase:              Succeeded
Start:              2023-05-06 08:22:53 +0800 CST
Finished:           2023-05-06 08:22:54 +0800 CST
Duration:           1s
Message:            successfully synced (all tasks run)

GROUP  KIND        NAMESPACE  NAME          STATUS  HEALTH       HOOK  MESSAGE
       Service     default    guestbook-ui  Synced  Healthy            service/guestbook-ui created
apps   Deployment  default    guestbook-ui  Synced  Progressing        deployment.apps/guestbook-ui created

等待一段时间后,应用关联资源就部署完成了:

$ kubectl get all
NAME                               READY   STATUS    RESTARTS   AGE
pod/guestbook-ui-b848d5d9d-rtzwf   1/1     Running   0          67s

NAME                   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/guestbook-ui   ClusterIP   10.107.51.212   <none>        80/TCP    67s
service/kubernetes     ClusterIP   10.96.0.1       <none>        443/TCP   30d

NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/guestbook-ui   1/1     1            1           67s

NAME                                     DESIRED   CURRENT   READY   AGE
replicaset.apps/guestbook-ui-b848d5d9d   1         1         1       67s

测试完成后,执行 argocd app delete 命令删除应用,该应用关联的资源将会被级联删除:

$ argocd app delete guestbook
Are you sure you want to delete 'guestbook' and all its resources? [y/n] y
application 'guestbook' deleted

参考

更多

部署其他的应用类型

Argo CD 不仅支持原生的 Kubernetes 配置清单,也支持 Helm Chart、Kustomize 或者 Jsonnet 部署清单,甚至可以通过 Config Management Plugins 实现自定义配置。

Flux CD