一、导读
短信登录和用户名密码登录的逻辑是不同的,Spring Security 框架中实现的是用户名密码的登录方式。现在我们就模仿它的原理来加入短信登录的认证(注意不是验证),实现右边的。
之前写的图形验证码是在 UsernamePasswordAuthenticationFilter前增加了我们自己的图形验证过滤器,验证成功之后再交给用户名和密码进行认证,调用userDetailsService进行匹配验证。最后通过的话,会进入Authentication已认证流程。短信认证的思路和上面一样:
- SmsCodeAuthenticationFilter 短信登录请求
- SmsCodeAuthenticationProvider 提供短信登录处理的实现类
- SmsCodeAuthenticationToken 存放认证信息(包括未认证前的参数信息传递)
- 最后开发一个过滤器放在 短信登录请求之前,进行短信验证码的验证,
因为这个过滤器只关心提交的验证码是否正常就行了。所以可以应用到任意业务中,对任意业务提交进行短信的验证。
二、开发短信登录功能
1、流程开发
我们首先创建一个SmsCodeAuthenticationToken ,用来产生身份验证令牌。直接复制参考 UsernamePasswordAuthenticationToken 的写法,分析哪些需要哪些是不需要的,稍微修改一下即可(代码都放在core中)。
/*** 类名称 : SmsCodeAuthenticationToken* 功能描述 :手机短信登陆认证令牌* 创建时间 : 2018/11/12 18:56* -----------------------------------*/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
//存放用户名 : credentials 字段去掉,因为短信认证在授权认证前已经过滤了private final Object principal;
/** 功能描述:创建用户名密码身份验证令牌需要使用此构造函数* 返回值:通过身份验证的代码返回false*/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("无法将此令牌设置为可信使用构造函数,该构造函数将接受一个已授予的权限列表");}
super.setAuthenticated(false);}
@Overridepublic void eraseCredentials() {super.eraseCredentials();}
}
然后是手机短信认证登陆过滤器 SmsCodeAuthenticationFilter,仿照的是UsernamePasswordAuthenticationFilter
/*** 类名称 : SmsCodeAuthenticationFilter* 功能描述 :手机短信认证登陆过滤器* -----------------------------------*/
@Slf4j
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
//发送短信验证码 或 验证短信验证码时,传递手机号的参数的名称[mobile]private String mobileParameter = SecurityConstant.DEFAULT_MOBILE_PARAMETER;
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {// 拦截该路径,如果是访问该路径,则标识是需要短信登录super(new AntPathRequestMatcher(SecurityConstant.DEFAULT_LOGIN_PROCESSING_URL_MOBILE, "POST"));}
@Overridepublic Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {if (postOnly && !request.getMethod().equals("POST")){throw new AuthenticationServiceException("不支持该认证方法: " + 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//把request里面的一些信息copy近token里面。后面认证成功的时候还需要copy这信息到新的tokensetDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);}
/** 功能描述:提供身份验证请求的详细属性* 入参:[request 为此创建身份验证请求, authRequest 详细信息集的身份验证请求对象]*/protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {authRequest.setDetails(authenticationDetailsSource.buildDetails(request));}
/** 功能描述:设置用于获取用户名的参数名的登录请求。* 入参:[usernameParameter 默认为“用户名”。]*/public void setMobileParameter(String mobileParameter) {Assert.hasText(mobileParameter, "手机号参数不能为空");this.mobileParameter = mobileParameter;}
/** 功能描述:获取手机号*/protected String obtainMobile(HttpServletRequest request) {return request.getParameter(this.mobileParameter);}
/** 功能描述:定义此筛选器是否只允许HTTP POST请求。如果设置为true,则接收不到POST请求将立即引发异常并不再继续身份认证*/public void setPostOnly(boolean postOnly) {this.postOnly = postOnly;}
public final String getMobileParameter() {return mobileParameter;}
}
接下来实现短信处理器SmsCodeAuthenticationProvider,用于匹配用户信息,如果认证成功加入到认证成功队列。这个没有找到仿照的地方。没有发现和usernamePassword类型的提供provider
/*** 类名称 : SmsCodeAuthenticationProvider* 功能描述 :短信处理器,查询用户信息,成功存放到已认证token* -----------------------------------*/
@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
@Overridepublic Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
//看下面UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {throw new InternalAuthenticationServiceException("无法获取用户信息");}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());//需要把未认证中的一些信息copy到已认证的token中authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;}
@Overridepublic boolean supports(Class<?> authentication) {return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);}
}
但是我们看上面通过token获取用户信息部分
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
我们查看userDetailService接口
public interface UserDetailsService {UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
只有根据username加载的接口,如果我们系统中只有一种登录实现方式的话是没问题的,比如portal系统中只支持用户名密码登录或者只支持短信验证码登录,我们只要在UserDetailsService实现中进行相应处理即可。但是两种方式都支持的话,就必须解决该问题了。我们对UserDetailsService进行扩展
public interface TinUserDetailsService extends UserDetailsService {UserDetails loadUserByMobile(String mobile) throws UsernameNotFoundException;
}
然后我们把上面的UserDetailsService替换成TinUserDetailsService即可
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
if (userDetailsService instanceof TinUserDetailsService){
TinUserDetailsService tinUserDetailsService = (TinUserDetailsService) userDetailsService;
UserDetails user = tinUserDetailsService.loadUserByMobile((String) authenticationToken.getPrincipal());
if (user == null) {throw new InternalAuthenticationServiceException("无法获取用户信息");}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());//需要把未认证中的一些信息copy到已认证的token中authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;}else {throw new InternalAuthenticationServiceException("请实现TinUserDetailsService#loadUserByMobile方法");}
}
这里之所以不直接将UserDetailsService替换成TinUserDetailsService,是为了不影想当不使用手机验证码登录时能正常实现UserDetailsService。这样我们在portal中的实现类就可以分开编写了。
public class UserDetailsServiceImpl implements TinUserDetailsService {
@Autowiredprivate UserRepository userRepository;
/** 功能描述:用户名密码登陆*/@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {log.info("表单登录用户名:" + username);}
/** 功能描述:手机验证码登陆*/@Overridepublic UserDetails loadUserByMobile(String mobile) {log.info("表单登录手机号:" + mobile);}
}
2、加入到security的认证流程
需要的几个东西已经准备好了,这里要进行配置把这些加入到 security的认证流程中去。创建SmsCodeAuthenticationSecurityConfig
@Component
public class SmsCodeAuthenticationSecurityConfigextends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {@Autowiredprivate AuthenticationFailureHandler authenticationFailureHandler;@Autowiredprivate AuthenticationSuccessHandler authenticationSuccessHandler;@Autowiredprivate UserDetailsService userDetailsService;
@Overridepublic void configure(HttpSecurity http) throws Exception {SmsCodeAuthenticationFilter filter = new SmsCodeAuthenticationFilter();// 把该过滤器交给管理器filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));filter.setAuthenticationFailureHandler(authenticationFailureHandler);filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
http// 注册到AuthenticationManager中去.authenticationProvider(smsCodeAuthenticationProvider)// 添加到 UsernamePasswordAuthenticationFilter 之后// 貌似所有的入口都是 UsernamePasswordAuthenticationFilter// 然后UsernamePasswordAuthenticationFilter的provider不支持这个地址的请求// 所以就会落在我们自己的认证过滤器上。完成接下来的认证.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);}
}
这里我们要注意:图上流程,因为最先走的短信认证的过滤器(不是验证码,只是认证)。要使用管理器来获取provider,所以把管理器注册进去。
3、应用方配置
这里是browser的BrowserSecurityConfig。变化的配置用注释标出来了,无变化的把注释去掉了。
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate SecurityProperties securityProperties;
@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
@Autowiredprivate MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;@Autowiredprivate MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowiredprivate DataSource dataSource;@Autowiredprivate PersistentTokenRepository persistentTokenRepository;@Autowiredprivate UserDetailsService userDetailsService;
// 由下面的 .apply(smsCodeAuthenticationSecurityConfigs)方法添加这个配置@Autowiredprivate SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfigs;
@Beanpublic PersistentTokenRepository persistentTokenRepository() {JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();jdbcTokenRepository.setDataSource(dataSource);return jdbcTokenRepository;}
@Overrideprotected void configure(HttpSecurity http) throws Exception {ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();validateCodeFilter.setFailureHandler(myAuthenticationFailureHandler);validateCodeFilter.setSecurityProperties(securityProperties);validateCodeFilter.afterPropertiesSet();
// 短信的是copy图形的过滤器,这里直接copy初始化SmsCodeFilter smsCodeFilter = new SmsCodeFilter();smsCodeFilter.setFailureHandler(myAuthenticationFailureHandler);smsCodeFilter.setSecurityProperties(securityProperties);smsCodeFilter.afterPropertiesSet();http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)// 在这里不能注册到我们自己的短信认证过滤器上,会报错,注意和验证码的顺序.addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class).formLogin().loginPage("/authentication/require").loginProcessingUrl("/authentication/form").successHandler(myAuthenticationSuccessHandler).failureHandler(myAuthenticationFailureHandler).and().rememberMe().tokenRepository(persistentTokenRepository).tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds()).userDetailsService(userDetailsService).and().authorizeRequests().antMatchers("/authentication/require",securityProperties.getBrowser().getLoginPage(),"/code/*","/error").permitAll().anyRequest().authenticated().and().csrf().disable()// 这里应用短信认证配置.apply(smsCodeAuthenticationSecurityConfigs);}
}
我们再看一下登录页面的表单的提交代码,/authentication/mobile登录地址,就是我们认证过滤器里面的支持地址(在browser中)。
<h3>短信验证码</h3>
<form action="/authentication/mobile" method="post"><table><tr><td>手机号:</td><td><input type="text" name="mobile"></td></tr><tr><td>短信验证码:</td><td><input type="text" name="smsCode"><a href="/code/sms?mobile=13012345678">发送验证码</a></td></tr><tr><td><button type="submit">登录</button></td></tr></table>
</form>
然后我们就可以访问登录页面,点击发送短信验证码,然后在后台复制真正发送的验证码添加,提交短信登录,进行测试。自定义认证逻辑就完成了,大致步骤就是:
- 入口配置 应用方使用该配置 .apply(smsCodeAuthenticationSecurityConfigs)
- 提供处理过滤器 ProcessingFilter 并限制该过滤器支持拦截的url
- 提供AuthenticationProvider 进行认证的处理支持
- 把ProviderManager 赋值给 ProcessingFilter
- 把AuthenticationProvider注册到AuthenticationManager中去(这里完成ProcessingFilter调用管理器查找Provider,完成认证这个过程)
- 把 ProcessingFilter 添加到 认证处理链中 ,之后(也就是UsernamePasswordAuthenticationFilter)
现在讲一下关于验证码,我们发现上面的处理流程其实和短信验证码没有关系,只是验证手机号信息。但事实上我们已经编写了短信验证码的逻辑,我们只需要在入口配置中( 应用方)把验证码(验证是否有效,是否过期)的过滤器添加到认证处理链之前(也就是UsernamePasswordAuthenticationFilter),就是在进入认证之前先把验证码是否有效验证了,那么在进行身份认证的过程中其实是无需关注验证码的。