[Security] 自定义手机号/短信登录

Posted by hblolj on 2019-07-15

距离上一次更新,不知不觉已经过去了半个月了,人真的是不能放松,一放松就肆意妄为了。希望这个月内可以把 SpringSecurity 系列更新完毕吧,加油!。( 这里是将我之前在其他博客上的文章搬运到自建的博客上,所以时间上会有点差异,具体时间以代码上时间为准。)

OK,言归正传上一章我们利用 SpringSecurity 提供的一些可选配置,实现了自定义表单登录。但是在我们的日常需求中,仅仅是表单登录时满足不了的。所以这一章,我给大家带来 SpringSecurity 下自定义登录方式的示例。

首先我们选定我们的自定义登录方式,这里我们选择手机短信登录。显而易见,SpringSecurity 并没有给我们提供手机短信登录的简单配置集成方式,所以需要我们自己来进行实现。

短信认证组成分析

我们先来分析一波,手机短信登录我们可以分为两个部分:

  • 手机验证码校验
    • 手机验证码校验应该是一个复用模块,因为不光登录可能会用到,注册、绑定等很多场景也都可能用到,并且这一块和 SpringSecurity 关系不大,我们放到后面,将其专门开发成一个 Lib
  • 手机号登录
    • 通过了手机验证码校验,其实就是一个手机号登录了,按用户的手机号去数据库查询。所以我们现在主要完成第二块,手机号登录。

通过源码分析 SpringSecurity 通用认证流程

要自定义手机号登录,我们这里必须分析一下 SpringSecurity认证流程,具体源码在后面的章节我会带着大家去详细看一下。这里我们先来分析一下 SpringSecurity 的可能认证流程,我们前面的章节已经可以使用表单登录了,那么我们就以表单登录的方式来跟踪一下源码,分析出认证流程,回忆一下我们之前做了那些事:

  1. 我们指明了登录方式为 formLogin
  2. 我们通过设置配置,自定义认证路径
  3. 我们自定义了 UserDetailService 从数据库中查询用户信息
  4. 我们自定义了认证成功或失败处理器

然后我们来猜测一下可能的认证流程

  1. 用户发起认证请求,服务端从请求中取出参数

  2. 去数据库按参数进行查询,然后进行校验

  3. 最后做认证结果处理。

我们从 IDEA 点击查看 formLogin 方法

HttpSecurity 类

1
2
3
4
public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
// 发现这里 new 了一个 FormLoginConfigurer
return (FormLoginConfigurer)this.getOrApply(new FormLoginConfigurer());
}

继续点击 FormLoginConfigurer 类进行查看,发现在 FormLoginConfigurer 的构造函数中创建了一个 UsernamePasswordAuthenticationFilter,并且设置了表单登录的参数名。

FormLoginConfigurer 类

1
2
3
4
5
public FormLoginConfigurer() {
super(new UsernamePasswordAuthenticationFilter(), (String)null);
this.usernameParameter("username");
this.passwordParameter("password");
}

我们继续进入 UsernamePasswordAuthenticationFilter,我们发现这是一个过滤器,其次在它的构造函数里面指定了 /loginPost。(结合之前我们配置时说的,默认登录地址是 login + Post),我们猜测这里是设置了拦截的 UrlMethod,那么这个 Filter 应该就是认证的入口

UsernamePasswordAuthenticationFilter 类

1
2
3
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}

我们继续看 Filter 的 处理方法,可以出在这个方法里面做了三步处理:

  • 从请求中取出了表单参数
  • 将参数封装到了 UsernamePasswordAuthenticationToken
  • 使用getAuthenticationManager().authenticate(authRequest); 进行认证。
    • getAuthenticationManager 获取到的是一个 AuthenticationManager 对象,实际上是使用 AuthenticationManagerauthenticate 方法进行认证。

UsernamePasswordAuthenticationFilter 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {
String username = this.obtainUsername(request);
String password = this.obtainPassword(request);
if (username == null) {
username = "";
}

if (password == null) {
password = "";
}

username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
}

我们继续进入 authenticate 方法,发现其是一个接口方法,有很多实现类。没办法,我们只好将应用启动,进行 debug 断点跟踪。

1
2
3
public interface AuthenticationManager {
Authentication authenticate(Authentication var1) throws AuthenticationException;
}

经过断点跟踪,我们发现实际上调用的是 ProviderManagerauthenticate 方法,我们发现在该方法中,获取所有的 Providers,然后遍历,找出与封装的 Token 匹配的 Provider,调用其 authenticate 方法。

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
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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
// 获取之前封装的 Token 类型
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
Authentication result = null;
boolean debug = logger.isDebugEnabled();

// 获取所有 providers,遍历之
for (AuthenticationProvider provider : getProviders()) {
// 判断 Provider 是否支持封装的 Token
if (!provider.supports(toTest)) {
continue;
}

if (debug) {
logger.debug("Authentication attempt using "
+ provider.getClass().getName());
}

try {
// 调用 Provider 的认证方法
result = provider.authenticate(authentication);

if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw e;
}
catch (InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
}
catch (AuthenticationException e) {
lastException = e;
}
}

if (result == null && parent != null) {
// Allow the parent to try.
try {
// 认证没有获取到结果,使用 parent 进行认证
result = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException e) {
lastException = e;
}
}

if (result != null) {
// 认证完毕后,调用 Token 的方法擦除掉敏感信息(eg: password...)
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
// 认证通过,发布认证成功消息
eventPublisher.publishAuthenticationSuccess(result);
return result;
}

// Parent was null, or didn't authenticate (or throw an exception).

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

prepareException(lastException, authentication);

throw lastException;
}

我们继续追踪 Providerauthenticate 方法,进入AbstractUserDetailsAuthenticationProviderauthenticate 方法,我们重点关注一下 retrieveUser 方法。

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));

// Determine username
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();

boolean cacheWasUsed = true;
// 从缓存中尝试获取用户信息
UserDetails user = this.userCache.getUserFromCache(username);

if (user == null) {
cacheWasUsed = false;

try {
// 缓存没获取到,使用封装的 Token 尝试获取用户信息
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");

if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
else {
throw notFound;
}
}

Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}

try {
// UserDetail 支持设置账户冻结、启用等四个状态,这里是对账户状态进行校验
preAuthenticationChecks.check(user);
// 进行密码校验,之前如果使用了 passwordEncoder 对密码进行加密,那么从数据库中取出来的应该是加
//密过的密码,这里会对参数中的明文密码与数据库密码进行校验
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// 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);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}

postAuthenticationChecks.check(user);

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

Object principalToReturn = user;

if (forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
// 将获取到的用户信息封装成一个 Authentication 返回
return createSuccessAuthentication(principalToReturn, authentication, user);
}

我们发现 retrieveUser 方法是一个抽象方法,具体实现应该在子类中,继续追踪,发现实现在 DaoAuthenticationProvider 中,在 retrieveUser 方法中调用 UserDetailServiceloadUserByUsername 方法,到了这里,大概的流程就和我们上一章的配置对上了。

PS: UserDetailService 在系统中有多个实现,这里会使用哪个要看实际情况与设置,这个后面有机会说一下。

AbstractUserDetailAuthenticationProvider 类

1
2
3
protected abstract UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException;

DaoAuthenticationProcider 类

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
protected final UserDetails retrieveUser(String username,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
UserDetails loadedUser;

try {
// 调用 UserDetailService 的 loadUserByUsername 方法
loadedUser = this.getUserDetailsService().loadUserByUsername(username);
}
catch (UsernameNotFoundException notFound) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
passwordEncoder.isPasswordValid(userNotFoundEncodedPassword,
presentedPassword, null);
}
throw notFound;
}
catch (Exception repositoryProblem) {
throw new InternalAuthenticationServiceException(
repositoryProblem.getMessage(), repositoryProblem);
}

if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
SpringSecurity 认证流程总结:

我们来总结一下整个认证流程,通过过滤器获取到请求参数,封装 Token,调用 ProviderManagerauthenticate 方法,该方法实际上调用的 ProviderManager 管理的 Provider (认证逻辑的实现类) 的 authenticate 方法,最后调用 UserDetailService 去获取用户的信息。

  1. 首先通过 Filter 拦截用户请求,获取到参数
  2. 将参数封装成 Token
  3. 调用 AuthenticationManagerauthenticated 方法。这里 AuthenticationManager 是接口,实际上调用的是 ProviderManagerauthenticated 方法。从名字我们可以猜测出 ProviderManager 管理了很多 Provider
  4. ProviderManagerauthenticated 方法中,获取所有 Provider,遍历,按 Token 匹配,调用匹配到的 Providerauthentication 方法(这里表单登录实际上调用的是 DaoAuthenticationProvider 的方法)
  5. 最终调用的是 UserDetailServiceloadUserByUsername 方法
  6. 查询出用户信息后,进行校验
  7. 校验通过后,发布认证成功信息。如果认证失败,会抛出异常,最终也会发布认证失败信息。

分析我们自定义短信认证需要实现的操作

  1. 自定义一个 Filter 用来拦截手机号登陆
  2. 自定义一个 Token,用来装载用户的认证信息与认证后的结果信息
  3. 自定义一个 Provider 的实现类,实现核心的认证逻辑
  4. **自定义一个 UserDetailService 的实现,根据手机号查询用户信息 **
  5. 最终把上面这些自定义的类作为配置,加入到 SpringSecurity 的校验流程中去
  6. 在实际的业务场景中,这里还需要将验证码校验模块放在上面的自定义 Filter 之前,只有验证码校验通过,才能访问短信认证 Filter

OK,接下来我们一步一步来实现:

自定义 SmsCodeAuthenticationToken

继承 AbstractAuthenticationToken 类,父类里面主要三个属性

  • 权限集合 ( Collection<GrantedAuthority )
  • 客户端信息( Object detail )
  • 是否通过认证( authenticated )

自己实现的 Token 在此基础上按照认证方式进行扩展,比如如果是表单登录,需要添加用户名、密码等。我们这里是短信验证码认证,只需要手机号即可。

  • Token 主要在认证流程中装载数据。
  • 下面单参数的构造方法,传递一个 mobile,是认证前用来存储认证参数的,此时默认将 authenticated 置为 false
  • 双参数的构造方法,是用来状态获取到的用户信息,此时默认将 authenticate 置为 true,但是并不代表当前认证已经通过了。因为可能后面还有密码校验(表单登录时)、账号状态校验等。
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
/**
* @author: hblolj
* @Date: 2019/3/15 10:58
* @Description: 认证前用来装载认证参数,认证通过后用来装载用户信息,因为短信验证码登录没有密码,将 credentials 移除了
* @Version:
**/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken{

private final Object principal;

public SmsCodeAuthenticationToken(Object mobile) {
super((Collection)null);
this.principal = mobile;
this.setAuthenticated(false);
}

public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true);
}

public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
} else {
super.setAuthenticated(false);
}
}

public void eraseCredentials() {
super.eraseCredentials();
}

@Override
public Object getCredentials() {
return null;
}

public Object getPrincipal() {
return this.principal;
}
}
自定义 SmsCodeAuthenticationFilter

主要参考 UsernamePasswordAuthenticationFilter 来实现自定义 Filter。在 Filter 中主要要做的事情有以下几点:

  • 指定 Filter 拦截的 UrlHttpMethod

  • 完成拦截的逻辑代码

    • 从请求中获取参数(手机号)

    • 将参数封装成自定义的 Token,同时设置一下 Detail ( 主要是发起请求的客户端信息 )

    • 调用 AuthenticationManagerauthenticated 方法进行认证

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
/**
* @author: hblolj
* @Date: 2019/3/15 10:58
* @Description:
* @Version:
**/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

// TODO: 2019/3/15 该参数可以抽取成配置,最后通过配置文件进行修改,这样作为共用组件只需要实现一个 default,具体值可以有调用者指定
private String mobileParameter = "mobile";

private boolean postOnly = true;

/**
* 通过构造函数指定该 Filter 要拦截的 url 和 httpMethod
*/
protected SmsCodeAuthenticationFilter() {
// TODO: 2019/3/15 pattern 可以抽取成配置,最后通过配置文件进行修改,这样作为共用组件只需要实现一个 default,具体值可以有调用者指定
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

// 当设置该 filter 只拦截 post 请求时,符合 pattern 的非 post 请求会触发异常
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
} else {

// 1. 从请求中获取参数 mobile + smsCode
String mobile = obtainMobile(request);
if (mobile == null){
mobile = "";
}

mobile = mobile.trim();

// 2. 封装成 Token 调用 AuthenticationManager 的 authenticated 方法,该方法中根据 Token 的类型去调用对应 Provider 的 authenticated
SmsCodeAuthenticationToken token = new SmsCodeAuthenticationToken(mobile);
this.setDetails(request, token);

// 3. 返回 authenticated 方法的返回值
return this.getAuthenticationManager().authenticate(token);
}
}

protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}

protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}

public String getMobileParameter() {
return mobileParameter;
}

public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "mobileParameter parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}
自定义 SmsCodeAuthenticationProvider

SmsCodeAuthenticationProvider 实现 AuthenticationProvider 接口,其中有两个方法:

  • authenticate: 主要是认证逻辑实现
    • authenticate 方法中主要的逻辑
      • token 取出参数,调用 UserDetailService 进行查询用户信息。UserDetailService 需要我们根据不同的业务实现不同的实现类,去数据库做不同的查询操作。
      • 使用查询出的用户信息构造新的 SmsCodeAuthenticationToken
      • 如果是表单登录,还要使用 PasswordEncoder 进行密码校验
      • 如果系统有设置账号冻结相关设置,这里可以进行校验(按取出的用户信息)
      • 最后返回 token。(如果返回的 result 不为 null,最后回去做密码擦除等操作,然后调用登录成功处理。)
  • supports: 对 authenticate 的 参数进行校验,与 Provider 对应的 Token 进行比较,看是否是其子类或子接口。

PS: 这里注明一下,短信验证码校验应该在 SmsCodeAuthenticationFilter 之前就被校验了

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
/**
* @author: hblolj
* @Date: 2019/3/15 10:58
* @Description: 短信验证码认证的真正校验逻辑,实际上是按手机号查询用户,短信验证码过滤器在这之前
* @Version:
**/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider{

private UserDetailsService userDetailsService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {

SmsCodeAuthenticationToken token = (SmsCodeAuthenticationToken) authentication;

// 对用户进行认证
UserDetails userDetails = userDetailsService.loadUserByUsername((String) token.getPrincipal());
if (userDetails == null){
throw new InternalAuthenticationServiceException("未找到对应的用户信息!");
}

// 构造新的 Token,采用该构造函数时,会默认将 authenticated 参数置为 true
SmsCodeAuthenticationToken authenticationToken = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationToken.setDetails(token.getDetails());

// TODO: 2019/3/15 如果认证方式与密码相关,这里可以对密码进行校验 @PasswordEncoder

// TODO: 2019/3/15 可以校验账号状态: 启用、冻结等等
// userDetails.isAccountNonExpired(); 账号是否过期
// userDetails.isAccountNonLocked(); 账号有无冻结
// userDetails.isCredentialsNonExpired(); 账号密码是否过期
// userDetails.isEnabled(); 账号是否启用

return authenticationToken;
}

@Override
public boolean supports(Class<?> aClass) {
// aClass 是 authenticate 方法参数的类型
// 此处判断 aClass 是否是该 Provider 对应的 Token 的子类或者子接口,只有通过了,才会调用 authenticate 方法去认证
return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
}

public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
自定义 UserDetailService

重写 loadUserByUsername 方法,按手机号查询。在实际开发中,这里可以提供一个默认缺省实现,真正的实现交给业务开发人员去实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author: hblolj
* @Date: 2019/3/15 14:08
* @Description:
* @Version:
**/
@Component("mobileUserDetailService")
public class MobileUserDetailService implements UserDetailsService{

@Override
public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
// TODO: 2019/3/15 按手机号查询用户信息
return new User("4000368163", "123", true, true, true,
true, AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
自定义 SmsCodeAuthenticationSecurityConfig

经过上面的几步准备,现在万事俱备,只欠东风。我们只需要将 FilterProvider 添加到 SpringSecurity 的认证链路当中 ( 就可以召唤神龙了 ) 即可。

  • 继承 SecurityConfigurerAdapter 类,重写该类中的 configure(HttpSecurity) 方法。(后面源码分析时,会分析这个类是怎样作用于配置的)
  • configure 方法中,首先初始化自定义的 FilterProvider,最后使用 HttpSecurity 进行设置添加
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
/**
* @author: hblolj
* @Date: 2019/3/15 10:59
* @Description:
* @Version:
**/
@Component
public class SmsCodeAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>{

@Autowired
private AuthenticationSuccessHandler successHandler;

@Autowired
private AuthenticationFailureHandler failureHandler;

@Autowired
private UserDetailsService mobileUserDetailService;

@Override
public void configure(HttpSecurity builder) throws Exception {

// 1. 初始化 SmsCodeAuthenticationFilter
SmsCodeAuthenticationFilter filter = new SmsCodeAuthenticationFilter();
filter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
filter.setAuthenticationSuccessHandler(successHandler);
filter.setAuthenticationFailureHandler(failureHandler);

// 2. 初始化 SmsCodeAuthenticationProvider
SmsCodeAuthenticationProvider provider = new SmsCodeAuthenticationProvider();
provider.setUserDetailsService(mobileUserDetailService);

// 3. 将设置完毕的 Filter 与 Provider 添加到配置中,将自定义的 Filter 加到 UsernamePasswordAuthenticationFilter 之前
builder.authenticationProvider(provider).addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
}
}

最后,将自定义的 config 添加到配置中,主要使用 apply 方法将我们自定义的 config 加入到 SpringSecurity 中,同时设置手机登录地址访问不需要认证,不然就没法使用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Autowired
private SmsCodeAuthenticationConfig smsCodeAuthenticationConfig;

@Override
protected void configure(HttpSecurity http) throws Exception {

http.apply(smsCodeAuthenticationConfig) // 加载短信验证
.and()
.formLogin() // 指定登录认证方式为表单登录
.loginProcessingUrl("/authentication/form") // 自定义表单登录的 action 地址,默认是 /login
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler)
.and()
.authorizeRequests()
// 设置手机登录地址不需要校验
.antMatchers("/authentication/mobile").permitAll()
.antMatchers(
securityProperties.getBrowser().getSignUpUrl()).permitAll() // 允许登录页面不需要认证就可以访问,不然会死循环导致重定向次数过多
.anyRequest() // 对所有的请求
.authenticated() // 都进行认证
.and()
.csrf()
.disable();
}

然后启动服务,因为是 post 请求,我们打开 postman 进行模拟,这里我对DefaultAuthenticationSuccessHandler 做了一下处理,使其返回 principal.toString()

3-1

如图所示,证明我们配置的手机号认证流程已经生效了。

同理,除了手机号的自定义登录,我们还可以自定义其他的登录方式,比如微信公众号开发中,我们需要使用用户的 OpenId 来登录,就可以按这个模式来处理 ( 最终更新完后,我会将代码实现上传到 Github 上,到时候会包含这个 weixin openId 登录,这里大家感兴趣的话,可以自己先实现以下)。


这里额外说明一下,QQ 登录微信登录微信公众号内部登录虽然都走的 OAuth2 协议,但是又有点不一样,属于纯纯的第三方登录,要使用 SpringSocial + OAuth2 来开发。这个我会放到后面几章去讲解。

下一章的话,会带大家处理一下在 SpringSecurity 下的 Session 管理与登出。

To Be Continue…