概述
手机验证码登录是目前很常见的一种登录方式,本文阐述基于Spring Security快速实现手机验证码登录。本文建立在你对Spring Security基本原理有所了解的基础上,有兴趣的同学戳这里是关于Spring Security基本原理的文章:https://www.jianshu.com/p/e22fdeedc9a3
本文实验环境:
- SpringBoot:2.2.0.RELEASE
- IDE:IntelliJ IDEA 2018.2.4
思路
- 创建手机验证码实体和相关工具(生成器,发送器)
- 增加生成验证码接口和验证码校验过滤器
- 参考表单登录认证流程实现手机验证码登录认证流程
- 配置 Spring Security
实现步骤
加入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
</dependency>
创建手机验证码实体
第一步先创建一个手机验证码实体类,包含验一个证码字段和一个过期时间字段,还提供了两种构造器,提供两种不同设置过期时间的方式
public class ValidateCode {
private String code;
private LocalDateTime expireTime;
public ValidateCode(String code, int expireIn){
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
public ValidateCode(String code, LocalDateTime expireTime){
this.code = code;
this.expireTime = expireTime;
}
public boolean isExpried() {
return LocalDateTime.now().isAfter(expireTime);
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public LocalDateTime getExpireTime() {
return expireTime;
}
public void setExpireTime(LocalDateTime expireTime) {
this.expireTime = expireTime;
}
}
创建验证码生成器
封装一个验证码生成器,generate方法生成一个四位数验证码
@Component
public class SmsCodeGenerator{
public ValidateCode generate(ServletWebRequest request) {
String code = RandomStringUtils.randomNumeric(4);
return new ValidateCode(code, 60);
}
}
创建验证码发送器
封装一个验证码发送器,在这只是模拟发送验证码,并没有真正的向手机发送验证码,验证码在控制台打印出来,具体的实现向手机发送验证码需自己实现
@Component
public class DefaultSmsCodeSender{
public void send(String mobile, String code) {
System.out.println("向手机"+mobile+"发送短信验证码"+code);
}
}
生成验证码接口
创建一个接口用于生成验证,此接口会向外暴漏出来,不被Spring Seurity过滤器拦截,下文会写详细的Spring Seurity配置。接口内生成验证码,并且作为key-value键值对保存在session中,最后向手机号发送验证码
@RestController
public class ValidateCodeController {
@Autowired
private SmsCodeGenerator smsCodeGenerator;
@Autowired
private DefaultSmsCodeSender defaultSmsCodeSender;
@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response, HttpSession session, @RequestParam String mobile) throws IOException {
ValidateCode smsCode = smsCodeGenerator.generate(new ServletWebRequest(request));
session.setAttribute(mobile, smsCode);
defaultSmsCodeSender.send(mobile,smsCode.getCode());
}
}
增加手机验证码过滤器
自定义一个手机验证码过滤器,拦截请求URL是/authentication/mobile(手机验证码登录接口)和请求方式是POST的请求,做验证码校验。校验包括验证码是否过期等
public class ValidateCodeFilter extends OncePerRequestFilter {
private AuthenticationFailureHandler authenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain)
throws ServletException, IOException {
if(StringUtils.equals("/authentication/mobile", httpServletRequest.getRequestURI())
&& StringUtils.equalsIgnoreCase(httpServletRequest.getMethod(), "post")){
try {
validateSmsCode(httpServletRequest,httpServletRequest.getSession());
}catch (ValidateCodeException e) {
authenticationFailureHandler.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
return;
}
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
//校验手机验证码
private void validateSmsCode(HttpServletRequest request, HttpSession session) throws ServletRequestBindingException {
//请求里的手机号和验证码
String mobileInRequest = request.getParameter("mobile");
String codeInRequest = request.getParameter("smsCode");
ValidateCode codeInSession = (ValidateCode) session.getAttribute(mobileInRequest);
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if(codeInSession == null){
throw new ValidateCodeException("该手机号未发送验证码");
}
if(codeInSession.isExpried()){
session.removeAttribute(mobileInRequest);
throw new ValidateCodeException("验证码已过期");
}
if(!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码不匹配");
}
session.removeAttribute(mobileInRequest);
}
public AuthenticationFailureHandler getAuthenticationFailureHandler() {
return authenticationFailureHandler;
}
public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
this.authenticationFailureHandler = authenticationFailureHandler;
}
}
其中抛出一个校验异常
public class ValidateCodeException extends AuthenticationException {
private static final long serialVersionUID = -7285211528095468156L;
public ValidateCodeException(String msg) {
super(msg);
}
}
还有一个认证失败的handler,失败处理是在返回中打印错误信息返回体SimpleResponse,SimpleResponse就是一个简单的服务器返回体
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
logger.info("登录失败");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponse(exception.getLocalizedMessage())));
}
}
public class SimpleResponse {
private Object content;
public SimpleResponse(Object content) {
this.content = content;
}
public Object getContent() {
return content;
}
public void setContent(Object content) {
this.content = content;
}
}
参照Spring Security表单登录的流程自定义一套手机验证码的登录流程
有看过Spring Security源码的同学应该知道表单登录的实现流程,大致如下:
- UsernamePasswordAuthenticationFilter拦截登录请求
- UsernamePasswordAuthenticationFilter获取到用户名和密码构造一个UsernamePasswordAuthenticationToken传入AuthenticationManager
- AuthenticationManager找到对应的Provider进行具体校验逻辑处理
- 最后登录信息保存进SecurityContext
在这进行手机验证码的登录,参照表单登录流程,咋们来实现一个一样的手机验证码登录流程
- 参考UsernamePasswordAuthenticationFilter、UsernamePasswordAuthenticationToken源码,实现自定义SmsCodeAuthenticationFilter和SmsCodeAuthenticationToken
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter{
public static final String SPRING_SECURITY_FORM_MOBILE = "mobile";
private String mobileParameter = SPRING_SECURITY_FORM_MOBILE;
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
@Override
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);
if (mobile == null) {
mobile = "";
}
mobile = mobile.trim();
SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 获取手机号
* @param request
* @return
*/
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}
protected void setDetails(HttpServletRequest request,
SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
public void setUsernameParameter(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 String getMobileParameter() {
return mobileParameter;
}
}
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
public SmsCodeAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
public SmsCodeAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
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");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
- 自定义一个SmsCodeAuthenticationProvider处理登录逻辑并返回经过认证的用户信息,通过重写supports方法实现向AuthenticationManager传入SmsCodeAuthenticationToken时使用SmsCodeAuthenticationProvider处理认证
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken smsCodeAuthenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String)smsCodeAuthenticationToken.getPrincipal());
if(user == null){
throw new InternalAuthenticationServiceException("用户不存在");
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user,user.getAuthorities());
authenticationResult.setDetails(smsCodeAuthenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> aClass) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
- SmsCodeAuthenticationProvider中含有一个UserDetailsService是用来查找用户的,在这只是模拟过程没有真实的进行数据库查找
@Component
public class MyUserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
/**
* 这里实际情况应该是根据参数s查询数据库用户数据
*/
return new User(s,"123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
- 把各个组件拼装起来并且把SmsCodeAuthenticationFilter加入到Spring Security的过滤器链
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private UserDetailsService myUserDetailsService;
@Override
public void configure(HttpSecurity builder) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
//配置smsCodeAuthenticationFilter成功和失败的处理器
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);
//设置SmsCodeAuthenticationProvider的UserDetailsService
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(myUserDetailsService);
//把smsCodeAuthenticationFilter过滤器添加在UsernamePasswordAuthenticationFilter之前
builder.authenticationProvider(smsCodeAuthenticationProvider);
builder.addFilterBefore(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
代码中认证是啊比处理器在上文中已经有提到,这里还有一个认证成功处理器,这个认证成功的处理方式就是把认证成功的认证信息在返回中打印出来
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(MyAuthenticationSuccessHandler.class);
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(authentication));
}
}
Spring Security配置和测试
Spring Security配置中把ValidateCodeFilter加到UsernamePasswordAuthenticationFilter之前,把上一步SmsCodeAuthenticationSecurityConfig配置加入。使用自定义登录界面,并且把登录界面和获取验证码的接口都暴漏出来
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
//在UsernamePasswordAuthenticationFilter之前加上验证码过滤器
.formLogin()
.loginPage("/mobile-login.html")
.and()
.authorizeRequests()
.antMatchers("/mobile-login.html").permitAll()
.antMatchers("/code/*").permitAll()
.anyRequest()
.authenticated()
.and()
.csrf().disable()
//把SmsCodeAuthenticationSecurityConfig配置加进来
.apply(smsCodeAuthenticationSecurityConfig);
}
}
自定义简单的登录界面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h2>标准登录页面</h2>
<h3>短信登录</h3>
<form action="/authentication/mobile" method="post">
<table>
<tr>
<td>手机号:</td>
<td><input type="text" name="mobile" value="13012345678"></td>
</tr>
<tr>
<td>短信验证码:</td>
<td>
<input type="text" name="smsCode">
<a href="/code/sms?mobile=13012345678">发送验证码</a>
</td>
</tr>
<tr>
<td colspan="2"><button type="submit">登录</button></td>
</tr>
</table>
</form>
</body>
</html>
启动程序测试一下,访问自定义登录页面http://localhost:8080/mobile-login.html
点击发送验证码,控制台打印
返回登录页面输入验证码进行登录,成功打印出登录用户的信息
如果随便输入一个验证码进行登录,校验也没问题
再写一个controller测试一下
@Controller
@RequestMapping("/")
public class HelloController {
private Logger logger = LoggerFactory.getLogger(HelloController.class);
@RequestMapping(value = "hello", method = RequestMethod.GET)
@ResponseBody
public String hello(){
return "ok";
}
}
不登录的情况情况下访问http://localhost:8080/hello会被引导至登录页面,验证码登录后可成功访问
总结
Github源码地址:https://github.com/iemi/mobile-code-login