Spring Security实现短信验证码登录
概述
手机验证码登录是目前很常见的一种登录方式,本文阐述基于 快速实现手机验证码登录。本文建立在你对 基本原理有所了解的基础上,有兴趣的同学戳这里是关于 基本原理的文章:
本文实验环境:
思路
实现步骤
加入依赖
org.springframework.boot spring-boot-starter-security commons-lang commons-lang
创建手机验证码实体
第一步先创建一个手机验证码实体类,包含验一个证码字段和一个过期时间字段,还提供了两种构造器,提供两种不同设置过期时间的方式
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;}
}
创建验证码生成器
封装一个验证码生成器,方法生成一个四位数验证码
@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);}
}
生成验证码接口
创建一个接口用于生成验证,此接口会向外暴漏出来,不被 过滤器拦截,下文会写详细的 配置。接口内生成验证码,并且作为key-value键值对保存在中,最后向手机号发送验证码
@RestController
public class ValidateCodeController {@Autowiredprivate SmsCodeGenerator smsCodeGenerator;@Autowiredprivate 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是//(手机验证码登录接口)和请求方式是POST的请求,做验证码校验。校验包括验证码是否过期等
public class ValidateCodeFilter extends OncePerRequestFilter {private AuthenticationFailureHandler authenticationFailureHandler;@Overrideprotected 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);}
}
还有一个认证失败的,失败处理是在返回中打印错误信息返回体,就是一个简单的服务器返回体
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {private Logger logger = LoggerFactory.getLogger(getClass());@Autowiredprivate ObjectMapper objectMapper;@Overridepublic 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;}
}
参照 表单登录的流程自定义一套手机验证码的登录流程
有看过 源码的同学应该知道表单登录的实现流程,大致如下:
在这进行手机验证码的登录,参照表单登录流程,咋们来实现一个一样的手机验证码登录流程
参考、源码,实现自定义和
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"));}@Overridepublic 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" propertysetDetails(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);}@Overridepublic void eraseCredentials() {super.eraseCredentials();}
}
自定义一个处理登录逻辑并返回经过认证的用户信息,通过重写方法实现向r传入时使用处理认证
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {private UserDetailsService userDetailsService;@Overridepublic 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;}@Overridepublic boolean supports(Class> aClass) {return SmsCodeAuthenticationToken.class.isAssignableFrom(aClass);}public UserDetailsService getUserDetailsService() {return userDetailsService;}public void setUserDetailsService(UserDetailsService userDetailsService) {this.userDetailsService = userDetailsService;}}
中含有一个是用来查找用户的,在这只是模拟过程没有真实的进行数据库查找
@Component
public class MyUserDetailService implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {/*** 这里实际情况应该是根据参数s查询数据库用户数据*/return new User(s,"123", AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));}}
把各个组件拼装起来并且把加入到 的过滤器链
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter {@Autowiredprivate AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;@Autowiredprivate AuthenticationFailureHandler imoocAuthenticationFailureHandler;@Autowiredprivate UserDetailsService myUserDetailsService;@Overridepublic void configure(HttpSecurity builder) throws Exception {SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();smsCodeAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));//配置smsCodeAuthenticationFilter成功和失败的处理器smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);smsCodeAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);//设置SmsCodeAuthenticationProvider的UserDetailsServiceSmsCodeAuthenticationProvider 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);@Autowiredprivate ObjectMapper objectMapper;@Overridepublic 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));}
}
配置和测试
配置中把加到之前,把上一步配置加入。使用自定义登录界面,并且把登录界面和获取验证码的接口都暴漏出来
@Configuration
public class SecurityBrowserConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate AuthenticationFailureHandler myAuthenticationFailureHandler;@Autowiredprivate SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;@Overrideprotected 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);}
}
自定义简单的登录界面
登录
标准登录页面
短信登录
启动程序测试一下,访问自定义登录页面:8080/-login.html
登录页面
控制台打印
返回登录页面输入验证码进行登录,成功打印出登录用户的信息
成功登录
如果随便输入一个验证码进行登录,校验也没问题
验证码不匹配
再写一个测试一下
@Controller
@RequestMapping("/")
public class HelloController {private Logger logger = LoggerFactory.getLogger(HelloController.class);@RequestMapping(value = "hello", method = RequestMethod.GET)@ResponseBodypublic String hello(){return "ok";}
}
不登录的情况情况下访问:8080/hello会被引导至登录页面,验证码登录后可成功访问
总结
源码地址: