一、前言

《Spring Security添加图形验证码》 一节中,我们已经实现了基于Spring Boot + Spring Security的账号密码登录,并集成了图形验证码功能。时下另一种非常常见的网站登录方式为手机短信验证码登录,但Spring Security默认只提供了账号密码的登录认证逻辑,所以要实现手机短信验证码登录认证功能,我们需要模仿Spring Security账号密码登录逻辑代码来实现一套自己的认证逻辑。

二、短信验证码生成

我们在 《Spring Security自定义用户认证》 这一节Spring Security添加图形验证码的基础上来集成短信验证码登录的功能。

2.1 定义短信验证码对象

和图形验证码类似,我们先定义一个短信验证码对象SmsCode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Getter
@Setter
public class SmsCode implements Serializable {

private static final long serialVersionUID = 3497935890426858542L;

private String code;
private LocalDateTime expireTime;

public SmsCode(String code, int expireIn) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}

public SmsCode(String code, LocalDateTime expireTime) {
this.code = code;
this.expireTime = expireTime;
}

public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime);
}

}

SmsCode对象包含了两个属性:code验证码和expireTime过期时间。isExpire方法用于判断短信验证码是否已过期。

2.2 生成短信验证码

接着在ValidateCodeController中生成短信验证码相关请求对应的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
public class ValidateCodeController {

@GetMapping("/code/sms")
public void createSmsCode(HttpServletResponse response, HttpSession session, String mobile) throws IOException {
SmsCode smsCode = createSMSCode();
session.setAttribute("smsCodeObj", smsCode);
session.setAttribute("smsCode", smsCode.getCode());
// 输出验证码到控制台代替短信发送服务
System.out.println(mobile + "您的登录验证码为:" + smsCode.getCode() + ",有效时间为60秒");
}

private SmsCode createSMSCode() {
String code = RandomStringUtils.randomNumeric(6);
return new SmsCode(code, 60);
}

}

这里我们使用createSMSCode方法生成了一个6位的纯数字随机数,有效时间为60秒。然后通过SessionStrategy对象的setAttribute方法将短信验证码保存到了Session中,对应的key为smsCode。

至此,短信验证码生成模块编写完毕,下面开始改造登录页面。

2.3 登录页改造

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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
<link rel="stylesheet" href="css/login.css" type="text/css">
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
</head>
<body>
<form class="login-page" action="/login/mobile" method="post">
<div class="form">
<h3>短信验证码登录</h3>
<input type="text" placeholder="手机号" name="mobile" value="18888888888" required="required"/>
<span style="display: inline">
<input type="text" name="smsCode" placeholder="短信验证码" style="width: 50%;"/>
<a href="#" onclick="sendSmsCode()">发送验证码</a>
</span>
<button type="submit">登录</button>
</div>
</form>
</body>
<script inline="javascript">
function sendSmsCode() {
var mobile = $("input[name='mobile']").val();
$.ajax({
type: "get",
url: "/code/sms",
data: {"mobile": mobile},
dataType: "json",
success: function (r) {
alert("发送成功");
}
});
}
</script>
</html>

其中a标签的单击对应我们的短信验证码生成方法的请求URL。Form的action对应处理短信验证码登录方法的请求URL,这个方法下面在进行具体实现。

2.4 路径免验证改造

我们需要在Spring Security中配置/code/sms路径免验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    @Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin() // 表单登录
// http.httpBasic() // HTTP Basic
//.loginPage("/login.html")
.loginPage("/authentication/require") // 登录跳转 URL
.loginProcessingUrl("/login")
.successHandler(authenticationSucessHandler) // 处理登录成功
.failureHandler(authenticationFailureHandler) // 处理登录失败
.and()
.authorizeRequests() // 授权配置
//.antMatchers("/login.html").permitAll()
.antMatchers("/authentication/require", "/login.html", "/code/sms").permitAll() // 登录跳转 URL 无需认证
.anyRequest() // 所有请求
.authenticated() // 都需要认证
.and().csrf().disable();
}

2.5 测试

重启项目,访问 http://localhost:8080/login.html

点击发送验证码,控制台输出如下:

18888888888您的登录验证码为:548746,有效时间为60秒

接下来开始实现使用短信验证码登录认证逻辑。

三、添加短信验证码认证

3.1 改造思想

在Spring Security中,使用用户名密码认证的过程大致如下图所示:

Spring Security使用UsernamePasswordAuthenticationFilter过滤器来拦截用户名密码认证请求,将用户名和密码封装成一个UsernamePasswordToken对象交给AuthenticationManager处理。AuthenticationManager将挑出一个支持处理该类型Token的AuthenticationProvider(这里为DaoAuthenticationProvider,AuthenticationProvider的其中一个实现类)来进行认证,认证过程中DaoAuthenticationProvider将调用UserDetailService的loadUserByUsername方法来处理认证,如果认证通过(即UsernamePasswordToken中的用户名和密码相符)则返回一个UserDetails类型对象,并将认证信息保存到Session中,认证后我们便可以通过Authentication对象获取到认证的信息了。

由于Spring Security并没用提供短信验证码认证的流程,所以我们需要仿照上面这个流程来实现:

在这个流程中,我们自定义了一个名为SmsAuthenticationFitler的过滤器来拦截短信验证码登录请求,并将手机号码封装到一个叫SmsAuthenticationToken的对象中。在Spring Security中,认证处理都需要通过AuthenticationManager来代理,所以这里我们依旧将SmsAuthenticationToken交由AuthenticationManager处理。接着我们需要定义一个支持处理SmsAuthenticationToken对象的SmsAuthenticationProvider,SmsAuthenticationProvider调用UserDetailService的loadUserByUsername方法来处理认证。与用户名密码认证不一样的是,这里是通过SmsAuthenticationToken中的手机号去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails对象中返回并将认证后的信息保存到Authentication对象中。

为了实现这个流程,我们需要定义SmsAuthenticationFitler、SmsAuthenticationToken和SmsAuthenticationProvider,并将这些组建组合起来添加到Spring Security中。下面我们来逐步实现这个过程。

3.2 定义SmsAuthenticationToken

查看UsernamePasswordAuthenticationToken的源码,将其复制出来重命名为SmsAuthenticationToken,并稍作修改,修改后的代码如下所示:

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
public class SmsAuthenticationToken extends AbstractAuthenticationToken {

private static final long serialVersionUID = 530L;
private final Object principal;

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

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

public Object getCredentials() {
return null;
}

public Object getPrincipal() {
return this.principal;
}

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();
}

}

SmsAuthenticationToken包含一个principal属性,从它的两个构造函数可以看出,在认证之前principal存的是手机号,认证之后存的是用户信息。UsernamePasswordAuthenticationToken原来还包含一个credentials属性用于存放密码,这里不需要就去掉了。

3.3 定义SmsAuthenticationFilter

定义完SmsAuthenticationToken后,我们接着定义用于处理短信验证码登录请求的过滤器SmsAuthenticationFilter,同样的复制UsernamePasswordAuthenticationFilter源码并稍作修改:

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
public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

public static final String MOBILE_KEY = "mobile";
private String mobileParameter = MOBILE_KEY;

private boolean postOnly = true;

public SmsAuthenticationFilter() {
super(new AntPathRequestMatcher("/login/mobile", "POST"));
}

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 mobile = obtainMobile(request);
if (mobile == null) {
mobile = "";
}

mobile = mobile.trim();
SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
this.setDetails(request, authRequest);

return this.getAuthenticationManager().authenticate(authRequest);
}
}

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


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

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

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}

public final String getMobileParameter() {
return this.mobileParameter;
}

}

构造函数中指定了当请求为/login/mobile,请求方法为POST的时候该过滤器生效。mobileParameter属性值为mobile,对应登录页面手机号输入框的name属性。attemptAuthentication方法从请求中获取到mobile参数值,并调用SmsAuthenticationToken的SmsAuthenticationToken(String mobile)构造方法创建了一个SmsAuthenticationToken。下一步就如流程图中所示的那样,SmsAuthenticationFilter将SmsAuthenticationToken交给AuthenticationManager处理。

3.4 定义SmsAuthenticationProvider

在创建完SmsAuthenticationFilter后,我们需要创建一个支持处理该类型Token的类,即SmsAuthenticationProvider,该类需要实现AuthenticationProvider的两个抽象方法:

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
public class SmsAuthenticationProvider implements AuthenticationProvider {

private MyUserDetailService userDetailService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
UserDetails userDetails = userDetailService.loadUserByUsername((String) authenticationToken.getPrincipal());

if (userDetails == null)
throw new InternalAuthenticationServiceException("未找到与该手机号对应的用户");

SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());

authenticationResult.setDetails(authenticationToken.getDetails());

return authenticationResult;
}

@Override
public boolean supports(Class<?> aClass) {
return SmsAuthenticationToken.class.isAssignableFrom(aClass);
}

public MyUserDetailService getUserDetailService() {
return userDetailService;
}

public void setUserDetailService(MyUserDetailService userDetailService) {
this.userDetailService = userDetailService;
}
}

其中supports方法指定了支持处理的Token类型为SmsAuthenticationToken,authenticate方法用于编写具体的身份认证逻辑。在authenticate方法中,我们从SmsAuthenticationToken中取出了手机号信息,并调用了UserDetailService的loadUserByUsername方法。该方法在用户名密码类型的认证中,主要逻辑是通过用户名查询用户信息,如果存在该用户并且密码一致则认证成功;而在短信验证码认证的过程中,该方法需要通过手机号去查询用户,如果存在该用户则认证通过。认证通过后接着调用SmsAuthenticationToken的SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities)构造函数构造一个认证通过的Token,包含了用户信息和用户权限。

你可能会问,为什么这一步没有进行短信验证码的校验呢?实际上短信验证码的校验是在SmsAuthenticationFilter之前完成的,即只有当短信验证码正确以后才开始走认证的流程。所以接下来我们需要定一个过滤器来校验短信验证码的正确性。

3.5 定义SmsCodeFilter

短信验证码的校验逻辑其实和图形验证码的校验逻辑基本一致,所以我们在图形验证码过滤器的基础上稍作修改,代码如下所示:

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
@Component
@Slf4j
public class SmsCodeFilter extends OncePerRequestFilter {

@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;

@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
FilterChain filterChain) throws ServletException, IOException {
if (StringUtils.equalsIgnoreCase("/login/mobile", httpServletRequest.getRequestURI())
&& StringUtils.equalsIgnoreCase(httpServletRequest.getMethod(), "post")) {
try {
validateSmsCode(httpServletRequest);
} catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
return;
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}

private void validateSmsCode(HttpServletRequest request) throws ServletRequestBindingException {
String smsCodeInRequest = ServletRequestUtils.getStringParameter(request, "smsCode");
String mobile = ServletRequestUtils.getStringParameter(request, "mobile");

String code = (String) request.getSession().getAttribute("smsCode");
SmsCode smsCode = (SmsCode) request.getSession().getAttribute("smsCodeObj");

log.info("获取提交的验证码:{}",smsCodeInRequest);
log.info("获取保存的验证码:{}",code);

if (StringUtils.isBlank(smsCodeInRequest)) {
throw new ValidateCodeException("验证码不能为空!");
}
if (smsCode == null) {
throw new ValidateCodeException("验证码不存在,请重新发送!");
}
if (smsCode.isExpire()) {
request.getSession().removeAttribute(request.getParameter("smsCodeObj"));
throw new ValidateCodeException("验证码已过期,请重新发送!");
}
if (!StringUtils.equalsIgnoreCase(smsCode.getCode(), smsCodeInRequest)) {
throw new ValidateCodeException("验证码不正确!");
}

request.getSession().removeAttribute(request.getParameter("smsCodeObj"));
request.getSession().removeAttribute(request.getParameter("smsCode"));

}

}

方法的基本逻辑和之前定义的ValidateCodeFilter一致,这里不再赘述。

3.6 配置生效

在定义完所需的组件后,我们需要进行一些配置,将这些组件组合起来形成一个和上面流程图对应的流程。创建一个配置类SmsAuthenticationConfig:

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
@Component
public class SmsAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;

@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;

@Autowired
private MyUserDetailService userDetailService;

@Override
public void configure(HttpSecurity http) throws Exception {
SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
smsAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
smsAuthenticationProvider.setUserDetailService(userDetailService);

http.authenticationProvider(smsAuthenticationProvider)
.addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

}
}

在流程中第一步需要配置SmsAuthenticationFilter,分别设置了AuthenticationManager、AuthenticationSuccessHandler和AuthenticationFailureHandler属性。这些属性都是来自SmsAuthenticationFilter继承的AbstractAuthenticationProcessingFilter类中。

第二步配置SmsAuthenticationProvider,这一步只需要将我们自个的UserDetailService注入进来即可。

最后调用HttpSecurity的authenticationProvider方法指定了AuthenticationProvider为SmsAuthenticationProvider,并将SmsAuthenticationFilter过滤器添加到了UsernamePasswordAuthenticationFilter后面。
到这里我们已经将短信验证码认证的各个组件组合起来了。

3.7 配置改造

最后一步需要做的是配置短信验证码校验过滤器,并且将短信验证码认证流程加入到Spring Security中。在BrowserSecurityConfig的configure方法中添加如下配置:

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
    @Autowired
private SmsCodeFilter smsCodeFilter;

@Autowired
private SmsAuthenticationConfig smsAuthenticationConfig;

@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加短信验证码校验过滤器
.formLogin() // 表单登录
// http.httpBasic() // HTTP Basic
//.loginPage("/login.html")
.loginPage("/authentication/require") // 登录跳转 URL
.loginProcessingUrl("/login")
.successHandler(authenticationSucessHandler) // 处理登录成功
.failureHandler(authenticationFailureHandler) // 处理登录失败
.and()
.authorizeRequests() // 授权配置
//.antMatchers("/login.html").permitAll()
.antMatchers("/authentication/require", "/login.html", "/code/sms").permitAll() // 登录跳转 URL 无需认证
.anyRequest() // 所有请求
.authenticated() // 都需要认证
.and().csrf().disable()
.apply(smsAuthenticationConfig); // 将短信验证码认证配置加到 Spring Security 中;
}

具体含义见注释,这里不再赘述。

3.8 测试

重启项目,访问 http://localhost:8080/login.html ,点击发送验证码,控制台输出如下:

18888888888您的登录验证码为:826812,有效时间为60秒

输入该验证码,点击登录后页面如下所示:

认证成功。