SpringSecurity5 自定义认证逻辑

回到序章

来来来,点这

流程详解

在介绍认证流程的时候,整理过一个表单登陆的大致流程如下:

  1. 终端发起登陆请求
  2. 认证过滤器 UsernamePasswordAuthenticationFilter 拦截到请求,处理认证逻辑,在这里获得登陆信息,根据获得的登陆信息创建出未认证的 Authentication( 这里对应的是 UsernamePasswordAuthenticationToken 对象 ),然后将对象交给认证管理器
  3. 认证管理器循环所有的 AuthenticationProvider,找到对应的 provider 进行认证
    3.1. 认证管理器 AuthenticationManager 维护着许多 AuthenticationProvider,这些 AuthenticationProvider 分别用来认证不同类型的请求,例如:表单、第三方 等
  4. 认证成功后,生成一个已认证的 Authentication,并将其通过 SecurityContextHolder.getContext().setAuthentication(authResult); 写到 SecurityContext

在之前实现验证码校验,是通过加一个过滤器,然后通过 http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class); 将过滤器加到 spring security 的过滤器链里。但这种有个不太好的地方,你可以挂个 debug 在这个过滤器的代码里,可以发现每触发一次请求,就会进一次这串代码。

当然这种方式是可行的,反正也就是每次请求多一个判断,符合登陆的就处理,不符合的就抛掉,功能实现了也没啥好纠结的。

不过这里主要目的还是提一下另一种,就是自定义 AuthenticationProvider

首先先看一下 AuthenticationProvider

1
2
3
4
5
6
7
8
9
10
package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

public interface AuthenticationProvider {
Authentication authenticate(Authentication var1) throws AuthenticationException;

boolean supports(Class<?> var1);
}

其中,authenticate() 方法就是拿来做认证,检验用户传进来的数据和合法性,然后返回一个 Authentication,里面装着用户信息;而 supports() 是用来判断当前的 AuthenticationProvider 能否支持对应的 Authentication

通过 IDEA 生成 Authentication 的继承和实现图如下:

Authentication的继承实现关系图

再看看 AuthenticationProvider,因为结构图有点大,可以自己去折腾着玩( 右键类,选择 Diagrams,出来的图里右键该类图,点击 Show Implementations,然后选出来就完了 ),这里以 UsernamePasswordAuthenticationToken 往上推进行介绍,首先找到 supports() 方法内使用到这个类的 Provider,有如下:

  • [abstract] AbstractUserDetailsAuthenticationProvider
  • DaoAuthenticationProvider [extends AbstractUserDetailsAuthenticationProvider]
  • [abstract] AbstractJaasAuthenticationProvider
  • DefaultJaasAuthenticationProvider extends AbstractJaasAuthenticationProvider
  • JaasAuthenticationProvider extends AbstractJaasAuthenticationProvider

因为 Jaas 认证的没提到过,这里要提的话,还得先介绍这个,以后再找一个合适的机会单独出一章。所以便以 AbstractUserDetailsAuthenticationProvider 开说。

AbstractUserDetailsAuthenticationProvider 这个其实就是拿到用户的信息,并检验密码。

顺嘴提一下认证流程篇的:一次完整的认证,会拥有多个 AuthenticationProvider,而 ProviderManager 会统一管理 AuthenticationProvider

首先看一下 DaoAuthenticationProvider 这个子类,可以看到没有重写核心的 authenticate()supports(),所以主要关注 AbstractUserDetailsAuthenticationProvider 这个类。

首先看一下 supports(),前面提到的这个方法是用来判断当前的 AuthenticationProvider 能否支持对应的 Authentication,而这里的 AuthenticationUsernamePasswordAuthenticationToken,也就是这里的 supports() 是判断当前 Provider 是否支持 UsernamePasswordAuthenticationToken。看代码没啥毛病:

1
2
3
4
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

再看一下 authenticate(),这个方法是来做认证的,代码如下:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
String username = determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return createSuccessAuthentication(principalToReturn, authentication, user);
}

这个类的处理流程直译下来:

  1. 从入参的 Authentication 里拿到 username
  2. 根据 username 从缓存中去获得用户信息
  3. 如果获得的用户信息为空,调用 retrieveUser() 方法获得用户信息,通过 this.getUserDetailsService().loadUserByUsername(username); 获得
  4. 调用 additionalAuthenticationChecks() 方法检验
  5. 调用 createSuccessAuthentication() 创建 UsernamePasswordAuthenticationToken 返回

过滤器之外的自定义认证的思路

过滤器实现验证码篇

结合上面的,思路的话就是创建一个类,然后继承 DaoAuthenticationProvider,重写 additionalAuthenticationChecks() 方法,然后将自定义的一些逻辑代码码进去就完了。

例如验证码校验,注释掉原来的 Filter,然后增加 MyAuthenticationProvider

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
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Objects;

public class MyAuthenticationProvider extends DaoAuthenticationProvider {

@Override
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
HttpServletRequest req = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

if ("POST".equalsIgnoreCase(req.getMethod())) {
String code = req.getParameter("code");
String sessionCode = req.getSession().getAttribute("code") + "";
if (code == null || "".equals(code)) {
throw new AuthenticationServiceException("验证码不能为空");
} else if (!code.equals(sessionCode)) {
throw new AuthenticationServiceException("验证码错误");
}
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}

再看看配置类 SecurityWebConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private PasswordEncoder passwordEncoder() {
DelegatingPasswordEncoder encoder = (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
encoder.setDefaultPasswordEncoderForMatches(NoOpPasswordEncoder.getInstance());
return encoder;
}

private MyAuthenticationProvider myAuthenticationProvider() {
MyAuthenticationProvider provider = new MyAuthenticationProvider();
provider.setUserDetailsService(userLoginService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}

@Override
protected AuthenticationManager authenticationManager() throws Exception {
return new ProviderManager(myAuthenticationProvider());
}