上一篇文章写了使用spring security、oauth2、JWT 实现了最常用的帐号密码登陆功能,但是现在的对外的在线系统基本至少有2种登录方式,用的最多的就是短信验证码,此种方式的好处有很多,例如天然的可以知道用户的手机号_,下面我们就来利用自定义spring security的认证方式实现短信验证码登陆功能。
1.用户通过手机获取短信验证码
2.用户填写验证码,提交登陆
3.系统判断用户的验证码是否正确,正确则登陆成功,失败则提示错误
以上3点就是使用短信验证码登陆的基本流程判断,当然在实际过程中,每一步都需要做全面的判断,例如下发验证码之前要判断手机号是否存在,短信验证码是否短时间已经发送过,检验短信验证码是否过期等等,这里只是为了说明如何自定义一个登陆方式,在业务层面就不详细展开说了,只是做一个最底层的自定义短信验证码登录架构。
生成对应的控制层、服务层、持久层文件,可以参考之前的文章。
自定义登录的方式其实就是模拟spring security 默认帐号密码登录的整个过程。
一、首先我们得有个登陆的入口,也就自定义一个类似的“loadUserByUsername(username)”方法。因为帐号密码的登陆不能去掉,所以我们得自定义一个接口并继承“UserDetailsService”自带的接口
/** * 自定义登录用户服务接口 * * @author huangm * @since 2021年9月23日 */ public interface HnUserDetailsService extends UserDetailsService { /** * 手机验证码登录 * * @author huangm * @date 2021年9月23日 * @param phone * 手机号 * @return * @throws UsernameNotFoundException */ default CurrentLoginUser loadUserByPhone(final String phone) throws UsernameNotFoundException { return null; } }
接口中的方法 loadUserByPhone(String phone)就是短信验证码登录的入口方法。
二、模拟登陆的入口路径
前端提交登陆的路径我们默认固定为sms/login,并固定使用POST方式提交。这里就需要定义一个短信验证码登陆的鉴权过滤器。即模拟“UsernamePasswordAuthenticationFilter”
/** * 手机验证码登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现 * * @author huangm * @since 2021年6月7日 */ public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter { /** * form表单中手机号码的字段name */ public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "phone"; private String mobileParameter = SmsCodeAuthenticationFilter.SPRING_SECURITY_FORM_MOBILE_KEY; /** * 是否仅 POST 方式 */ private boolean postOnly = true; public SmsCodeAuthenticationFilter(final AuthenticationManager authManager, final AuthenticationSuccessHandler successHandler, final AuthenticationFailureHandler failureHandler, final ApplicationEventPublisher eventPublisher) { // 短信登录的请求 post 方式的 /sms/login super(new AntPathRequestMatcher("/sms/login", "POST")); this.setAuthenticationManager(authManager); this.setAuthenticationSuccessHandler(successHandler); this.setAuthenticationFailureHandler(failureHandler); this.setApplicationEventPublisher(eventPublisher); } @Override public Authentication attemptAuthentication( final HttpServletRequest request, final HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } String mobile = this.obtainMobile(request); if (mobile == null) { mobile = ""; } mobile = mobile.trim(); System.out.println("mobile 0000****" + mobile); SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile); // Allow subclasses to set the "details" property this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } protected String obtainMobile(final HttpServletRequest request) { return request.getParameter(this.mobileParameter); } protected void setDetails(final HttpServletRequest request, final SmsCodeAuthenticationToken authRequest) { authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request)); } public String getMobileParameter() { return this.mobileParameter; } public void setMobileParameter(final String mobileParameter) { Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null"); this.mobileParameter = mobileParameter; } public void setPostOnly(final boolean postOnly) { this.postOnly = postOnly; } }
三、 自定义放置认证信息的TOKEN
/** * 手机验证码登录 AuthenticationToken,模仿 UsernamePasswordAuthenticationToken 实现 <Br> * 放置认证的信息 * * @author huangm * @since 2021年9月23日 */ public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID; /** * 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名, 在这里就代表登录的手机号码 */ private final Object principal; /** * 构建一个没有鉴权的 SmsCodeAuthenticationToken */ public SmsCodeAuthenticationToken(final Object principal) { super(null); this.principal = principal; this.setAuthenticated(false); } /** * 构建拥有鉴权的 SmsCodeAuthenticationToken */ public SmsCodeAuthenticationToken(final Object principal, final Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; // must use super, as we override // 是否已经认证 super.setAuthenticated(true); } @Override public Object getCredentials() { return null; } @Override public Object getPrincipal() { return this.principal; } @Override public void setAuthenticated(final 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(); } }
四、验证码统一判断(鉴权 Provider)
这个自定义的鉴权 Provider,即判断用户提交过来的验证码是否和数据库中有效的验证码一致,如果一致则表示登陆成功,否则返回错误信息。
/** * 手机验证码登录鉴权 Provider,要求实现 AuthenticationProvider 接口 * * @author huangm * @since 2021年6月7日 */ public class SmsCodeAuthenticationProvider implements AuthenticationProvider { private HnUserDetailsService userDetailsService; @Override public Authentication authenticate(final Authentication authentication) throws AuthenticationException { final SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication; final String phone = (String) authenticationToken.getPrincipal(); final CurrentLoginUser userDetails = this.userDetailsService.loadUserByPhone(phone); // 验证手机验证码是否正确 this.checkSmsCode(userDetails); // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回 final SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities()); authenticationResult.setDetails(authenticationToken.getDetails()); return authenticationResult; } private void checkSmsCode(final CurrentLoginUser userDetails) { final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()) .getRequest(); final String inputCode = request.getParameter("captcha"); if (HnStringUtils.isBlank(inputCode)) { throw new BadCredentialsException("未检测到验证码"); } final Map<String, Object> smsCodeMap = userDetails.getParamsMap(); if (smsCodeMap == null) { throw new BadCredentialsException("未检测到申请验证码"); } final String smsCode = String.valueOf(smsCodeMap.get("captcha")); if (HnStringUtils.isBlankOrNULL(smsCode)) { throw new BadCredentialsException("未检测到申请验证码"); } final int code = Integer.valueOf(smsCode); if (code != Integer.parseInt(inputCode)) { throw new BadCredentialsException("验证码错误"); } } @Override public boolean supports(final Class<?> authentication) { // 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口 return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication); } public UserDetailsService getUserDetailsService() { return this.userDetailsService; } public void setUserDetailsService(final HnUserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }
判断验证码的核心代码就是checkSmsCode(loginUser)方法,这里需要跟loadUserByPhone(phone)实现整合一起分析,后面写到登陆的逻辑时再说这块。
五、登陆成功失败的Handler
登陆成功同帐号密码即可,不用填写,只需要填写登陆失败的即可。即在上一篇文章的 SecurityHandlerConfig.java中添加上短信失败后处理的代码即可。
/** * 短信验证码登陆失败 * * @return */ @Bean public AuthenticationFailureHandler smsCodeLoginFailureHandler() { return new AuthenticationFailureHandler() { @Override public void onAuthenticationFailure( final HttpServletRequest request, final HttpServletResponse response, final AuthenticationException exception) throws IOException, ServletException { String code = null; if (exception instanceof BadCredentialsException) { code = "smsCodeError";// 手机验证码错误 } else { code = exception.getMessage(); } System.out.println("******sms fail**** " + code); HnResponseUtils.responseJson(response, HttpStatus.OK.value(), new ErrorResponse(code)); } }; }
新增了短信验证码的登陆,那么Security的配置文件肯定要坐相应的处理。
SecurityConfig.java :
1.要修改userDetailsService 为上面添加了验证码登陆的HnUserDetailsService
2.要做验证码登陆成功,失败的拦截器处理
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired HnUserDetailsService userDetailsService; @Autowired private AuthenticationSuccessHandler loginSuccessHandler; @Autowired private AuthenticationFailureHandler loginFailureHandler; @Autowired private AuthenticationEntryPoint authenticationEntryPoint; @Autowired private AuthenticationFailureHandler smsCodeLoginFailureHandler; @Autowired private LogoutSuccessHandler logoutSuccessHandler; @Autowired private TokenFilter tokenFilter; @Resource private ApplicationEventPublisher applicationEventPublisher; /** * 登陆页面 */ @Value("${login.loginHTML}") private String loginHtml; /** * 登陆后台处理页面 */ @Value("${login.loginProcessingUrl}") private String loginProcessingUrl; /** * 不拦截的页面 */ @Value("${login.permitAllUrl}") private String permitAllUrl; /** * 密码字段名称,默认password */ @Value("${login.passwordParameter}") private String passwordParameter; public String[] getPermitAllUrl() { final String str = this.permitAllUrl.replaceAll(" ", ""); return str.split(","); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(final HttpSecurity http) throws Exception { http.csrf().disable(); if (HnStringUtils.isBlank(this.passwordParameter)) { this.passwordParameter = "password"; } // 基于token,所以不需要session http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); http.authorizeRequests().antMatchers(this.getPermitAllUrl()).permitAll().anyRequest().authenticated(); http.formLogin().loginPage(this.loginHtml).loginProcessingUrl(this.loginProcessingUrl) .passwordParameter(this.passwordParameter).successHandler(this.loginSuccessHandler) .failureHandler(this.loginFailureHandler).and().exceptionHandling() .authenticationEntryPoint(this.authenticationEntryPoint).and().logout().logoutUrl("/logout") .logoutSuccessHandler(this.logoutSuccessHandler); // 解决不允许显示在iframe的问题 // http.headers().frameOptions().disable(); // http.headers().cacheControl(); http.addFilterBefore(this.tokenFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore( new SmsCodeAuthenticationFilter(this.authenticationManager(), this.loginSuccessHandler, this.smsCodeLoginFailureHandler, this.applicationEventPublisher), UsernamePasswordAuthenticationFilter.class); } @Override protected void configure(final AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(this.userDetailsService).passwordEncoder(this.passwordEncoder()); // 自定义 短信登录身份认证组件 final SmsCodeAuthenticationProvider smsProvider = new SmsCodeAuthenticationProvider(); smsProvider.setUserDetailsService(this.userDetailsService); auth.authenticationProvider(smsProvider); } }
我这里就简单模拟下短信验证码的生成,就不做下发验证码的功能了。
@RestController @RequestMapping("sms") public class SysSmsCodeController { @Autowired ISysSmsCodeService sysSmsCodeService; @PostMapping("createCode") public AjaxResponse createCode(final String phone, final String codeType) { final String captcha = String.valueOf((int) Math.ceil(Math.random() * 9000 + 1000)); final Date date = new Date(); final String expireTime = HnDateUtils.add(date, Calendar.MINUTE, 3, "yyyy-MM-dd HH:mm:ss", Locale.CHINA); // if (true) { // throw new HnException("phoneNonExist"); // } final SysSmsCode smsCode = new SysSmsCode(); smsCode.setId(HnIdUtils.getNewId()); smsCode.setPhone(phone); smsCode.setCodeType(codeType);//验证码的类型,如登陆,找回密码等 smsCode.setCaptcha(captcha);//验证码 smsCode.setExpireTime(expireTime);//3分钟有效期 smsCode.setCreateDateTime(HnDateUtils.format(date, "yyyy-MM-dd HH:mm:ss")); smsCode.setCreateTimeMillis(System.currentTimeMillis()); this.sysSmsCodeService.save(smsCode); System.out.println(HnStringUtils.formatString("{0}:为 {1} 设置短信验证码:{2}", smsCode.getId(), phone, captcha)); return new SucceedResponse("成功生成验证码!"); } }
运行后结果
数据表中如下
为此手机号生成了一个有效验证码,有效期3分钟。
UserDetailsServiceImpl 类修改成实现上面的自定义接口HnUserDetailsService,然后新增验证码登陆方法,
@Override public CurrentLoginUser loadUserByPhone(final String phone) throws UsernameNotFoundException { final QueryWrapper<SysUser> wrapper = new QueryWrapper<SysUser>(); wrapper.eq("phone", phone); final SysUser user = this.sysUserService.getOne(wrapper); if (user == null) { // "手机号不存在" throw new UsernameNotFoundException("phoneNonExist"); } // 获取当前手机号的当前有效验证码,用于框架验证,写入到当前用户的paramsMap final String datetimeStr = HnDateUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss"); final QueryWrapper<SysSmsCode> smsCodeWrapper = new QueryWrapper<SysSmsCode>(); smsCodeWrapper.eq("phone", phone); smsCodeWrapper.eq("code_type", "login"); smsCodeWrapper.ge("expire_time", datetimeStr); SysSmsCode sysSmsCode = null; final CurrentLoginUser loginUser = this.checkUser(user); try { sysSmsCode = sysSmsCodeService.getOne(smsCodeWrapper); loginUser.addAttribute("captcha", sysSmsCode.getCaptcha());// 将当前有效短信验证码写入 } catch (final Exception e) { // 验证码已经过期 throw new UsernameNotFoundException("codeExpireTime"); } return loginUser; }
注意:这里的loginUser.addAttribute(“captcha”, sysSmsCode.getCaptcha());即将当前数据库中的有效验证码写入登陆用户对象的paramsMap中,而上面的
验证码鉴权 Provider 中的方法checkSmsCode()中的final Map<String, Object> smsCodeMap = userDetails.getParamsMap(); 这句话获取的就是这里写入的验证码,然后与前端用户提交过来的验证码作比较即可判断是否一致。
效果如下图:验证码失效
手机号不存在:
验证码错误:
登录成功:
成功的效果跟账号密码登陆成功是一样的,怎样就可以实现两种方式的登陆效果了。
PS:生成短信验证码,登陆短信验证码都需要去掉权限判断,所以要将此路径添加到配置文件yml中