上一篇文章写了使用spring security、oauth2、JWT 实现了最常用的帐号密码登陆功能,但是现在的对外的在线系统基本至少有2种登录方式,用的最多的就是短信验证码,此种方式的好处有很多,例如天然的可以知道用户的手机号_,下面我们就来利用自定义spring security的认证方式实现短信验证码登陆功能。
功能逻辑
1.用户通过手机获取短信验证码 2.用户填写验证码,提交登陆 3.系统判断用户的验证码是否正确,正确则登陆成功,失败则提示错误
以上3点就是使用短信验证码登陆的基本流程判断,当然在实际过程中,每一步都需要做全面的判断,例如下发验证码之前要判断手机号是否存在,短信验证码是否短时间已经发送过,检验短信验证码是否过期等等,这里只是为了说明如何自定义一个登陆方式,在业务层面就不详细展开说了,只是做一个最底层的自定义短信验证码登录架构。
数据库
短信验证码的表(sms_code)
生成对应的控制层、服务层、持久层文件,可以参考之前的文章。
自定义验证码登陆
自定义登录的方式其实就是模拟spring security 默认帐号密码登录的整个过程。 一、首先我们得有个登陆的入口,也就自定义一个类似的“loadUserByUsername(username)”方法。因为帐号密码的登陆不能去掉,所以我们得自定义一个接口并继承“UserDetailsService”自带的接口
public interface HnUserDetailsService extends UserDetailsService {
default CurrentLoginUser loadUserByPhone(final String phone) throws UsernameNotFoundException {
return null;
}
}
接口中的方法 loadUserByPhone(String phone)就是短信验证码登录的入口方法。
二、模拟登陆的入口路径 前端提交登陆的路径我们默认固定为sms/login,并固定使用POST方式提交。这里就需要定义一个短信验证码登陆的鉴权过滤器。即模拟“UsernamePasswordAuthenticationFilter”
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "phone";
private String mobileParameter = SmsCodeAuthenticationFilter.SPRING_SECURITY_FORM_MOBILE_KEY;
private boolean postOnly = true;
public SmsCodeAuthenticationFilter(final AuthenticationManager authManager,
final AuthenticationSuccessHandler successHandler,
final AuthenticationFailureHandler failureHandler,
final ApplicationEventPublisher eventPublisher) {
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);
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
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
public SmsCodeAuthenticationToken(final Object principal) {
super(null);
this.principal = principal;
this.setAuthenticated(false);
}
public SmsCodeAuthenticationToken(final Object principal,
final Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
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,即判断用户提交过来的验证码是否和数据库中有效的验证码一致,如果一致则表示登陆成功,否则返回错误信息。
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);
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) {
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中添加上短信失败后处理的代码即可。
@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配置文件
新增了短信验证码的登陆,那么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;
@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";
}
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);
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);
final SysSmsCode smsCode = new SysSmsCode();
smsCode.setId(HnIdUtils.getNewId());
smsCode.setPhone(phone);
smsCode.setCodeType(codeType);
smsCode.setCaptcha(captcha);
smsCode.setExpireTime(expireTime);
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");
}
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中
|