10. 核心服务

现在,我们已经对 Spring Security 体系结构及其核心类进行了高层次的概述,接下来让我们更深入地研究一两个核心接口及其实现,特别是 AuthenticationManager、UserDetailsService 和 AccessDecisionManager。这些会在本文余下的部分中定期出现,因此了解它们的配置和操作非常重要。

10.1 AuthenticationManager、ProviderManager 和 AuthenticationProvider

AuthenticationManager 只是一个接口,因此实现可以是我们选择的任何东西,但是在实践中它是如何工作的呢?如果我们需要检查多个身份验证数据库或者不同身份验证服务(如数据库和 LDAP 服务器)的组合,该怎么办?

Spring Security 中的默认实现称为 ProviderManager,它并不处理身份验证请求本身,而是委托给已配置的 AuthenticationProvider 列表,依次查询每个 AuthenticationProvider 以查看它是否可以执行身份验证。每个提供程序将抛出异常或返回完全填充的身份验证对象。还记得我们的好朋友,UserDetails 和 UserDetailsService 吗?如果没有,回头看前一章,更新你的记忆。验证身份验证请求最常见的方法是加载相应的 UserDetails,并根据用户输入的密码检查所加载的密码。这是 DaoAuthenticationProvider 使用的方法(见下文)。加载的 UserDetails 对象(尤其是它所包含的 GrantedAuthority)将在构建完全填充的 Authentication 对象时使用,该身份验证对象从成功的身份验证返回并存储在 SecurityContext 中。

如果使用命名空间,将在内部创建和维护 ProviderManager 的实例,并使用命名空间身份验证提供程序元素向其添加提供程序(参阅 命名空间章节)。在这种情况下,你不应该在应用程序上下文中声明 ProviderManager bean。但是,如果你不使用命名空间,那么你会声明它是这样的:

<bean id="authenticationManager"
		class="org.springframework.security.authentication.ProviderManager">
	<constructor-arg>
		<list>
			<ref local="daoAuthenticationProvider"/>
			<ref local="anonymousAuthenticationProvider"/>
			<ref local="ldapAuthenticationProvider"/>
		</list>
	</constructor-arg>
</bean>

在上面的例子中,我们有三个提供者。它们按照所示的顺序(使用 List 暗示)进行尝试,每个提供者都可以尝试身份验证,或者通过简单地返回 null 来跳过身份验证。如果所有实现返回 null,则 ProviderManager 将抛出 ProviderNotFoundException。如果你有兴趣了解链提供者的更多详细信息,请参阅 ProviderManager JavaDoc。

向 ProviderManager 注入诸如 web 表单登录处理过滤器之类的身份验证机制,并将调用它来处理它们的身份验证请求。你需要的提供者有时可以与身份验证机制互换,而在其它时候,它们将依赖于特定的身份验证机制。例如,DaoAuthenticationProvider 和 LdapAuthenticationProvider 与任何提交简单用户名/密码身份验证请求的机制兼容,因此可以使用基于表单的登录或 HTTP 基本身份验证。另一方面,一些身份验证机制创建只能由单一类型的 AuthenticationProvider 解释的身份验证请求对象。这方面的一个例子是 JA-SIG CAS,它使用服务凭证的概念,因此只能由 CasAuthenticationProvider 进行身份验证。你不必太担心这一点,因为如果你忘记注册合适的提供者,那么当尝试进行身份验证时,你将简单地接收 ProviderNotFoundException。

10.1.1 擦除成功身份认证的凭证

默认情况下(从 Spring Security 3.1 开始),ProviderManager 将尝试清除由成功身份验证请求返回的身份验证对象中的任何敏感凭证信息。这样可以防止口令信息被保留的时间比必要的时间长。

例如,在使用用户对象缓存时,这可能会导致在无状态应用程序中提高性能。如果 Authentication 包含对缓存中的对象(例如 UserDetails 实例)的引用,并且删除了凭证,那么就不能再根据缓存的值进行身份验证。如果使用缓存,则需要考虑这一点。一个明显的解决方案是首先在缓存实现中或在创建返回的 Authentication 对象的 AuthenticationProvider 中复制对象。或者,你可以禁用 ProviderManager 上的 eraseCredentialsAfterAuthentication 属性。有关的更多详细信息,请参阅 JavaDoc。

10.1.2 DaoAuthenticationProvider

Spring Security 实现的最简单的 AuthenticationProvider 是 DaoAuthenticationProvider,也是框架最早支持的之一。它利用 UserDetailsService(作为 DAO)来查找用户名、密码和 GrantedAuthority。它通过比较 UsernamePasswordAuthenticationToken 中提交的密码与 UserDetailsService 加载的密码来验证用户。配置提供程序非常简单:

<bean id="daoAuthenticationProvider"
	class="org.springframework.security.authentication.dao.DaoAuthenticationProvider">
<property name="userDetailsService" ref="inMemoryDaoImpl"/>
<property name="passwordEncoder" ref="passwordEncoder"/>
</bean>

PasswordEncoder 是可选的。PasswordEncoder 提供对 UserDetails 对象中呈现的密码的编码和解码,该对象从配置的 UserDetailsService 返回。这将在下面更详细地讨论。

10.2 UserDetailsService 实现

如本参考指南前面所述,大多数身份验证提供者都利用 UserDetails 和 UserDetailsService 接口。记得 UserDetailsService 的继承是一个单一的方法:

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

返回的 UserDetails 是一个提供 getter 的接口,该 getter 保证非空提供身份验证信息,如用户名、密码、授权权限以及是否启用或禁用用户帐户。大多数身份验证提供者将使用 UserDetailsService,即使用户名和密码实际上没有用作身份验证决策的一部分。它们可能仅将返回的 UserDetails 对象用于其 GrantedAuthority 信息,因为其它一些系统(如 LDAP 或 X.509 或 CAS 等)已经承担了实际验证凭证的责任。

由于 UserDetailsService 实现起来非常简单,所以用户使用自己选择的持久性策略来检索身份验证信息应该很容易。话虽如此,Spring Security 确实包含了一些有用的基本实现,我们将在下面进行研究。

10.2.1 内存中身份验证

很容易使用创建自定义 UserDetailsService 实现,它从选择的持久性引擎中提取信息,但许多应用程序不需要这样的复杂性。如果你正在构建一个原型应用程序,或者刚开始集成 Spring Security,当你不想花费时间配置数据库或编写 UserDetailsService 实现时,这一点尤其适用。对于这种情况,一个简单的选择是使用安全 命名空间中的 user-service 元素:

<user-service id="userDetailsService">
<!-- Password is prefixed with {noop} to indicate to DelegatingPasswordEncoder that
NoOpPasswordEncoder should be used. This is not safe for production, but makes reading
in samples easier. Normally passwords should be hashed using BCrypt -->
<user name="jimi" password="{noop}jimispassword" authorities="ROLE_USER, ROLE_ADMIN" />
<user name="bob" password="{noop}bobspassword" authorities="ROLE_USER" />
</user-service>

这也支持使用外部属性文件:

<user-service id="userDetailsService" properties="users.properties"/>

属性文件应该包含表单中的条目

username=password,grantedAuthority[,grantedAuthority][,enabled|disabled]

例如

jimi=jimispassword,ROLE_USER,ROLE_ADMIN,enabled
bob=bobspassword,ROLE_USER,enabled

10.2.2 JdbcDaoImpl

Spring Security 还包括可以从 JDBC 数据源获得身份验证信息的 UserDetailsService。使用内部 Spring JDBC,因此它避免了仅存储用户细节的全功能对象关系映射器(ORM)的复杂性。如果你的应用程序确实使用 ORM 工具,那么你可能更喜欢编写自定义 UserDetailsService 来重用你可能已经创建的映射文件。回到 JdbcDaoImpl,示例配置如下所示:

<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
<property name="driverClassName" value="org.hsqldb.jdbcDriver"/>
<property name="url" value="jdbc:hsqldb:hsql://localhost:9001"/>
<property name="username" value="sa"/>
<property name="password" value=""/>
</bean>

<bean id="userDetailsService"
	class="org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl">
<property name="dataSource" ref="dataSource"/>
</bean>

可以通过修改上面所示的 DriverManagerDataSource 来使用不同的关系数据库管理系统。还可以使用从 JNDI 获得的全局数据源,也可以使用任何其它 Spring 配置。

权限组

默认情况下,JdbcDaoImpl 将权限加载到单个用户,假设当局直接映射到用户(参见数据库集合附录)。另一种方法是将权限划分为组,并将组分配给用户。有些人喜欢这种方法作为一种管理用户权利的手段。有关如何启用组权限的详细信息,请参阅 JdbcDaoImpl JavaDoc。附录中也包含了组集合。

10.3 密码编码

Spring Security 的 PasswordEncoder 接口用于执行密码的单向转换,以允许安全地存储密码。由于 PasswordEncoder 是单向转换,所以当密码转换需要是双向转换时(即,存储用于对数据库进行身份验证的凭证),并不打算这样做。通常,PasswordEncoder 用于存储需要与身份验证时用户提供的密码相比较的密码。

10.3.1 密码历史

多年来,存储密码的标准机制已经发展。开始时密码以明文形式存储。密码被认为是安全的,因为数据存储的密码保存在所需的凭证中以访问密码。然而,恶意用户能够找到使用 SQL 注入等攻击获取用户名和密码的大量数据转储的方法。随着越来越多的用户凭证成为公共安全问题,意识到我们需要做更多的工作来保护用户密码。

然后鼓励开发人员在通过单向散列(例如 SHA-256)运行密码后存储密码。当用户试图进行身份验证时,将哈希密码与它们键入的密码的哈希值进行比较。这意味着系统只需要存储口令的单向散列。如果发生了攻破,则只有密码的单向散列被暴露。由于散列是单向的,而且在计算上很难猜测给定散列的密码,因此不值得努力找出系统中的每个密码。为了击败这个新系统,恶意用户决定创建被称为彩虹表的查找表。不是每次都做猜测每一个密码的工作,而是计算一次密码并将其存储在查找表中。

为了减少彩虹表的有效性,鼓励开发人员使用加盐密码。代替只使用密码作为哈希函数的输入,将为每个用户的密码生成随机字节(称为加盐)。通过生成的哈希函数运行盐和用户密码。盐将与用户密码一起存储在明文中。然后,当用户试图进行身份验证时,将散列密码与存储的盐的散列和他们键入的密码进行比较。独特的加盐意味着彩虹表不再有效,因为哈希对于每个加盐和密码组合都不同。

在现代,我们认识到密码散列(如 SHA-256)不再安全。原因是,用现代硬件,我们可以一秒钟执行数十亿的散列计算。这意味着我们可以轻松地破解每个密码。

现在鼓励开发人员利用自适应单向函数来存储密码。使用自适应单向函数验证密码是有意的资源(即 CPU、内存等)密集的。一个自适应单向函数允许配置一个工作因子,它可以随着硬件变得更好而增长。建议将工作因子调整到大约1秒以验证系统上的密码。这种折衷是让攻击者很难破解密码,但不会花费太大,这会给您自己的系统带来过多的负担。Spring Security 试图为工作因素提供一个良好的起点,但是鼓励用户为自己的系统定制工作因素,因为性能会随着系统而急剧变化。应该使用的自适应单向函数的例子包括 bcrypt、PBKDF2、scrypt 和 Argon2。

由于自适应单向函数故意占用大量资源,因此为每个请求验证用户名和密码将显著降低应用程序的性能。Spring Security(或任何其它库)都无法加速密码验证,因为通过使验证资源密集来获得安全性。鼓励用户将长期凭证(即用户名和密码)交换为短期凭证(即 session、OAuth Token 等)。短期凭证可以快速验证而不损失任何安全性。

10.3.2 DelegatingPasswordEncoder

在 Spring Security 5.0 之前,默认 PasswordEncoder 是需要纯文本密码的 NoOpPasswordEncoder。基于密码历史部分,你可能希望默认的 PasswordEncoder 现在类似于 BCryptPasswordEncoder。然而,这忽略了三个现实世界的问题:

  • 有许多应用程序使用不能轻易迁移的旧密码编码。
  • 密码存储的最佳实践将再次改变。
  • 作为框架 Spring Security 不能经常发生突破性变化

相反,Spring Security 引入了 DelegatingPasswordEncoder,它通过以下方式解决了所有问题:

  • 确保使用当前密码存储建议对密码进行编码
  • 允许在现代和传统格式中验证密码
  • 允许在未来升级编码

你可以很容易地使用 PasswordEncoderFactories 构建一个 DelegatingPasswordEncoder 的实例。

PasswordEncoder passwordEncoder =
    PasswordEncoderFactories.createDelegatingPasswordEncoder();

或者,你可以创建自己的自定义实例。例如:

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
    new DelegatingPasswordEncoder(idForEncode, encoders);

密码存储格式

密码的一般格式是:

{id}encodedPassword

这样,id 是用于查找应该使用哪个 PasswordEncoder 的标识符,encodedPassword 是所选 PasswordEncoder 的原始编码密码。id 必须位于密码的开头,以 { 开头和 } 结尾。如果找不到 id,id 将为 null。例如,下面可能是使用不同 id 编码的密码列表。所有的原始密码都是 "password"。

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 1
{noop}password 2
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 3
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=  4
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 5

1

第一个密码将具有 bcrypt 的 PasswordEncoder id 和 $2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 的 encodedPassword。当匹配时,它将委托给 BCryptPasswordEncoder

2

第二个密码有 noop 的 PasswordEncoder id 和 password 的 encodedPassword。当匹配时,它将委托给 NoOpPasswordEncoder

3

第三个密码将具有 pbkdf2 的 PasswordEncoder id 和 5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 的 encodedPassword。当匹配时,它将委托给 Pbkdf2PasswordEncoder

4

第四个密码将具有 scrypt 的 PasswordEncoder id 和 $e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc= 的 encodedPassword。当匹配时,它将委托给 SCryptPasswordEncoder

5

最后的密码将具有 将具有 sha256 的 PasswordEncoder id 和 97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0 的 encodedPassword。当匹配时,它将委托给 StandardPasswordEncoder

[Note] Note

一些用户可能担心存储格式是为潜在的黑客提供的。这不是一个问题,因为密码的存储不依赖于算法是一个秘密。此外,大多数格式很容易让攻击者在没有前缀的情况下计算出来。例如,BCrypt 密码通常以 $2a$ 开始。

密码编码

传递到构造函数的 idForEncode 决定使用哪个 PasswordEncoder 来编码密码。在上面我们构造的 DelegatingPasswordEncoder 中,这意味着对 password 进行编码的结果将被委托给 BCryptPasswordEncoder 并用 {bcrypt} 作为前缀。最终结果看起来像:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

密码匹配

匹配是基于 {id} 和 id 的映射到构造函数中提供的 PasswordEncoder 的。我们在 密码存储格式一节中的示例提供了一个如何实现这一点的工作示例。默认情况下,调用 matches(CharSequence, String) 的结果是密码和未映射的 id(包括空 id)将导致 IllegalArgumentException。这个行为可以使用 DelegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(PasswordEncoder) 定制。

通过使用 id,我们可以匹配任何密码编码,但编码密码使用最现代的密码编码。这很重要,因为与加密不同,密码散列被设计成没有简单的方法来恢复明文。由于无法恢复明文,所以很难迁移密码。虽然迁移 NoOpPasswordEncoder 对于用户来说很简单,但我们选择在默认情况下包含它,以便为入门体验简化它。

新手入门体验

如果你把一个演示或一个示例放在一起,就需要花费一些时间来散列用户的密码。有方便的机制,使这更容易,但这仍然是不打算用于生产。

User user = User.withDefaultPasswordEncoder()
  .username("user")
  .password("password")
  .roles("user")
  .build();
System.out.println(user.getPassword());
// {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

如果你正在创建多个用户,还可以重用生成器。

UserBuilder users = User.withDefaultPasswordEncoder();
User user = users
  .username("user")
  .password("password")
  .roles("USER")
  .build();
User admin = users
  .username("admin")
  .password("password")
  .roles("USER","ADMIN")
  .build();

这会对存储的密码进行哈希,但密码仍然在内存中和编译后的源代码中公开。因此,它仍然不被认为是安全的生产环境。对于生产,你应该在外部散列你的密码。

故障排除

当存储的一个密码没有 id 时,会发生以下错误,如称为密码存储格式的部分所述。

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
	at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)

解决错误的最简单方法是切换到显式地提供密码被编码的 PasswordEncoder。解决此问题的最简单方法是找出当前如何存储密码,并显式地提供正确的 PasswordEncoder。如果你正在从 Spring Security 4.2.x 迁移,则可以通过暴露 NoOpPasswordEncoder bean 来还原先前的行为。例如,如果使用 Java 配置,则可以创建一个看起来像:

[Warning] Warning

恢复到 NoOpPasswordEncoder 并不被认为是安全的。相反,你应该迁移到使用 DelegatingPasswordEncoder 来支持安全密码编码。

@Bean
public static NoOpPasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

如果使用 XML 配置,则可以用 id passwordEncoder 公开 PasswordEncoder:

<b:bean id="passwordEncoder"
        class="org.springframework.security.crypto.password.NoOpPasswordEncoder" factory-method="getInstance"/>

或者,你可以用正确的 id 将所有密码前缀,并继续使用 DelegatingPasswordEncoder。例如,如果你使用 BCrypt,你将从类似的东西迁移密码:

$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

到

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG

对于映射的完整列表,请参阅 PasswordEncoderFactories 的 JavaDoc。

10.3.3 BCryptPasswordEncoder

BCryptPasswordEncoder 实现使用广泛支持的 bcrypt 算法来对密码进行散列。为了使它更能抵抗密码破解,bcrypt 是故意放慢的。像其它的自适应单向函数一样,它应该调整大约1秒来验证系统上的密码。

// Create an encoder with strength 16
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(16);
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

10.3.4 Pbkdf2PasswordEncoder

Pbkdf2PasswordEncoder 实现使用 PBKDF2 算法来散列密码。为了使它更能抵抗密码破解,PBKDF2 是一个故意放慢的算法。像其它的自适应单向函数一样,它应该调整大约1秒来验证系统上的密码。当需要 FIPS 认证时,该算法是一个很好的选择。

// Create an encoder with all the defaults
Pbkdf2PasswordEncoder encoder = new Pbkdf2PasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

10.3.5 SCryptPasswordEncoder

SCryptPasswordEncoder 实现使用 scrypt 算法来对密码进行散列。为了战胜自定义硬件上的密码破解,scrypt 是一种故意放慢的算法,它需要大量的内存。像其它的自适应单向函数一样,它应该调整大约1秒来验证系统上的密码。

// Create an encoder with all the defaults
SCryptPasswordEncoder encoder = new SCryptPasswordEncoder();
String result = encoder.encode("myPassword");
assertTrue(encoder.matches("myPassword", result));

10.3.6 其它 PasswordEncoders

还有许多其它的 PasswordEncoder 实现,完全是为了向后兼容而存在的。它们都被贬低以表明它们不再被认为是安全的。但是,由于迁移现有的遗留系统是困难的,所以没有删除它们的计划。

10.4 Jackson 支持

Spring Security 增加了 Jackson 支持持久性 Spring Security 相关类的支持。当使用分布式会话(即会话复制、Spring Session 等)时,这可以提高序列化 Spring Security 相关类的性能。

要使用它,注册 JacksonJacksonModules.getModules(ClassLoader) 作为 Jackson 模块。

ObjectMapper mapper = new ObjectMapper();
ClassLoader loader = getClass().getClassLoader();
List<Module> modules = SecurityJackson2Modules.getModules(loader);
mapper.registerModules(modules);

// ... use ObjectMapper as normally ...
SecurityContext context = new SecurityContextImpl();
// ...
String json = mapper.writeValueAsString(context);