序言
在本系列文章(一)初识 Spring Security 中,我们不仅初步认识了 Spring Security,明晰了 Spring Security 的三个核心概念——认证、鉴权、防护,还准备研究其工作原理。
现在,就跟随本篇文章来探讨认证与授权是如何实现的吧。
认证与授权
在 Security 中,认证和授权功能是交由过滤器UsernamePasswordAuthenticationFilter来完成的。
那么,UsernamePasswordAuthenticationFilter又是如何运作的呢?
我们直接去阅读源码,最终将得到简化的认证授权流程信息:
- ①
UsernamePasswordAuthenticationFilter执行attemptAuthentication方法将提交的表单信息封装为一个UsernamePasswordAuthenticationToken对象,传递给AuthenticationManager执行authenticate认证方法 - ②
AuthenticationManager接口通过多态选择默认的实现类ProviderManager去执行authenticate方法 - ③
ProviderManager在执行该方法时会遍历AuthenticationProvider列表,通过多态选择实现类AbstractUserDetailsAuthenticationProvider和DaoAuthenticationProvider去执行认证方法 - ④
DaoAuthenticationProvider在retrieveUser方法中会从UserDetailsService的实现类的loadUserByUsername得到UserDetails对象 - ⑤ 在校验
UserDetails对象的合理性之后,创建一个认证成功的存放了权限信息的Authentication对象,用作后续的鉴权
现在你肯定看的一头雾水,因为你并不知道上面流程中的各个接口和类的作用是什么,不过没关系,下面我们将一步一步地分析它们。
UsernamePasswordAuthenticationToken
在认证流程的第 ① 步中会将表单信息封装UsernamePasswordAuthenticationToken该对象,这发生在UsernamePasswordAuthenticationFilter的attemptAuthentication方法中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
// 1.获取表单中的用户名和密码
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
// 2.封装为一个对象
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
`
这个对象是干啥的呢?我们看下源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
...
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
...
}
public abstract class AbstractAuthenticationToken implements Authentication,
CredentialsContainer {
// 权限集合
private final Collection<GrantedAuthority> authorities;
private Object details;
private boolean authenticated = false;
...
}
从源码中得知:
principal:等同于usernamecredentials:等同于passwordauthorities:继承的父类权限集合暂时为空
AuthenticationManager
构造完UsernamePasswordAuthenticationToken对象后应当做些什么?
那肯定是查看数据库是否存在与UsernamePasswordAuthenticationToken对象匹配的用户名,没有肯定就认证失败了嘛!
认证具体是交由AuthenticationManager接口的实现类ProviderManager去完成的,我们来看下源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// 认证提供者列表
private List<AuthenticationProvider> providers = Collections.emptyList();
public List<AuthenticationProvider> getProviders() {
return providers;
}
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
...
try {
// 根据不同的认证提供者进行认证
result = provider.authenticate(authentication);
// 只要有一个认证提供者认证成功,就算认证成功,跳出循环
if (result != null) {
copyDetails(authentication, result);
break;
}
...
} catch {
...
}
}
if (result != null) {
...
return result;
}
}
ProviderManager维护了一个 List,该 List 中存放了AuthenticationProvider接口不同的实现类,认证时需遍历所有的实现类进行认证,只要有一个AuthenticationProvider认证成功,就算认证成功。
一般情况下,是由AuthenticationProvider接口的实现类DaoAuthenticationProvider根据用户名从数据源中获取用户信息对象的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
// 从数据源中根据用户名获取 UserDetails 对象
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
} catch (){
...
}
}
获取到UserDetails对象后,就是密码的比对了,由于注册时密码应当会以特定算法加密密码用密文进行保存,所以解密时也需要先通过对应的加密算法加密为密文,与获取的密文进行匹配,匹配成功,则认证成功。
以上过程由additionalAuthenticationChecks方法完成:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
// 表单未输密码抛异常
throw new BadCredentialsException(...);
}
String presentedPassword = authentication.getCredentials().toString();
// 将表单密码加密后与数据源获取的密文进行匹配
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
// 不匹配抛异常
throw new BadCredentialsException(...);
}
}
扩展——ProviderManager 为何维护了一个 AuthenticationProvider 列表?
在前面的分析过程中,我们发现ProviderManager类中维护了一个AuthenticationProvider列表,为什么需要这个列表呢?
定义AuthenticationProvider接口的目的在于认证方式可能不同,需要子类实现,默认情况下的认证方式实现类为DaoAuthenticationProvider。
emmm,认证方式,什么意思?
我们知道现在表单使用的是用户名/密码的信息,所以我们数据源查询应当是根据用户名查询出用户对象,但如果产品提了需求说再加一种认证方法,使用电话和验证码的方式进行认证,那现在的认证方式不就不符合业务需要了嘛?
此时,我们可以自定义一个AuthenticationProvider的实现类,重写业务逻辑以匹配业务场景。
因此,总结来讲就是会存在五花八门的认证方式:
- 用户名/密码
- 邮箱/密码
- 手机号/验证码
- 人脸识别
因此,若默认的用户名/密码认证方式不符合业务需要,可以随业务进行扩展。
UserDetailsService
不得不提一嘴,若没有任何配置,Spring Security 根据用户名查询用户对象的数据源是从默认的内存中读取的,这自然不符合正常场景,我们的用户信息都是保存到数据库的,那么如何进行修改以从数据库中查询用户对象呢?
从前面知道,根据用户名获取用户信息的过程是由前面的DaoAuthenticationProvider中调用的retrieveUser方法的以下方法实现:1
2// 从数据源中根据用户名获取 UserDetails 对象
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
我们从源码来看看这个UserDetailsService到底是什么:1
2
3public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
很明显,它是一个接口,默认根据用户名读取用户信息的实现类InMemoryUserDetailsManager重写了它的方法:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class InMemoryUserDetailsManager implements UserDetailsManager,
UserDetailsPasswordService {
......
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
UserDetails user = 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());
}
......
}
那么,我们现在想走数据库去查询用户信息,肯定就需要实现该接口,重写其方法,从数据库读取用户信息:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class UserServiceImpl implements UserDetailsService {
private PasswordEncoder passwordEncoder;
private UserMapper userMapper;
/**
* Spring Security 认证授权均通过 UserDetailsService 接口提供的 loadUserByUsername 方法,
* 该方法返回 UserDetails 对象,该对象包含了用户角色权限信息,之后 url 权限校验需要使用
*/
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<User> userWrapper = new QueryWrapper<>();
userWrapper.eq("username", username);
User user = userMapper.selectOne(userWrapper);
if (user == null) {
throw new UsernameNotFoundException(username);
}
Set<String> permissionSet = userMapper.selectPermissionListByUsername(username);
return new SecurityUser(user.getUsername(), user.getPassword(), permissionSet,
true, true, true, true);
}
}
定义完成以后,还需要将默认的实现类进行替换,直接注入即可:
1 |
|
UserDetails
不知道你有没有注意到一点,loadUserByUsername方法的返回值为UserDetails接口,其源码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public interface UserDetails extends Serializable {
// 权限集合
Collection<? extends GrantedAuthority> getAuthorities();
// 获取密码
String getPassword();
// 获取用户名
String getUsername();
// 帐号未过期
boolean isAccountNonExpired();
// 帐号未锁定
boolean isAccountNonLocked();
// 凭证未过期
boolean isCredentialsNonExpired();
// 帐号可用
boolean isEnabled();
}
咦,这方法不是应该从数据库查询出用户对象嘛?为什么返回这个接口呢?什么意思?
emmm,这方法准确而言,应当从数据库查询出一个包含角色和权限的用户对象,因为后续鉴权需要使用到,并且由于 Spring Security 后续鉴权都是通过UserDetails接口多态进行判断的,所以我们最好创建一个对象实现该接口,然后根据数据库信息填充该对象,不然是不符合 Security 的要求滴!
PasswordEncoder
前面说过,数据库的密码是加密后的密文形式,所以比对时需要将表单的密码进行加密,之后才能判断它们是否相等,表单密码的加密就需要用到PasswordEncoder了:1
2
3
4
5
6
7if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}