认证篇:Spring Security 自定义验证码登录(下)之登录流程讲解

图 1-0 总结图

本篇为自定义验证码登录的下篇,主要会对自定义登录的流程进行讲解;我们曾在认证(二):表单登录的源码解析中对 Spring Security的认证流程有过比较详细的学习。

“模仿不是创作,但创作不能不有模仿”————周谷城。

我们可以从表单登录认证流程中寻找灵感,定制化开发出验证码登录认证。

流程分析

首先先回顾一下表单登录的认证流程:

图1-1 表单登录认证流程

大致上有这么几个比较核心的类/接口:

  1. UsernamePasswordAuthenticationFilter过滤器,用以捕获表单登录请求。

  2. UsernamePasswordAuthenticationToken表单登录token,用以匹配对应的认证Provider

  3. AuthenticationManager认证管理器,即不干活的"大佬"。

  4. ProviderManager管理领域的"二把手"。

  5. DaoAuthenticationProvider执行表单登录认证的Provider,即真正干活的"小弟"。

分析得出,除了管理领域的“大佬” AuthenticationManager和 “二把手” ProviderManager不需要我们额外实现,剩下的我们都需要自定义实现。

基于这个结论,我大致描绘出验证码登录认证的流程图:
图 1-2 验证码登录认证类比图

自定义验证码认证,需要将FilterToken以及 Provider嵌入到整个认证链路中;所以自定义认证需要做以下3件事情:

  • 自定义ValidateCodeAuthenticationFilter,类比UsernamePasswordAuthenticationFilter

  • 自定义SmsValidateCodeAuthenticationToken,类比UsernamePasswordAuthenticationToken

  • 自定义SmsValidateCodeAuthenticationProvider,类比DaoAuthenticationProvider

流程实现

通过上面的流程分析,我们可以大致弄清楚需要做什么事情了;那么 Just Do it!!!

自定义ValidateCodeAuthenticationFilter

ValidateCodeAuthenticationFilter拦截器的作用是:从请求中获取验证码进行验证。

可能有人会有疑问:从请求中获取验证码进行验证,听起来似乎是 Provider做的事情呀,为什么会放在这里呢(类比表单登录的 Filter只是做了请求参数到 Token的转换,真实的校验是在 Provider中)?

这里这么做是为了复用验证码的功能,在很多时候我们都用使用验证码的需求:修改密码、付款…… 如果将验证码的校验放在 Provider模块的话,一定程度上限制了验证码只能用于登录。

ValidateCodeAuthenticationFilter.class

/**
 * 验证码拦截器(自定义springSecurity过滤器实现,在登录之前进行验证码验证)
 *
 *  InitializingBean接口为bean提供了初始化方法的方式,它只包括afterPropertiesSet方法,
 *  凡是继承该接口的类,在初始化bean的时候都会执行该方法。
 *
 * @author 小奇
 * @date 2020/10/08
 **/
@Slf4j
@Component("ValidateCodeAuthenticationFilter")
public class ValidateCodeAuthenticationFilter extends OncePerRequestFilter implements InitializingBean {
    
    


    /**
     * key为请求的路径 map为验证码类型
     * 存放所有需要校验验证码的url(例如:验证码登录、支付时需要填写验证码等等……)
     */
    private Map<String, ValidateCodeEnum> urlMap = new HashMap<>(8);

    /**
     * 验证请求url与配置的url是否匹配的工具类
     */
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /**
     * 通过配置文件的方式配置 一些需要验证码认证的url
     */
    @Autowired
    private SecurityProperties securityProperties;

    /**
     * 认证失败处理器
     */
    @Autowired
    private WebAuthenticationFailHandler failureHandler;

    /**
     * Holder是对Processor的更上一级调用
     */
    @Autowired
    private ValidateCodeProcessorHolder validateCodeProcessorHolder;

    /**
     * 初始化本类的Bean对象时候,会执行该方法进行相应的初始化
     */
    @Override
    public void afterPropertiesSet() {
    
    
        initUrlMap(securityProperties.getCode().getImage().getUrls(), ValidateCodeEnum.IMAGE);
        initUrlMap(securityProperties.getCode().getSms().getUrls(), ValidateCodeEnum.SMS);
    }

    /**
     * 拦截 校验验证码
     *
     * @param request
     * @param response
     * @param chain
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
    
    

        // 获取请求的类型 生成对应的key
        Optional<ValidateCodeEnum> codeEnumOpt = findValidateCodeType(request);
        ValidateCodeEnum codeEnum = codeEnumOpt.orElseThrow(() -> new ValidateCodeException("请求不需要做验证码拦截"));
        log.info("校验请求(" + request.getRequestURI() + ")中的验证码,验证码类型" + codeEnum);
        try {
    
    
            validateCodeProcessorHolder.findValidateCodeProcessor(codeEnum).validate(new ServletWebRequest(request, response));
            log.info("验证码校验通过");
        } catch (ValidateCodeException exception) {
    
    
            failureHandler.onAuthenticationFailure(request, response, exception);
            return;
        }
        // 执行后续拦截器链
        chain.doFilter(request, response);
    }

    /**
     * 根据请求request获取请求的校验码类型
     * 如果该请求不需要进行验证验证码拦截则返回空对象 验证码只针对POST请求
     * 限制了一个请求路径只能有一种验证码校验
     *
     * @param request
     * @return Optional<ValidateCodeEnum>
     */
    private Optional<ValidateCodeEnum> findValidateCodeType(HttpServletRequest request) {
    
    
        if (StringUtils.equalsIgnoreCase(request.getMethod(), HttpConstant.POST)) {
    
    
            Set<String> keySet = urlMap.keySet();
            return keySet.stream().filter(key -> pathMatcher.match(key, request.getRequestURI())).map(key -> urlMap.get(key)).findFirst();
        }
        log.warn("校验验证码---请求方式不符合要求");
        return Optional.empty();
    }


    /**
     * 装载匹配对应的拦截url到map中
     * 配置文件中url串以逗号分割(这个可自定义 并无强制要求,这里只是提供一种实现的方案)
     * @param urls
     * @param codeEnum
     */
    private void initUrlMap(String urls, ValidateCodeEnum codeEnum) {
    
    
        if (StringUtils.isNotBlank(urls)) {
    
    
            String[] urlArr = urls.split(CommonConstant.COMMA);
            Arrays.stream(urlArr).forEach(url -> urlMap.put(url, codeEnum));
        }
    }

}

我们可以看到 ValidateCodeAuthenticationFilter类继承了 OncePerRequestFilter并且实现于 InitializingBean,那么这两个类/接口各自有什么用呢?

OncePerRequestFilter能够确保在一次请求中只通过一次 Filter,而不需要重复的执行。大家常识上都认为,一次请求本来就只Filter一次,为什么还要由此特别限定呢?

往往我们的常识和实际的实现并不真的一样,经过一番资料的查阅,此方法是为了兼容不同的 Web Container,也就是说并不是所有的 Container都入我们期望的只过滤一次,servlet版本不同,执行过程也不同;而 OncePerRequestFilter就能够确保一次请求指通过一次 Filiter

InitializingBean是一个初始化设置相关的配置,Spring在初始化bean的时候,如果bean实现了 InitializingBean接口,那么会自动调用 afterPropertiesSet方法进行初始化。

自定义SmsValidateCodeAuthenticationToken

SmsValidateCodeAuthenticationToken的作用类比于 UsernamePasswordAuthenticationToken,认证成功之前代表 认证参数信息,认证成功之后代表 用户信息

故而在实现上,我们也可以模仿 UsernamePasswordAuthenticationToken

SmsValidateCodeAuthenticationToken.class

/**
 * 自定义登录方式---短信验证码登录
 * 类比UsernamePasswordAuthenticationToken
 *
 * @author 小奇
 * @date 2020/10/08
 **/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
    
    


    private static final long serialVersionUID = 530L;

    /**
     * 登录之前放手机号码、登录之后放用户的信息
     */
    private final Object principal;

    /**
     * 认证之前的构造器
     *
     * @param mobile 认证请求mobile参数
     */
    public SmsCodeAuthenticationToken(String mobile) {
    
    
        super(null);
        this.principal = mobile;
        // 没认证之前设置为false
        this.setAuthenticated(false);
    }

    /**
     * 认证成功之后的构造器
     *
     * @param principal 用户信息
     * @param authorities 用户权限
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
    
    
        super(authorities);
        this.principal = principal;
        // 已认证 设置为true
        super.setAuthenticated(true);
    }


    /**
     * 这个是登录的密码 这里不需要 直接返回nul
     */
    @Override
    public Object getCredentials() {
    
    
        return null;
    }

    @Override
    public Object getPrincipal() {
    
    
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
    
    
        if (isAuthenticated) {
    
    
            throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        } else {
    
    
            super.setAuthenticated(false);
        }
    }

    /**
     * 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
     * 用以保护密码不直接返回前端
     */
    @Override
    public void eraseCredentials() {
    
    
        super.eraseCredentials();
    }
}

自定义SmsValidateCodeAuthenticationProvider

自定义 SmsValidateCodeAuthenticationProvider,类比 DaoAuthenticationProvider;主要功能为:根据用户输入的手机号,进行数据库用户的匹配校验(验证码校验已抽离到 Filter)。

SmsCodeAuthenticationProvider.class

/**
 * Provider 真实做验证操作,类比账号密码登录的DaoAuthenticationProvider
 * @author 小奇
 * @date 2020/10/08
 **/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
    
    


    /**
     * 具体根据项目中用以表示用户信息的service
     */
    private final HrService hrService;

    /**
     * 构造器注入
     * @param hrService
     */
    public SmsCodeAuthenticationProvider(HrService hrService) {
    
    
        this.hrService = hrService;
    }

    /**
     * 身份验证的逻辑
     * @param authentication
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    
    
        // 通过手机号去查询用户  authenticationToken 没认证
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
        Hr hr = hrService.getUserInfoByPhone((String) authentication.getPrincipal());

        if (hr == null) {
    
    
            throw new InternalAuthenticationServiceException("无法根据手机号:" + authentication.getPrincipal() + "获取用户信息");
        }

        // 放入用户信息以及权限 以及设置详细信息  authenticationResult 认证之后
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(hr, hr.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;

    }


    /**
     * 判断该provider能够支持的authenticationToken验证
     * ProviderManager就是根据该方法,判断一个Provider是否支持对应的认证模式
     *
     * @param authenticationToken
     * @return boolean
     */
    @Override
    public boolean supports(Class<?> authenticationToken) {
    
    
        // 判断传入的authenticationToken是否是SmsCodeAuthenticationToken类型
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authenticationToken);
    }
}

至此,我们大致已经将自定义验证码登录认证流程所需的模块补充完整,接下来就需要将它们"嵌入"到认证链路上;也即是配置。

SmsCodeSecurityConfig.class

/**
 * 短信验证码登录的配置(也可以直接在WebSecurityConfig中配置,抽离出来是为了方便后续的维护)
 *
 * @author 小奇
 * @date 2020/10/08
 **/
@Component
public class SmsCodeSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    
    

    /**
    * 成功处理器
    */
    @Autowired
    private WebAuthenticationSuccessHandler successHandler;

    /**
    * 失败处理器
    */
    @Autowired
    private WebAuthenticationFailHandler failHandler;

    @Autowired
    private HrService hrService;

    @Override
    public void configure(HttpSecurity httpSecurity) {
    
    

        // 1. 配置SmsCodeAuthenticationFilter (类比UsernamePasswordAuthenticationFilter)
        // 配置AuthenticationManager、 成功处理和失败处理
        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(httpSecurity.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(successHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(failHandler);

        // 2. 配置自定义的SmsCodeAuthenticationProvider 类比DaoAuthenticationProvider
        // 配置hrService 用于读取用户信息
        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider(hrService);


        // 3. 将自定义的provider添加到AuthenticationManager管理的Provider集合中;
        // 并且配置带UsernamePasswordAuthenticationFilter之后
        httpSecurity.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

总结

本篇文章为自定义验证码登录认证的下篇,主要对认证流程进行讲解;至此自定义验证码登录认证就介绍完拉!文中主要截取了核心代码进行展示,后续会将完整的项目代码放到github上~

本文为我在之前在b站上学习某大佬课程过程中所做的学习笔记,可能会出现理解上的偏差,如有错误或不妥指出,烦请指出!

文章到这就结束拉,欢迎大家关注小奇公众号~
请添加图片描述

猜你喜欢

转载自blog.csdn.net/weixin_46920376/article/details/108984205