spring security——短信验证码登录(四)

一、导读

        短信登录和用户名密码登录的逻辑是不同的,Spring Security 框架中实现的是用户名密码的登录方式。现在我们就模仿它的原理来加入短信登录的认证(注意不是验证),实现右边的

spring security——短信验证码登录(四)_第1张图片

之前写的图形验证码是在 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 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 {
    @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中)。

短信验证码

手机号:
短信验证码: 发送验证码

然后我们就可以访问登录页面,点击发送短信验证码,然后在后台复制真正发送的验证码添加,提交短信登录,进行测试。自定义认证逻辑就完成了,大致步骤就是:

  1. 入口配置 应用方使用该配置 .apply(smsCodeAuthenticationSecurityConfigs)
  2. 提供处理过滤器 ProcessingFilter 并限制该过滤器支持拦截的url
  3. 提供AuthenticationProvider 进行认证的处理支持
  4. 把ProviderManager 赋值给 ProcessingFilter
  5. 把AuthenticationProvider注册到AuthenticationManager中去(这里完成ProcessingFilter调用管理器查找Provider,完成认证这个过程)
  6. 把 ProcessingFilter 添加到 认证处理链中 ,之后(也就是UsernamePasswordAuthenticationFilter)

现在讲一下关于验证码,我们发现上面的处理流程其实和短信验证码没有关系,只是验证手机号信息。但事实上我们已经编写了短信验证码的逻辑,我们只需要在入口配置中( 应用方)把验证码(验证是否有效,是否过期)的过滤器添加到认证处理链之前(也就是UsernamePasswordAuthenticationFilter),就是在进入认证之前先把验证码是否有效验证了,那么在进行身份认证的过程中其实是无需关注验证码的。

你可能感兴趣的:(spring,security)