写在前面

在前面我们对SpringSecurity中的登录流程进行了较为详细的分析,但是采用的都是系统默认的认证逻辑,这种方式在学习中尚能使用,但是在实际工作中一般都会自定义登录逻辑。笔者结合自己实际工作中的一些应用来介绍一种比较常用的自定义认证逻辑。

知识回顾

前面我们在《添加登录验证码》和《前后端分离JSON格式登录实现》两篇文章中,通过自定义过滤器,并在过滤器中实现了相应的逻辑,这些也是自定义认证逻辑的范畴,只不过是最为基础罢了,但是它们都存在一些问题,如下面的例子所述的那样。

假设现在有一个系统,我们给它添加了一个验证码,同时为了校验验证码,需要自定义一个过滤器,并将该过滤器放入SpringSecurity的过滤器链中,之后每次请求都会通过该过滤器。这样的逻辑看似没有问题,但是你仔细想就会发现,我们仅仅需要登录的请求经过该过滤器,其他请求是无需经过的,因此如果你对性能有较为严苛的要求,那么就有必要对上述逻辑进行修改。

认证流程分析

首先阅读《详解登录流程》一文,之后再来阅读本部分内容会容易很多。

通过查阅ProviderManager#authenticate()方法中的源码可以知道,AuthenticationProvider接口定义了SpringSecurity中的验证逻辑,查看一下该接口的源码:

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

boolean supports(Class<?> var1);
}

从中可以知道它提供了两个方法,其中authenticate方法用于验证用户身份,supports方法用于判断当前AuthenticationProvider对象是否支持对应的Authentication。Authentication非常重要,里面包含了当前登录用户的信息,查看一下它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean var1) throws IllegalArgumentException;
}

可以发现它是一个接口,其中有6个方法:
(1)getAuthorities()方法用来获取用户的权限;
(2)getCredentials()方法用来获取用户的凭证,通常这里的凭证就是用户密码;
(3)getDetails()方法用来获取用户携带的详细信息;
(4)getPrincipal()方法用来获取当前用户,注意它可能是用户名,也可能是用户对象本身;
(5)isAuthenticated()方法用来判断当前用户是否认证成功;
(6)setAuthenticated()方法用来设置当前用户是否认证。

Authentication接口包含了一些获取用户信息的基本方法,它有很多实现类,如下所示:

在上述实现类中,我们用的较多的就是UsernamePasswordAuthenticationToken类,该类用于处理用户名和密码相关的认证逻辑。 请注意每一个Authentication都有与之匹配的AuthenticationProvider去处理校验逻辑,这也是AuthenticationProvider 接口提供supports()方法的原因。

那么问题来了,我怎么知道哪个Authentication与哪个AuthenticationProvider相匹配呢?可以查看这个AuthenticationProvider接口的子类信息,如下所示:

根据它的子类名称就能猜出相应的匹配关系,如上面的UsernamePasswordAuthenticationToken类所对应的AuthenticationProvider就是DaoAuthenticationProvider类。

通过《详解登录流程》一文的学习,我们知道在一次完整的认证中,可能包含多个AuthenticationProvider,而这些AuthenticationProvider都由ProviderManager来进行统一管理。

接下来重点研究之前提到过的DaoAuthenticationProvider,当我们需要使用用户名+密码这一方式进行登录时就会使用到它。DaoAuthenticationProvider类的父类为AbstractUserDetailsAuthenticationProvider类,AbstractUserDetailsAuthenticationProvider类实现了前面所说的AuthenticationProvider接口,且对该接口中的authenticatesupports这两个方法提供了具体的实现,因此我们来看AbstractUserDetailsAuthenticationProvider类中那两个实现的方法:

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
49
50
51
52
53
54
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
});
String username = this.determineUsername(authentication);
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;

try {
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
} catch (UsernameNotFoundException var6) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw var6;
}

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);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
} catch (AuthenticationException var7) {
if (!cacheWasUsed) {
throw var7;
}

cacheWasUsed = false;
user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
this.preAuthenticationChecks.check(user);
this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
}

this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) {
this.userCache.putUserInCache(user);
}

Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}

return this.createSuccessAuthentication(principalToReturn, authentication, user);
}

public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
authenticate方法分析

前面多次提到authenticate方法用于用户认证相关的逻辑,接下来分析一下该方法的执行流程:
(1)判断当前Authentication对象是否是UsernamePasswordAuthenticationToken类的实例,如果是,那么就能使用当前的AbstractUserDetailsAuthenticationProvider
(2)调用determineUsername()方法从登录信息中获取用户名。查看一下这个determineUsername()方法的源码:

1
2
3
private String determineUsername(Authentication authentication) {
return authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
}

这个方法的逻辑非常清晰,就是从Authentication中获取用户身份,这里就是用户名。
(3)从缓存中通过用户名来获取用户信息,如果获取不到用户信息则说明缓存未启用;反之得到用户信息;
(4)调用retrieveUser方法,将得到的用户名和之前的UsernamePasswordAuthenticationToken对象作为参数传入,查看一下这个retrieveUser方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
this.prepareTimingAttackProtection();

try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
} else {
return loadedUser;
}
} catch (UsernameNotFoundException var4) {
this.mitigateAgainstTimingAttack(authentication);
throw var4;
} catch (InternalAuthenticationServiceException var5) {
throw var5;
} catch (Exception var6) {
throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
}
}

可以看到它首先调用getUserDetailsService()方法得到一个UserDetailsService对象,之后调用它的loadUserByUsername()方法来获取UserDetails对象,而这个就是之前我们自定义UserDetailService类时所定义的loadUserByUsername()方法。
(5)调用preAuthenticationChecks.check(user)方法来检查User对象的各个属性是否正常。查看一下这个check方法,发现它存在于UserDetailsChecker接口中:

1
2
3
public interface UserDetailsChecker {
void check(UserDetails var1);
}

为了弄清楚这个check方法的执行逻辑,这里需要查看UserDetailsChecker接口的实现类AccountStatusUserDetailsChecker,在这个实现类中就有check方法的具体实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
this.logger.debug("Failed to authenticate since user account is locked");
throw new LockedException(this.messages.getMessage("AccountStatusUserDetailsChecker.locked", "User account is locked"));
} else if (!user.isEnabled()) {
this.logger.debug("Failed to authenticate since user account is disabled");
throw new DisabledException(this.messages.getMessage("AccountStatusUserDetailsChecker.disabled", "User is disabled"));
} else if (!user.isAccountNonExpired()) {
this.logger.debug("Failed to authenticate since user account is expired");
throw new AccountExpiredException(this.messages.getMessage("AccountStatusUserDetailsChecker.expired", "User account has expired"));
} else if (!user.isCredentialsNonExpired()) {
this.logger.debug("Failed to authenticate since user account credentials have expired");
throw new CredentialsExpiredException(this.messages.getMessage("AccountStatusUserDetailsChecker.credentialsExpired", "User credentials have expired"));
}
}

可以看到这里主要对用户的账户是否未锁住、是否可用、是否未过期、密码是否过期等进行判断,满足其中任意一个都会抛出异常。
(6)接着调用additionalAuthenticationChecks()方法来对用户密码进行判断,查看这个方法的源码,如下所示:

1
protected abstract void additionalAuthenticationChecks(UserDetails var1, UsernamePasswordAuthenticationToken var2) throws AuthenticationException;

这是一个抽象方法,它的具体实现是AbstractUserDetailsAuthenticationProvider类的子类。其实非常好理解,因为AbstractUserDetailsAuthenticationProvider是一个较为通用的父类,用于处理一些通用的逻辑。但是请注意,并不是任何时候的登录都需要输入用户密码,因此这里的additionalAuthenticationChecks()方法最好就是抽象方法,具体的实例逻辑交由AbstractUserDetailsAuthenticationProvider类的子类来实现。

对于此处而言就是DaoAuthenticationProvider这个类,可以发现这个类中的additionalAuthenticationChecks方法就提供了具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}

在这个方法中首先会判断用户密码是否为空,如果为空则抛出异常;不为空则得到当前登录用户的密码,之后调用passwordEncoder.matches()方法将之前获取到的密码与数据库中存储的密码是否一致,不一致则抛出异常。以BCryptPasswordEncoder类中的matches方法为例,查看一下密码的匹配逻辑,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
} else if (encodedPassword != null && encodedPassword.length() != 0) {
if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
this.logger.warn("Encoded password does not look like BCrypt");
return false;
} else {
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
} else {
this.logger.warn("Empty encoded password");
return false;
}
}

其中rawPassword是获取到的用户密码,而encodedPassword则是数据库中查询到的密码,密码匹配逻辑其实较为简单。
(7)回到AbstractUserDetailsAuthenticationProvider类的authenticate方法中,密码验证通过后,接下来调用postAuthenticationChecks.check(user)方法来检查密码是否过期。
(8)之后判断forcePrincipalAsString属性是否为true,该属性是强制将Principal(用户名)转换为String(字符串)对象。其实在UsernamePasswordAuthenticationFilter类中就已经将principal(用户名)设置为字符串,但是在默认情况下,当用户登录成功后,这个属性的值就成了当前用户,也就是UserDetails对象。需要说明的是,这个forcePrincipalAsString属性默认值为false,其实开发者不需要管这个参数,因为没转成字符串,反而更利于后续获取用户信息:

1
2
3
4
5
Object principalToReturn = user;
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
return this.createSuccessAuthentication(principalToReturn, authentication, user);

(9)最后调用createSuccessAuthentication()方法来构造一个Authentication对象,准确来说是UsernamePasswordAuthenticationToken对象:

1
2
3
4
5
6
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal, authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
this.logger.debug("Authenticated user");
return result;
}
supports方法分析

接下来再来看那个supports方法的作用,这里再次贴一下该方法的源码:

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

可以看到该方法的逻辑非常简单,就是判断当前Authentication对象是否是UsernamePasswordAuthenticationToken对象。

additionalAuthenticationChecks方法分析

由于AbstractUserDetailsAuthenticationProvider抽象类已经实现了authenticate和supports方法,因此在AbstractUserDetailsAuthenticationProvider的子类DaoAuthenticationProvider中,我们着重关注additionalAuthenticationChecks这个方法,而这个方法在前面我们就对它进行了分析,这里再次贴上它的源码:

1
2
3
4
5
6
7
8
9
10
11
12
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
}

在这个方法中首先会判断用户密码是否为空,如果为空则抛出异常;不为空则得到当前登录用户的密码,之后调用passwordEncoder.matches()方法将之前获取到的密码与数据库中存储的密码是否一致,不一致则抛出异常。

从前面《详解登录流程》一文中可以知道,AuthenticationProvider都是通过ProviderManager#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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
Iterator var9 = this.getProviders().iterator();

while(var9.hasNext()) {
AuthenticationProvider provider = (AuthenticationProvider)var9.next();
if (provider.supports(toTest)) {
if (logger.isTraceEnabled()) {
Log var10000 = logger;
String var10002 = provider.getClass().getSimpleName();
++currentPosition;
var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
}

try {
result = provider.authenticate(authentication);
if (result != null) {
this.copyDetails(authentication, result);
break;
}
} catch (InternalAuthenticationServiceException | AccountStatusException var14) {
this.prepareException(var14, authentication);
throw var14;
} catch (AuthenticationException var15) {
lastException = var15;
}
}
}

if (result == null && this.parent != null) {
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
} catch (ProviderNotFoundException var12) {
;
} catch (AuthenticationException var13) {
parentException = var13;
lastException = var13;
}
}

if (result != null) {
if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) {
((CredentialsContainer)result).eraseCredentials();
}

if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}

return result;
} else {
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]{toTest.getName()}, "No AuthenticationProvider found for {0}"));
}

if (parentException == null) {
this.prepareException((AuthenticationException)lastException, authentication);
}

throw lastException;
}
}

几乎所有关于认证的逻辑都在这个方法中,详细的流程分析这里就不再赘述,可以知道这个方法里面会遍历所有的AuthenticationProvider对象,并调用它的authenticate()方法来进行用户认证。

自定义认证

通过上面的知识回顾,我们已经知道之前通过自定义过滤器,然后将过滤器添加到SpringSecurity过滤器链中,进而实现添加验证码这一功能,这种方式其实是有问题的,它破坏了原有的过滤器链,使得一些非登录请求也需要经过上述配置的验证码过滤器,无形中降低了系统的性能。

因此我们需要对上述方式进行改进,改进的思路也很简单,不过在此之前需要捋一下登录请求的思路,将上面分析的几个方法给串起来。首先调用attemptAuthentication方法来进行尝试验证,之后调用AbstractUserDetailsAuthenticationProvider#authenticate方法来进行登录认证,在这个认证方法中又会调用DaoAuthenticationProvider#additionalAuthenticationChecks方法来校验用户登录密码。因此,我们的思路就是自定义一个AuthenticationProvider类并重写其中的additionalAuthenticationChecks方法,进而替换此处用于密码校验的DaoAuthenticationProvider#additionalAuthenticationChecks方法。

这样做的好处就是既能实现自定义功能,又能保持原有过滤器链的完整性。常见的手机号码动态登录也可以使用这种方式来认证,这一部分内容将在下一篇中介绍。

代码实现

此处同样是实现验证码登录,但是就不再自己定义生成验证码的逻辑了,而是直接使用网上现成的kaptcha,这个项目是Google项目的复制品,可以直接使用,非常方便。

创建工程

使用IDEA创建一个名为customize-kaptcha的SpringBoot工程:

当然也可以在创建项目的时候不添加任何依赖,而是在后续pom.xml依赖文件中添加如下依赖:

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
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
</dependencies>
提供一个验证码实例

新建config包,并在该包内新建一个VerifyCodeConfig类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
public class VerifyCodeConfig {
@Bean
DefaultKaptcha verifyCode(){
Properties properties = new Properties();
//定义生成验证码图片的宽度
properties.setProperty("kaptcha.image.width","150");
//定义生成验证码图片的高度
properties.setProperty("kaptcha.image.height","50");
//定义生成验证码中字符的取值范围
properties.setProperty("kaptcha.textproducer.char.string", "0123456789");
//定义验证码中字符的个数,此处为4个
properties.setProperty("kaptcha.textproducer.char.length", "4");

Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}

这个类中定义了一个verifyCode()方法,用于返回一个DefaultKaptcha对象,里面设置了验证码的宽度、高度、字符取值范围、字符个数等信息。

提供一个返回验证码接口

新建controller包,并在该包内新建一个VerifyCodeController类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
public class VerifyCodeController {
@Autowired
private DefaultKaptcha defaultKaptcha;

@GetMapping(value = "/vercode.jpg")
public void getVerifyCode(HttpServletResponse response, HttpSession session){
response.setContentType("image/jpeg");
String text = defaultKaptcha.createText();
session.setAttribute("verify_code",text);
BufferedImage image = defaultKaptcha.createImage(text);
try {
ServletOutputStream out = response.getOutputStream();
ImageIO.write(image,"jpg",out);
} catch (IOException e) {
e.printStackTrace();
}
}
}

请注意上面setContentType()方法中的image/jpeg为固定格式,不能将其修改为image/jpg。上面代码的含义非常简单,就是将生成的验证码中的字符串添加到session中,并将生成的验证码以图片形式展示在页面上。

提供一个AuthenticationProvider类

新建provider包,并在该包内新建一个MyAuthenticationProvider类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class MyAuthenticationProvider extends DaoAuthenticationProvider {
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
//获取当前请求
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//获取当前响应
//HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();

//获取用户通过表单输入的验证码字符串
String formCaptcha =request.getParameter("code");
//获取生成的验证码字符串(从session中获取)
String genCaptcha = (String) request.getSession().getAttribute("verify_code");

if(formCaptcha ==null || genCaptcha ==null || !formCaptcha.equals(genCaptcha)){
throw new AuthenticationServiceException("验证码错误!");
}
super.additionalAuthenticationChecks(userDetails, authentication);
}
}

可以看到这个自定义的AuthenticationProvider类需要继承DaoAuthenticationProvider类,并重写其中的additionalAuthenticationChecks()方法。该方法的逻辑分析如下:
(1)首先从上下文中获取当前请求Request对象,注意这种方式在Spring Web中很常见,下面也给出获取Response对象的代码:

1
2
3
4
//获取当前请求
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//获取当前响应
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();

(2)接着获取用户通过表单输入的验证码字符串,使用的方法就是request.getParameter()方式;
(3)从session中获取生成的验证码字符串,之后判断(2)和(3)中的对象是否相等,如果不等则直接抛出异常;
(4)最后调用父类的additionalAuthenticationChecks()方法,其实就是DaoAuthenticationProvider类的方法,用于进行密码的校验逻辑。

前面多次提到,验证码的验证工作是在用户名+密码验证之前进行的,因此这里就是先进行验证码验证,后进行用户名+密码校验。

提供一个响应类对象

新建bean包,并在该包内新建一个ResponseBean类,其中的代码如下所示:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public class ResponseBean {
private Integer status;
private String message;
private Object object;

private ResponseBean() {
}

private ResponseBean(Integer status, String message, Object object) {
this.status = status;
this.message = message;
this.object = object;
}

public static ResponseBean build(){
return new ResponseBean();
}

//成功,只有信息
public static ResponseBean ok(String message){
return new ResponseBean(200,message,null);
}

//成功,有信息和数据
public static ResponseBean ok(String message,Object object){
return new ResponseBean(200,message,object);
}

//失败,只有信息
public static ResponseBean error(String message){
return new ResponseBean(500,message,null);
}

//失败,有信息和数据
public static ResponseBean error(String message,Object object){
return new ResponseBean(500,message,object);
}

public Integer getStatus() {
return status;
}

public ResponseBean setStatus(Integer status) {
this.status = status;
return this;
}

public String getMessage() {
return message;
}

public ResponseBean setMessage(String message) {
this.message = message;
return this;
}

public Object getObject() {
return object;
}

public ResponseBean setObject(Object object) {
this.object = object;
return this;
}
}

可以看到这个类中定义了3个属性,然后提供ok和error的方法,注意这两个方法都存在多个重写方法,以适应不同的需求。

自定义SecurityConfig类

在前面我们自定义了MyAuthenticationProvider类,接下就是配置如何让自定义的MyAuthenticationProvider类来代替默认的DaoAuthenticationProvider类。

由于所有的AuthenticationProvider都是放在ProviderManager中进行管理,因此就需要开发者自己提供ProviderManager,然后将此处自定义的MyAuthenticationProvider注入其中。

在config包内新建一个SecurityConfig类,注意它需要继承WebSecurityConfigurerAdapter类,里面的代码如下所示:

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
49
50
51
52
53
54
55
56
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}

@Bean
MyAuthenticationProvider myAuthenticationProvider() {
MyAuthenticationProvider myAuthenticationProvider = new MyAuthenticationProvider();
myAuthenticationProvider.setPasswordEncoder(passwordEncoder());
myAuthenticationProvider.setUserDetailsService(userDetailsService());
return myAuthenticationProvider;
}

@Override
@Bean
protected AuthenticationManager authenticationManager() throws Exception {
ProviderManager manager = new ProviderManager(Arrays.asList(myAuthenticationProvider()));
return manager;
}

@Bean
@Override
protected UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
manager.createUser(User.withUsername("envy").password("1234").roles("admin").build());
return manager;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/vercode.jpg").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.successHandler((request, response, authentication) -> {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(ResponseBean.ok("success", authentication.getPrincipal())));
out.flush();
out.close();
})
.failureHandler((request, response, exception) -> {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
out.write(new ObjectMapper().writeValueAsString(ResponseBean.error(exception.getMessage())));
out.flush();
out.close();
})
.permitAll()
.and()
.csrf().disable();
}
}

接下来分析一下上述代码的含义:
(1)定义一个passwordEncoder方法,该方法用于返回一个不对密码进行加密的PasswordEncoder对象;
(2)定义一个myAuthenticationProvider方法,该方法用于返回一个MyAuthenticationProvider对象,这个对象设置了密码编码属性和用户详细信息。
(3)定义一个authenticationManager方法,该方法用于返回一个AuthenticationManager对象,请注意这个方法里面先构建一个ProviderManager对象,然后将之前自定义的MyAuthenticationProvider注入其中。
(4)定义一个userDetailsService方法,该方法用于返回一个UserDetailsService对象。请注意这里为了简单起见,我直接将用户存在了内存中,当然如果开发者想将其存在数据库中,可以参考之前的《Spring Data JPA操作数据库》一文。
(5)configure(HttpSecurity http)方法就是对资源进行控制,注意这个/vercode.jpg接口需要放开,任何人都可以访问。之后定义登录成功处理器以及失败处理器并显示对应的信息。

通过以上内容的配置,我们就能在不修改原来过滤器链的的情况下,又将自己自定义的验证码逻辑添加进去。

项目测试

接下来启动项目,开始进行测试。打开Postman,开始进行登录,首先输入正确的用户名和密码,但是输入错误的验证码,之后点击登录,可以发现现实“验证码错误”:

接下来直接访问/vercode.jpg接口,可以看到验证码图片显示正常:

之后回到登录接口,这次输入正确的用户名+密码+验证码,可以看到用户登录成功了:

如果用户输入正确的用户名和验证码,但是输入错误的密码时,页面会显示如下信息:

ok,那么本篇关于自定义认证逻辑的学习就到此为止,后续学习其他内容。