Spring Security 5.1.4 RELEASE
2 核心服务
书接上文,上一篇介绍了AuthenticationManager
, ProviderManager
, AuthenticationProvider
,并梳理了一下他们的作用以及关系。另外还介绍了Spring Security提供的一些AuthenticationProvider
的实现。其中,在provider中用到了UserDetailsService
来查询用户的具体信息,包括密码、分配的权限信息等,查询到的信息又会被包装成UserDetails
返回。接下来我们就具体来看一下这两个接口。
2.2 UserDetailsService
大部分的provider实现都会用到UserDetailsService
及UserDetails
,即使认证方式并不依赖用户名密码,但仍会用到UserDetails
中包含的用户权限信息GrantedAuthority
。
UserDetailsService
这个接口中,只声明了一个方法loadUserByUsername
,通过用户名查找用户信息。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
Spring Security提供了该接口的一些基本实现
In-Memory Authentication
一般来说,用户信息都会从数据库中加载,但如果是在开发过程中使用,或者只是需要开发一个原型产品,开发者并不希望为此创建一个用户信息的数据库,便可采用这种In-Memory的方式来加载用户信息。
xml配置
一种配置的方式是使用安全命名空间中的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"/>
在properties中,每一条用户信息的记录应当遵循以下格式:
username=password,grantedAuthority[,grantedAuthority][,enabled|disabled]
代码配置
相比于xml配置,个人更喜欢使用代码进行配置,这时候我们会用到的一个实现类便是InMemoryAuthenticationManager
,这个类实现了接口UserDetailsManager
,而这个接口继承了接口UserDetailsService
。
由于InMemoryAuthenticationManager
源码还是很清楚的,便不再看源码,我们直接看一下如何用代码进行配置使用。
@EnableWebSecurity
public class WebSecurityConfig implements WebMvcConfigurer {
@Bean
public UserDetailsService userDetailsService() throws Exception {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build());
return manager;
}
}
这里配置了一条用户信息,用户名、密码、角色分别为user, password, USER。
JdbcDaoImpl
这个实现是从数据库来获取用户的信息,内部使用了Spring JDBC来进行数据库读写,如果开发者使用了ORM,并且想复用自己写的映射关系的话,就需要自己实现一个定制的UserDetailsService
了。
xml配置
<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>
代码配置
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
// 数据库的连接信息,写在了application.yml配置文件里,当然也可以用代码配置。
// 这里通过依赖注入,使用注解@Autowired将dataSource的对象实例注入到配置类中
DataSource dataSource;
@Bean
@Override
// 用dataSource配置一个JdbcDaoImpl作为UserDetailsService的实现
protected UserDetailsService userDetailsService() {
JdbcDaoImpl jdbcService = new JdbcDaoImpl();
jdbcService.setDataSource(dataSource);
return jdbcService;
}
@Override
// 将UserDetailsService传入AuthenticationManager,并设置好PasswordEncoder
// 从安全的角度出发,用户的密码在数据库中是hash后存储的,因此需要一个PasswordEncoder,在认证时候将用户提交的密码hash后再与数据库中的密码进行比较。
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
}
@Bean
// 定义了一个要使用的PassordEncoder用来处理明文密码
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
2.3 UserDetails
UserDetails
该接口的实现用于保存用户的基本信息,但是并不直接用于认证,接口中提供了获取账户相关信息的方法。
package org.springframework.security.core.userdetails;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import java.io.Serializable;
import java.util.Collection;
public interface UserDetails extends Serializable {
// 返回用户的权限信息
Collection<? extends GrantedAuthority> getAuthorities();
// 返回用户密码
String getPassword();
// 返回用户名
String getUsername();
// 返回账号是否过期
boolean isAccountNonExpired();
// 返回账号是否被锁定
boolean isAccountNonLocked();
// 返回用户的密码是否过期
boolean isCredentialsNonExpired();
// 返回账号是否可用
boolean isEnabled();
}
在Spring Security提供的UserDetailsService
实现中使用的UserDetails
的实现是一个User
类,下方源码中可以看到,USER
中设置了最基本的一些用户的属性,分别对应了UserDetails
中提供的方法。让然,用户在实现自己的UserDetails
接口时,也可以根据自己的需求,添加更多的信息,比如手机号、邮箱等。
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private static final Log logger = LogFactory.getLog(User.class);
// 类成员变量
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
// 类方法省略
// ...
2.4 PasswordEncoder
PasswordEncoder
接口的实现主要用于对用户的密码进行一次“单向变形”后更安全的进行存储。在用户认证时候,在比较用户输入的明文密码和数据库存储的密码时候,也会用到PasswordEncoder
。
单向变形( one way transformation )是官方文档给出的说法,一般在实际开发中采用的实践是,对用户的密码进行加盐哈希后保存在数据库,当然开发者在实现自己的PasswordEncoder
时候,也可以设计自己的算法。
DelegatingPasswordEncoder
在Spring Security 5.0 之前,默认使用的PasswordEncoder
是NoOpPasswordEncoder
,其使用的是明文密码,即不对密码做任何处理。如果现在想要使用更安全的密码处理,有更新更安全的BCryptPasswordEncoder
可供使用,然而虽然安全性提高了,在实际应用中,依然会面临以下问题:
- 有很多老系统使用比较老的密码处理方法,要迁移到新的方法比较困难。
- 还是会不断有更新更安全的密码处理方法出现。
- Spring Security框架也不能频繁做大更新。
为了解决以上的问题,Spring Security引入了一种委派机制DelegatingPasswordEncoder
,能够提供一下功能:
- 确保能够使用目前推荐的密码处理方法进行密码存储
- 支持验证现代或传统的密码格式
- 允许在将来升级新的密码处理方式
既然这个实现在官方文档中拿了出来讲,那我们不妨也来看一下这个实现的源码。
package org.springframework.security.crypto.password;
import java.util.HashMap;
import java.util.Map;
public class DelegatingPasswordEncoder implements PasswordEncoder {
/*
* 为了同时支持使用不同的PasswordEncoder,处理过后的密码保存格式如:
* {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
* {}表示使用哪种PasswordEncoder的一个id,后面为单向加密过后的密码。
* 比如当进行密码匹配时,识别到{bcrypt}后,就会把任务委派给BCryptPasswordEncoder,对用户输入的密码处理后与保存的密码进行比较。
*/
private static final String PREFIX = "{";
private static final String SUFFIX = "}";
private final String idForEncode;
private final PasswordEncoder passwordEncoderForEncode;
private final Map<String, PasswordEncoder> idToPasswordEncoder;
private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
/**
* 生成一个DelegatingPasswordEncoder实例
* 构造函数中传入的idForEncode确定了当前使用了PasswordEncoder类型,之后的encode函数也会用使用该PasswordEncoder
* 传入的idToPasswordEncoder是一个字典,包含了所有支持的PasswordEncoder
*/
public DelegatingPasswordEncoder(String idForEncode,
Map<String, PasswordEncoder> idToPasswordEncoder) {
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for (String id : idToPasswordEncoder.keySet()) {
if (id == null) {
continue;
}
if (id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if (id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}
/**
* 设置一个默认使用的PasswordEncoder,如果id无法匹配到一个可用的PasswordEncoder,则使用默认的。
*/
public void setDefaultPasswordEncoderForMatches(
PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
}
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}
/**
* 将密码单向加密为 {idForEncode}encodedPassword 的形式返回。
*/
@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}
/**
* 将明文密码与加密后的密码比较
* 当prefixEncodedPassword中的id无法找到对应的PasswordEncode则会使用默认的defaultPasswordEncoderForMatches来处理
* 在这里,默认的defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder(),会抛出异常提示“不支持的encode方式”
*/
@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
}
String id = extractId(prefixEncodedPassword);
PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches
.matches(rawPassword, prefixEncodedPassword);
}
String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}
// 从{idForEncode}encodedPassword中提取出idForEncode
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
}
int start = prefixEncodedPassword.indexOf(PREFIX);
if (start != 0) {
return null;
}
int end = prefixEncodedPassword.indexOf(SUFFIX, start);
if (end < 0) {
return null;
}
return prefixEncodedPassword.substring(start + 1, end);
}
// 判断当前的加密方式id是否与传入的encodedPassword的加密方式一样
@Override
public boolean upgradeEncoding(String encodedPassword) {
String id = extractId(encodedPassword);
return !this.idForEncode.equalsIgnoreCase(id);
}
// 从从{idForEncode}encodedPassword中提取出encodedPassword的部分
private String extractEncodedPassword(String prefixEncodedPassword) {
int start = prefixEncodedPassword.indexOf(SUFFIX);
return prefixEncodedPassword.substring(start + 1);
}
/**
* 默认的PasswordEncoder,当id找不到对应PasswordEncoder时,抛出异常。
*/
private class UnmappedIdPasswordEncoder implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}
@Override
public boolean matches(CharSequence rawPassword,
String prefixEncodedPassword) {
String id = extractId(prefixEncodedPassword);
throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
}
}
}