在日常开发中,我们的应用不只有表单登录,大部分情况下有通过手机验证码登录、第三方账号登录等。这些不同的方式其实也都大同小异,手机号验证码登录相等于是密码不固定的表单登录;第三方登录其实是我们的服务器从第三方平台拿到了一个令牌,然后根据令牌从第三方平台获取用户信息,再存到我们服务器的 SecurityContext 中,第三方登录我们后面介绍 oAuth2.0 时再详细介绍,今天主要学习自定义手机验证码登录功能。
实现手机短信验证码登录
1 回顾 UsernamePasswordAuthenticationFilter 的认证过程
在 spring security 的 认证流程、权限验证流程源码解读 文章中,我们已经详细的介绍了 Spring Security 的认证流程,其中 UsernamePasswordAuthenticationFilter 是关键,它拦截 URI 为 /login 的请求,然后从请求中获取用户在登录界面中输入的用户名和密码信息,然后封装成 UsernamePasswordAuthenticationToken 对象并交给 AuthenticationManager 进行验证。
AuthenticationManager 最终将认证任务交给 AuthenticationProvider 来完成,在认证时,需要通过 UserDetailsService 从数据库中获取用户注册时保存的用户名与密码,然后与用户输入的信息进行比较来判断是否通过认证。
这里我们只做一个大概回顾,如果大家还不清楚的话,可以再看一下那篇文章。
我们做手机验证码登录时,也需要采用该流程,我们自己编写流程中的 filter、provider 等。
2 创建获取验证码的接口和登录界面
2.1 登录界面
myLoginPage.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>我的登录页面</title>
</head>
<body>
<h3>短信登陆</h3>
<form action="/mobile/login" method="post">
<table>
<tr>
<td>手机号码:</td>
<td><input id="mobileInput" type="text" name="mobile" value="12345678910"></td>
</tr>
<tr>
<td>短信验证码:</td>
<td>
<input type="text" name="code">
<button id="smsCodeBtn" type="button">获取验证码</button>
</td>
</tr>
<tr>
<td colspan="2"><input type="submit" value="登录"/></td>
</tr>
</table>
</form>
</body>
<script>
var Ajax={
get: function(url, fn) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onreadystatechange = function() {
if (xhr.readyState == 4 && xhr.status == 200 || xhr.status == 304) {
fn.call(this, xhr.responseText);
}
};
xhr.send();
}
}
var smsCodeBtn = document.getElementById("smsCodeBtn");
smsCodeBtn.onclick = function () {
mobile = document.getElementById('mobileInput').value;
url = "/smscode/send?mobile="+mobile;
Ajax.get(url,function (data) {
alert(data);
})
}
</script>
</html>
一个简单的登录界面,点击 “获取验证码” 按钮获取验证码。
2.2 创建获取验证码的接口
用户在登录界面点击获取验证码时调用该方法向用户的手机发送验证码,此处我们就在控制台打印一下验证码即可,实际开发中肯定是调用短信服务来给用户发送短信验证码。
本示例我们将生成的验证码存在了 session 中,后面登录验证的时候,也从 session 中获取,当然你也可以将验证码存在 redis、数据库等等都可以。
@RestController
public class SmsController {
Logger logger = LoggerFactory.getLogger(SmsController.class);
public static final String SMS_CODE = "SMS_CODE";
@RequestMapping("/smscode/send")
public String sendSmsCode(HttpSession session, HttpServletRequest request) throws ServletRequestBindingException {
String mobile = request.getParameter("mobile");
//随机生成一个验证码
Random rd=new Random();
int code = rd.nextInt(10000);
//模拟向用户发送短信
logger.info("send code to "+mobile+" :"+code);
Map<String, Object> map = new HashMap<>();
map.put("mobile", mobile);
map.put("code", code);
//将验证码存到 session 中,后面验证的时候,从 session 中获取
session.setAttribute(SMS_CODE,map);
return "验证码发送成功,请注意查收";
}
}
2 自定义 MobileCodeAuthenticationFilter
我们依葫芦画瓢照着 UsernamePasswordAuthenticationFilter 来编写我们的 MobileCodeAuthenticationFilter 。
该类需要继承自 AbstractAuthenticationProcessingFilter。
我就自己上代码了。我们直接复制 UsernamePasswordAuthenticationFilter 中的代码,稍作修改即可。
MobileCodeAuthenticationFilter.java:
public class MobileCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
//request parameter 参数名称
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
public static final String SPRING_SECURITY_FORM_CODE_KEY = "code";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
private String codeParameter = SPRING_SECURITY_FORM_CODE_KEY;
//我们的手机验证码登录 默认也只支持 POST 请求
private boolean postOnly = true;
public MobileCodeAuthenticationFilter() {
//默认的登录请求处理地址
super(new AntPathRequestMatcher("/mobile/login", "POST"));
}
//完成验证功能的方法
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String mobile = obtainMobile(request);
String code = obtainCode(request);
if (mobile == null) {
mobile = "";
}
if (code == null) {
code = "";
}
mobile = mobile.trim();
MobileCodeAuthenticationToken authRequest = new MobileCodeAuthenticationToken(
mobile, code);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
//获取用户输入的验证码
@Nullable
protected String obtainCode(HttpServletRequest request) {
return request.getParameter(codeParameter);
}
//获取用户输入的手机号码
@Nullable
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
//设置 IP地址、sessionID 等信息到 MobileCodeAuthenticationToken 对象中
protected void setDetails(HttpServletRequest request,
MobileCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
}
这段代码与 UsernamePasswordAuthenticationFilter 中的代码基本一致,只是我们将请求中参数名称换了一下而已。
3 MobileCodeAuthenticationToken
在 UsernamePasswordAuthenticationFilter 将获取到的用户信息封装到了 UsernamePasswordAuthenticationToken 中。那我们也来定义一个类似的 xxxAuthenticationToken 类,用来封装用户输入的手机号码和验证码。
MobileCodeAuthenticationToken 和 UsernamePasswordAuthenticationToken 一样,我们也让它继承自 AbstractAuthenticationToken 类。
MobileCodeAuthenticationToken.java:
package com.llk.filter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
public class MobileCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private Object credentials;
public MobileCodeAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
public MobileCodeAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
public Object getCredentials() {
return this.credentials;
}
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");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
credentials = null;
}
}
MobileCodeAuthenticationToken 与 UsernamePasswordAuthenticationToken 完全一致,基本不需要修改。
4 MobileCodeAuthenticationProvider
接下来我们就需要自定义 AuthenticationProvider 来完成登录验证了。
从 session 中取出用户获取的验证码与用户在登录界面输入的手机号码和验证码进行匹配,以此来判断用户是否登录成功。
我们直接贴出代码:
MobileCodeAuthenticationProvider.java:
@Component
public class MobileCodeAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
/**
* 进行身份认证的逻辑
* @param authentication
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//该 authenticationToken 就是我们在 MobileCodeAuthenticationFilter 中创建的那个
MobileCodeAuthenticationToken authenticationToken = (MobileCodeAuthenticationToken)authentication;
//获取用户输入的手机号码和验证码
String mobile = (String) authenticationToken.getPrincipal();
String code = (String) authenticationToken.getCredentials();
//根据手机号码拿到用户信息
//先根据手机号码查询该用户是否注册
UserDetails user = userDetailsService.loadUserByUsername(mobile);
if(user == null){
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
//验证手机号和验证码
checkSmsCode(mobile,code);
//注意这里调用的构造方法
//代码执行到这里就说明用户输入的手机号码和验证码是正确的,
// 就需要将 authenticated 设置为 true,否则后面的流程认为该用户还没有认证通过
MobileCodeAuthenticationToken authenticationResult
= new MobileCodeAuthenticationToken(user,null,user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
//该 provider 支持的认证方式
return authentication.equals(MobileCodeAuthenticationToken.class);
}
//验证输入的手机号码与验证是否与 session 中存储的一致
private void checkSmsCode(String mobile, String codeParameter) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
//从 session 中获取 SmsController 中保存的验证码信息
Map<String, Object> code = (Map<String, Object>) request.getSession().getAttribute(SmsController.SMS_CODE);
if(code == null) {
throw new BadCredentialsException("未申请验证码");
}
String savedMobile = (String) code.get("mobile");
int codeInt = (int) code.get("code");
if(!savedMobile.equals(mobile)) {
throw new BadCredentialsException("手机号码不一致");
}
if(codeInt != Integer.parseInt(codeParameter)) {
throw new BadCredentialsException("验证码错误");
}
}
}
大家可能觉得这里的 userDetailsService 有点多余,因为我们只需要从 session 中拿出之前保存的验证码来验证用户输入的验证码即可,不需要 userDetailsService 提供 userDetails 信息。
其实不然,在进行验证验证码之前,我们需要通过 userDetailsService 根据用户输入的手机号码去查询一下该手机号码是否是我们的用户,如果不是当然不能继续执行后面的逻辑了。
然后就是最重要的一点,通过 userDetailsService 获取到的 UserDetails 信息我们需要再构建一个 MobileCodeAuthenticationToken 对象,用于处理后续的 session 管理、将认证的用户加入到 SecurityContext 中等等操作。
5 AuthUserService
接下来我们定义一个 UserService 类为 MobileCodeAuthenticationProvider 提供获取 UserDetails 的方法。
该接口的原理与作用大家看过前面的文章用该已经很了解了,我们直接上代码。
此处只是模拟了一下从数据库获取了一个用户。
AuthUserService.java:
import com.llk.domain.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import java.util.ArrayList;
import java.util.List;
public class AuthUserService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
//此处模拟从数据库等中获取一个用户信息
User user = getUser(mobile);
return user;
}
private User getUser(String mobile){
User user = new User();
user.setMobile(mobile);
user.setPassword("123123123123123");
user.setUsername("llk");
//模拟用户具有的权限
GrantedAuthority grantedAuthority = new GrantedAuthority(){
@Override
public String getAuthority() {
return "USER";
}
};
List<GrantedAuthority> authorityList = new ArrayList<>();
authorityList.add(grantedAuthority);
user.setAuthorities(authorityList);
return user;
}
}
User.java:
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class User implements UserDetails {
private String mobile;
private String password;
private String username;
private List<GrantedAuthority> authorities;
public void setAuthorities(List<GrantedAuthority> authorities) {
this.authorities = authorities;
}
public void setUsername(String username) {
this.username = username;
}
public String getMobile() {
return mobile;
}
public void setMobile(String mobile) {
this.mobile = mobile;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
6 登录成功和登录失败的处理
LoginSuccessHandler.java:
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
/**
当用户登录成功后,就执行该方法
*/
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("登录成功");
System.out.println(authentication);
response.setContentType("application/json; charset=utf-8");
response.getWriter().write("LoginSuccessHandler 登录成功");
}
}
LoginFailureHandler.java:
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
/**
*认证过程出抛出异常时执行该方法
*/
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String message = exception.getMessage();
response.setContentType("application/json; charset=utf-8");
response.getWriter().write("LoginFailureHandler 登录失败:"+message);
}
}
7 配置文件
接下来的任务就是将我们前面定义的 MobileCodeAuthenticationFilter 和 MobileCodeAuthenticationProvider 等配置给 Spring Security。
7.1 MobileCodeSecurityConfigurer
该配置文件的主要目的是创建一个 MobileCodeAuthenticationFilter 实例,然后为该过滤器设置 AuthenticationManager、AuthenticationSuccessHandler、SessionAuthenticationStrategy 等,它的作用于表单登录的配置文件 FormLoginConfigurer 差不多。
MobileCodeSecurityConfigurer.java:
import com.llk.filter.MobileCodeAuthenticationFilter;
import com.llk.handler.LoginFailureHandler;
import com.llk.handler.LoginSuccessHandler;
import com.llk.provider.MobileCodeAuthenticationProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
@Configuration
public class MobileCodeSecurityConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private LoginSuccessHandler loginSuccessHandler;
@Autowired
private LoginFailureHandler loginFailureHandler;
@Autowired
private MobileCodeAuthenticationProvider mobileCodeAuthenticationProvider;
@Override
public void configure(HttpSecurity http) throws Exception {
//创建我们的认证过滤器
MobileCodeAuthenticationFilter mobileCodeAuthenticationFilter = new MobileCodeAuthenticationFilter();
//给认证过滤器设置认证管理器
mobileCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//设置登录成功或失败后的处理器
mobileCodeAuthenticationFilter.setAuthenticationSuccessHandler(loginSuccessHandler);
mobileCodeAuthenticationFilter.setAuthenticationFailureHandler(loginFailureHandler);
//设置 session 管理策略,如果不设置的话就是默认的 NullAuthenticatedSessionStrategy
SessionAuthenticationStrategy sessionAuthenticationStrategy = http
.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
mobileCodeAuthenticationFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
//添加我们的 mobileCodeAuthenticationProvider 到 authenticationProviders 集合中
http.authenticationProvider(mobileCodeAuthenticationProvider)
//将认证过滤器加到过滤器链中
.addFilterAfter(mobileCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
7.2 WebSecurityConfig
该配置文件与我们之前的差不多,主要用来开启我们的 Spring Security 功能,和一些接口访问的配置。
import com.llk.service.AuthUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
WebSecurityConfig.java:
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MobileCodeSecurityConfigurer mobileCodeSecurityConfigurer;
@Bean
public PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/smscode/send",//获取验证码的接口不需要登录
"/toLoginPage",//跳转到登录界面
"/mobile/login")//处理登录请求的接口不需要登录
.permitAll()
.antMatchers("/book/get/**").hasAnyAuthority("USER","ADMIN")
.anyRequest().authenticated() //其他任何请求都需要身份认证
//将 mobileCodeSecurityConfigurer 设置给 httpSecurity
.and().apply(mobileCodeSecurityConfigurer)
.and().logout().permitAll()
.and().csrf().disable(); //禁用CSRF
}
@Bean
public UserDetailsService userDetailsService() {
AuthUserService userService = new AuthUserService();
return userService;
}
}
在该配置类中,我们没有配置表单相关的,我们只配置了与我们本次的手机验证码登录相关的,如果你的应用也需要表单登录,直接在该配置文件中像我们之前那样加上表单登录的配置即可,他们是互不干扰的。
8 其他的控制器等
LoginController.java:
@Controller
public class LoginController {
@RequestMapping("/toLoginPage")
public String toLoginPage(){
return "myLoginPage";
}
}
BookController.java:
@RestController
@RequestMapping("/book")
public class BookController {
@RequestMapping("/get")
public Book get(){
Book book = new Book();
book.setBookId("1");
book.setBookName("《Thinking in Java》");
book.setAuthor("Bruce Eckel");
return book;
}
}
Book.java:
public class Book {
private String bookId;
private String bookName;
private String author;
//setter getter
}
好了,到目前为止,通过手机验证码登录的功能就完成,大家可以自行测试一下。
经过这个功能的练习,我想大家对 Spring Security 的认证流程的理解更加深入了,以后不管是完成什么样的登录功能都可以像这个案例一样。
我们在日常开发中,很多时候是前后端分离的应用,我们的表单登录可能就用不上了,因为我们传递的请求数据是 json 格式的,如果要完成登录功能的话,我们只需要重写一个认证过滤器,在 attemptAuthentication() 方法中从请求中获取出 json 格式的用户名密码等数据,再封装给 xxxAuthenticationToken 即可。
9 示例代码地址
本示例项目结构:
示例代码地址:https://github.com/coderllk/spring-security-oauth2-demos