一、导读
短信登录和用户名密码登录的逻辑是不同的,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);
}
@Override
public 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"));
}
@Override
public 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这信息到新的token
setDetails(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;
@Override
public 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;
}
@Override
public 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 {
@Autowired
private UserRepository userRepository;
/*
* 功能描述:用户名密码登陆
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("表单登录用户名:" + username);
}
/*
* 功能描述:手机验证码登陆
*/
@Override
public UserDetails loadUserByMobile(String mobile) {
log.info("表单登录手机号:" + mobile);
}
}
2、加入到security的认证流程
需要的几个东西已经准备好了,这里要进行配置把这些加入到 security的认证流程中去。创建SmsCodeAuthenticationSecurityConfig
@Component
public class SmsCodeAuthenticationSecurityConfig
extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private UserDetailsService userDetailsService;
@Override
public 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 {
@Autowired
private SecurityProperties securityProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
private MyAuthenticationFailureHandler myAuthenticationFailureHandler;
@Autowired
private DataSource dataSource;
@Autowired
private PersistentTokenRepository persistentTokenRepository;
@Autowired
private UserDetailsService userDetailsService;
// 由下面的 .apply(smsCodeAuthenticationSecurityConfigs)方法添加这个配置
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfigs;
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
@Override
protected 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),就是在进入认证之前先把验证码是否有效验证了,那么在进行身份认证的过程中其实是无需关注验证码的。