原文:Spring Security Architecture

译者:LeeR

本文是 Spring Security 的入门指南,深入解析了 Spring Security 框架的设计和基础模块。我们仅涉及程序安全性的基础知识,但是这可以帮助使用 Spring Security 的开发者解开一些疑惑。为此,我们如何使用 filter 和方法注解来实践 web 应用的安全。如果你想在更高层次上理解如何保障应用安全性,或者想要定制应用安全,又或者你只是想了解设计应用安全的思路,那么本指南就很适合你。

本指南不是解决最基本问题之外的用户手册(这样文章已经有很多了),但是本文对初学者和高手都有一定的帮助。Spring Boot 在本文中经常被提及,因为它为 Spring Security 提供了一些默认配置并且这有助于理解 Spring Security 是如何适应整个架构的。而所有这些原则对不使用 Spring Boot 的应用同样适用。

认证和访问控制

应用安全总结起来就是两大问题:认证(authentication,你是谁?)和授权(authorization,允许你做什么?)有时候也会用访问控制(access control)这个名词来代替授权,这会让我们一些困惑,但以这种方式思考可能会有帮助:“授权”在其他地方已经实现了。Spring Security 的架构旨在将认证从授权中分离出来,并也有适用于两者的策略和可扩展的设计。

认证(Authentication)

用于认证的主要接口是AuthenticationManager,它只有一个方法:

1
2
3
4
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}

一个 AuthenticationManagerauthenticate()方法中有三种情况:

  1. 返回 Authentication (authenticated=true),如果验证输入是合法的Principal)。
  2. 抛出AuthenticationException异常,如果输入不合法。
  3. 如果无法判断,则返回null

AuthenticationException是一个运行时异常,通常被应用程序以常规的方式的处理,这取决于应用的母的和代码风格。换句话说,代码中一般不会捕捉和处理这个异常。比如,可以使得网页显示认证失败,后端返回 401 HTTP 状态码,响应头中的WWW-Authenticate 有无视情况而定。

AuthenticationManager最普遍的实现是ProviderManagerProviderManager将认证委托给一系列的AuthenticationProvider实例 。AuthenticationProviderAuthenticationManager 很类似,但是它有一个额外的方法允许查询它支持的Authentication方式:

1
2
3
4
5
6
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;

boolean supports(Class<?> authentication);
}

supports方法的Class<?> authentication参数其实是Class<? extends Authentication>类型的。一个ProviderManager在一个应用中能支持多种不同的认证机制,通过将认证委托给一系列的AuthenticationProviderProviderManager没有识别出的认证类型,将会被忽略。

每个ProviderManager可以有一个父类,如果所有AuthenticationProvider都返回null,那么就交给父类去认证。如果父类也不可用,则抛出AuthenticationException异常。

有时应用的资源会有逻辑分组(比如所有网站资源都匹配URL/api/**),并且每个组都有自己的AuthenticationManager,通常是一个ProviderManager,它们之间有共同的父类认证器。那么父类就是一种全局资源,充当所有认证器的 fallback。

图1 ProviderManager 的继承关系

自定义AuthenticationManager

Spring Security 提供了一些配置方式帮助你快速的配置通用的AuthenticationManager。最常见的是AuthenticationManagerBuilder,它可以使用内存方式(in-memory)、JDBC 或 LDAP、或自定义的UserDetailService来认证用户。下面是设置全局认证器的例子:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {

... // web stuff here

@Autowired
public initialize(AuthenticationManagerBuilder builder, DataSource dataSource) {
builder.jdbcAuthentication().dataSource(dataSource).withUser("dave")
.password("secret").roles("USER");
}

}

虽然这个例子仅仅设计一个 web 应用,但是AuthenticationManagerBuilder的用处大为广阔(详细情况请看Web 安全是如何实现的)。请注意AuthenticationManagerBuilder是通过@AutoWired注入到被@Bean注解的一个方法中的,这使得它成为一个全局AuthenticationManager。相反的,如果我们这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class ApplicationSecurity extends WebSecurityConfigurerAdapter {

@Autowired
DataSource dataSource;

... // web stuff here

@Override
public configure(AuthenticationManagerBuilder builder) {
builder.jdbcAuthentication().dataSource(dataSource).withUser("dave")
.password("secret").roles("USER");
}

}

重写configure(AuthenticationManagerBuilder builder)方法,那么AuthenticationManagerBuilder仅会构造一个“本地”的AuthenticationManager,只是全局认证器的一个子实现。在 Spring Boot 应用中你可以使用@Autowired注入全局的AuthenticationManager,但是你不能注入“本地”的,除非你自己公开暴露它。

Spring Boot 提供默认的全局AuthenticationManager,除非你提供自己的全局AuthenticationManager。不用担心,默认的已经足够安全了,除非你真的需要一个自定义的全局AuthenticationManager。一般的,你只需只用“本地”的AuthenticationManagerBuilder来配置,而不需要担心全局的。

授权(Authorization)

一旦认证成功,我们就可以进行授权了,它核心的策略就是AccessDecisionManager。它提供三个方法并且全部委托给AccessDecisionVoter,这有点像ProviderManager将认证委托给AuthenticationProvider

一个AccessDecisionVoter考虑一个Authentication(代表一个Principal)和一个被ConfigAttributes装饰的安全对象:

1
2
3
4
5
6
boolean supports(ConfigAttribute attribute);

boolean supports(Class<?> clazz);

int vote(Authentication authentication, S object,
Collection<ConfigAttribute> attributes);

AccessDecisionVoterAccessDecisionManager方法中的object参数是完全泛型化的,它代表任何用户想要访问(web 资源或 Java 方法是最常见的两种情况)。ConfigAttributes也是相当泛型化的,它表示一个被装饰的安全对象并带有访问权限级别的元数据。ConfigAttributes是一个接口,仅有一个返回String的方法,返回的字符串中包含资源所有者,解释了访问资源的规则。常见的ConfigAttributes是用户的角色(比如ROLE_ADMINROLE_AUDIT),它们通常有一定的格式(比如以ROLE_`作为前缀)或者是可计算的表达式。

大部分人使用默认的AccessDecisionManager,即AffirmativeBased(如果没有 voters 返回那么该访问将被授权)。任何自定义的行为最好放在 voter 中,不乱世添加一个新的 voter 还是修改已有的 voter。

使用 Spring Expression Language(SpEL)表达式的ConfigAttributes是很常见的,比如isFullyAuthenticated() && hasRole('FOO')。解析表达式和加载表达式由AccessDecisionVoter实现。要扩展可处理的表达式的范围,需要自定义SecurityExpressionRoot,优势也需要SecurityExpressionHandler

Web 安全

Web 层中的 Spring Security 基于 Servlet 的Filter。所以先来看下Filter在 web 安全中所扮演的角色。下图展示了处理单个 HTTP 请求的经典分层结构。

客户端向应用发送请求,然后容器根据 URI 来决定哪个 filter(过滤器) 和哪个 Servlet 适用于它。一个 servlet 最多处理一个请求,过滤器是链式的,它们是有顺序的。事实上一个过滤器可以否决接下来的过滤器,如果它想独自处理这个请求的话。一个过滤器也可以对下流的过滤器和 servlet 修改响应和请求。所以过滤器的顺序十分重要,Spring Boot 提供管理过滤器的两种机制:一个是被@Bean注解的Filter可以用@Order注解或实现Ordered接口;另一个是过滤器是FilterRegistrationBean的一部分,它本身就有一个顺序。一些现有的过滤器定义了自己的常量来表示顺序,以帮助表明他们相对于彼此的顺序(比如 Spring Session 中的SessionRepositoryFilterDEFAULT_ORDER的值为Integer.MIN_VALUE + 50,它表示这个过滤器相对的在过滤链的前端,但是也不排斥在它之前的过滤器,前面还剩下50个位置)。

Spring Security 在过滤链中表现为一个Filter,其类型是FilterChainProxy,原因你很快就会知道。在一个 Spring Boot 应用中安全过滤器是ApplicationContext中的一个Bean,并且它是默认配置的,所以在每次请求中都会存在。而它在过滤链中的位置由SecurityProperties.DEFAULT_FILTER_ORDER决定,而该位置又由FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER(值为0,是 Spring Boot 中改变请求行为的过滤器的顺序的最大值)锚定。(译者注:Order的值越小,越在过滤链的前端)。除此之外,从容器的角度来看 Spring Security 是一个单独的过滤器,但是其中包含了额外过滤器,每个过滤器都发挥特殊的作用,如下图所示:

图2 Spring Security 是一个单独的物理过滤器,但是它将请求委托一系列的内部过滤器

事实上内部的安全过滤器不止一个层次结构:它们通常是DelegatingFilterProxy,不需要是一个 Spring Bean。代理委托给FilterChainProxy,而它是一个Bean,bean 的名字通常是springSecurityFilterChainFilterChainProxy包含了所有内部安全过滤器,并且以一定顺序排列成过滤链。其中所有的过滤器都有相同的 API(它们都实现了Servlet规范的Filter接口),它们都有机会否决过滤链的下流部分。

Spring Security 可以在同一顶层FilterChainProxy中管理多个过滤器链,并且对容器来说都是未知的。Spring Security Filter 包含了一系列的过滤链,并且向这些链分发匹配它们的请求。下图展示了根据请求路径来分发(/foo/**/**之前匹配)。这是一种常见但不是唯一的分发方式。最重要的特征是,分发过程中,只有一条过滤链只处理该请求。

图3 Spring Security FilterChainProxy 分发请求给首先匹配的过滤链。

一个纯净的(没有自定义安全配置的) Spring Boot 应用通常有 n 条过滤链,n = 6。第一条链(n-1)是忽略静态资源的,比如/css/**/images/**,和错误页面/error(这些路径可以在SecurityProperties中的security.ignored里配置)。最后一条链匹配所有路径/**,并且包含认证逻辑、授权、异常处理、session 处理,响应头处理等。这条过滤链中默认一共有 11 个过滤器,我们一般不关心使用哪个过滤器以及在何时使用他们。

注意:意识到 Spring Security 的内部过滤器对容器是透明的这是很重要的,所有的Filter都以@Bean的方式自动注册到容器中。所以如果你想在安全过滤链中添加过滤器,你不需要使用@Bean注解或将其包裹在显示禁用容器注册的FilterRegistrationBean中。

创建和自定义过滤链

Spring Boot 中默认的 fallback 过滤链(使用/**匹配的过滤链)有一个预定义的顺序SecurityProperties.BASIC_AUTH_ORDER。你可以使用security.base.enabled=false关闭它,或者你可以定义一个更低的顺序值(译者注:越低的值表示顺序更前,所以它的顺序在默认的 fallback 之前)。只要添加一个WebSecurityConfigurerAdapterWebSecurityConfigurer的 Bean 然后用@Order注解。比如:

1
2
3
4
5
6
7
8
9
@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)
public class ApplicationConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/foo/**")
...;
}
}

这个 Bean 将导致 Spring Security 在默认的 fallback 之前添加一个过滤链。

许多应用中对一组资源的和另一组资源的访问规则可能大不相同。比如一个有前端页面和后端 API 的应用支持基于 cookie 的认证将用户重定向到登录界面,同时也支持基于 token 的认证,认证失败将返回 401 响应码。每组资源有它自己的WebSecurityConfigurerAdapter,并且有这唯一的顺序和他自己的请求匹配规则。如果匹配规则重叠,则匹配顺序最前的过滤链。

请求匹配分发和授权

一条安全过滤链(等价的 ,就是一个WebSecurityConfigurerAdapter)拥有一个请求匹配规则用来匹配 HTTP 请求。一旦有应用了一条过滤链,则其他过滤链就不会使用。但在一条过滤链中,你可以通过HttpSecurity更细的粒度上配置匹配规则。比如:

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@Order(SecurityProperties.BASIC_AUTH_ORDER - 10)
public class ApplicationConfigurerAdapter extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/foo/**")
.authorizeRequests()
.antMatchers("/foo/bar").hasRole("BAR")
.antMatchers("/foo/spam").hasRole("SPAM")
.anyRequest().isAuthenticated();
}
}

配置 Spring Security 最容易犯的一个错误就是忘记匹配规则可以应用在不同的范围中,一个是整条过滤链,另一个是应用于过滤链匹配规则中的规则。

将应用安全规则与Actuator 规则结合

Method 安全

Spring Security 在支持 web 安全的同时,也提供了对 Java 方法执行的访问规则。对于 Spring Security 来说,方法只是一种不同类型的“资源”而已。对用户来说,访问规则在ConfigAttribute中有相同的格式(比如 角色 或者 表达式),但在代码中有不同的配置。第一步就是启用方法安全,比如你可以在应用的启动类上进行配置:

1
2
3
4
@SpringBootApplication
@EnableGlobalMethodSecurity(securedEnabled = true)
public class SampleSecureApplication {
}

之后,便可以在方法上直接使用注解:

1
2
3
4
5
6
7
8
9
@Service
public class MyService {

@Secured("ROLE_USER")
public String secure() {
return "Hello Security";
}

}

这个例子是一个有安全方法的服务。如果 Spring 创建了MyService Bean,那么它将被代理,调用者必须在方法调用之前通过一个安全拦截器。如果访问被拒绝,调用者会抛出一个AccessDeniedException而不是执行这个方法的结果。

还有其他可用于强制执行安全约束的方法注解,特别是@PreAuthorize@PostAuthorize, 它们允许你在其中写 SpEL 表达式并可以引用方法的参数和返回值。

提示:把 web 安全和方法安全放在一起并不突兀。过滤链提供了用户体验特性,比如认证和重定向到登录界面。而方法安全在更细粒度级别上提供了保护。

Spring Security 和线程

Spring Security是线程绑定的,因为它需要保证当前的已认证的用户(authenticated principal)对下流的消费者可用。基本构建块是SecurityContext,它可能包含Authentication(当一个用户登陆后,authenticated肯定是 true)。你总是可以从SecurityContextHolder中的静态方法得到SecurityContext,它内部使用了ThreadLocal进行管理。

1
2
3
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
assert(authentication.isAuthenticated);

这种操作并不常见,但是它可能对你有帮助。比如,你需要写一个自定义的认证过滤器(尽管如此,Spring Security 中还有一些基类可用于避免使用SecurityContextHolder的地方)。

如果需要访问 web endpoint(译者注:对应响应的 URL) 中经过身份验证的用户,则可以在@RequestMapping中使用方法参数注解。例如:

1
2
3
4
@RequestMapping("/foo")
public String foo(@AuthenticationPrincipal User user) {
... // do stuff with user
}

这个注解相当于从SecurityContext中获得当前Authentication,并调用getPrincipal()方法赋值给方法参数。Authentication中的Principal取决与用来认证的AuthenticationManager,所以这对于获得对用户数据类型的安全引用来说是一个有用的小技巧。

如果使用了 Spring Security,那么在HttpServletRequest中的Principal将是Authentication类型,因此你也可以直接使用它:

1
2
3
4
5
6
@RequestMapping("/foo")
public String foo(Principal principal) {
Authentication authentication = (Authentication) principal;
User = (User) authentication.getPrincipal();
... // do stuff with user
}

如果你需要编写在没有使用 Spring Security 的情况下的代码,那么这会很有用(你需要在加载Authentication类时更加谨慎)。

异步执行安全方法

因为SecurityContext是线程绑定的,所以如果你想在后台执行安全方法,比如使用@Async,你需要确保上下文的传递。这总结起来就是将SecurityContextRunnableCallable等包裹起来在后台执行。Spring Security 提供了一些帮助使之变得简单,比如RunnableCallable的包装器。 要将 SecurityContext 传递到@Async注解的方法,你需要编写 AsyncConfigurer 并确保 Executor 的正确性:

1
2
3
4
5
6
7
8
9
@Configuration
public class ApplicationConfiguration extends AsyncConfigurerSupport {

@Override
public Executor getAsyncExecutor() {
return new DelegatingSecurityContextExecutorService(Executors.newFixedThreadPool(5));
}

}