一、理论说明

在开始编码前,先理解下短信验证码的实现流程。如果你能对《SpringBoot集成Spring Security(7)——认证流程》这篇文章有一定的了解的话,那么这篇文章的学习你会轻松许多。

1.1 用户名密码登录逻辑

废话不多说,在上一篇文章中,以标准的用户名密码登录为例,讲解了整个认证流程。大致流程如下:

  1. 先进入 UsernamePasswordAuthenticationFilter 中,根据输入的用户名和密码信息,构造出一个暂时没有鉴权的 UsernamePasswordAuthenticationToken,并将 UsernamePasswordAuthenticationToken 交给 AuthenticationManager处理。

  2. AuthenticationManager本身并不做验证处理,他通过 for-each 遍历找到符合当前登录方式的一个 AuthenticationProvider,并交给它进行验证处理,对于用户名密码登录方式,这个 Provider 就是 DaoAuthenticationProvider

  3. 在这个 Provider 中进行一系列的验证处理,如果验证通过,就会重新构造一个添加了鉴权的 UsernamePasswordAuthenticationToken,并将这个 token 传回到 UsernamePasswordAuthenticationFilter 中。

  4. 在该 Filter 的父类 AbstractAuthenticationProcessingFilter 中,会根据上一步验证的结果,跳转到 successHandler 或者是 failureHandler。

    img

    ​ Spring Security 认证流程(部分)

1.2 短信验证码登录逻辑

我们可以仿照用户名密码登录的逻辑,来实现短信验证码的登录逻辑。

  1. 用户名密码登录有个 UsernamePasswordAuthenticationFilter ,我们搞一个 SmsAuthenticationFilter,代码粘过来改一改。
  2. 用户名密码登录需要 UsernamePasswordAuthenticationToken,我们搞一个 SmsAuthenticationToken,代码粘过来改一改。
  3. 用户名密码登录需要 DaoAuthenticationProvider,我们模仿它也implenments AuthenticationProvider,叫做 SmsAuthenticationProvider

短信登录验证逻辑

​ 短信登录验证逻辑

我们自己搞了上面三个类以后,想要实现的效果如上图所示。当我们使用短信验证码登录的时候:

  1. 先经过 SmsAuthenticationFilter,构造一个没有鉴权的 SmsAuthenticationToken,然后交给 AuthenticationManager 处理。
  2. AuthenticationManager 通过 for-each 挑选出一个合适的 provider 进行处理,当然我们希望这个 provider 要是 SmsAuthenticationProvider
  3. 验证通过后,重新构造一个有鉴权的 SmsAuthenticationToken,并返回给 SmsAuthenticationFilter
  4. filter 根据上一步的验证结果,跳转到成功或者失败的处理逻辑。

二、代码实战

请通过 github 链接下载第一章代码,或者参看《SpringBoot集成Spring Security(1)——入门程序》初始化项目,这里就不再赘述了。

2.1 SmsAuthenticationToken

首先我们编写 SmsAuthenticationToken,这里直接参考 UsernamePasswordAuthenticationToken 源码,直接粘过来,改一改。

步骤:

  1. principal 原本代表用户名,这里保留,只是代表了手机号码。
  2. credentials 原本代码密码,短信登录用不到,直接删掉。
  3. SmsCodeAuthenticationToken() 两个构造方法一个是构造没有鉴权的,一个是构造有鉴权的。
  4. 剩下的几个方法去除无用属性即可。
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = 1L;
    /** 表示用户的手机号 **/
    private final Object principal;

    /** 构建一个没有鉴权的SmsAuthenticationToken **/
    public SmsAuthenticationToken(Object principal){
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    /** 构建一个有鉴权的SmsAuthenticationToken **/
    public SmsAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

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

    @Override
    public Object getCredentials() {
        return null;
    }

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

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

2.2 SmsAuthenticationFilter

然后编写 SmsAuthenticationFilter,参考 UsernamePasswordAuthenticationFilter 的源码,直接粘过来,改一改。

步骤:

  1. 原本的静态字段有 username 和 password,都干掉,换成我们的手机号字段。

  2. SmsCodeAuthenticationFilter() 中指定了这个 filter 的拦截 Url,我指定为 post 方式的 /sms/login

  3. 剩下来的方法把无效的删删改改就好了。

    public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
        /** form表单中手机号码的字段name **/
        private static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";
        private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
        /** 是否仅为post方式 **/
        private boolean postOnly = true;
    
        public SmsAuthenticationFilter() {
            //短信登录请求post方式的/sms/login
            super(new AntPathRequestMatcher("/sms/login","POST"));
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
            if(postOnly && !request.getMethod().equals("POST")){
                throw new AuthenticationServiceException("Authentication method not support:"+request.getMethod());
            }
            String mobile = obtainMobile(request);
            
            if(mobile == null){
                mobile = "";
            }
            mobile = mobile.trim();
            SmsAuthenticationToken authRequest = new SmsAuthenticationToken(mobile);
            setDetails(request,authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    
        private void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) {
            authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
        }
    
        private String obtainMobile(HttpServletRequest request) {
            return request.getParameter(mobileParameter);
        }
    
        public String getMobileParameter() {
            return mobileParameter;
        }
    
        public void setMobileParameter(String mobileParameter) {
            Assert.hasText(mobileParameter,"Mobile parameter must not be empty or null");
            this.mobileParameter = mobileParameter;
        }
    
        public boolean isPostOnly() {
            return postOnly;
        }
    
        public void setPostOnly(boolean postOnly) {
            this.postOnly = postOnly;
        }
    }

2.3 SmsAuthenticationProvider

这个方法比较重要,这个方法首先能够在使用短信验证码登录时候被 AuthenticationManager 挑中,其次要在这个类中处理验证逻辑。

步骤:

  1. 实现 AuthenticationProvider 接口,实现 authenticate() 和 supports() 方法。

  2. supports()方法决定了这个 Provider 要怎么被 AuthenticationManager 挑中,

    我这里通过 return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication),处理所有 SmsCodeAuthenticationToken 及其子类或子接口。

  3. authenticate()方法处理验证逻辑。

    1. 首先将 authentication 强转为 SmsCodeAuthenticationToken
    2. 从中取出登录的 principal,也就是手机号。
    3. 调用自己写的 checkSmsCode() 方法,进行验证码校验,如果不合法,抛出 AuthenticationException 异常。
    4. 如果此时仍然没有异常,通过调用 loadUserByUsername(mobile) 读取出数据库中的用户信息。
    5. 如果仍然能够成功读取,没有异常,这里验证就完成了。
    6. 重新构造鉴权后的 SmsCodeAuthenticationToken,并返回给 SmsCodeAuthenticationFilter 。
  4. SmsCodeAuthenticationFilter 的父类在 doFilter() 方法中处理是否有异常,是否成功,根据处理结果跳转到登录成功/失败逻辑。

public class SmsAuthenticationProvider implements AuthenticationProvider {
    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
        String mobile = (String) authenticationToken.getPrincipal();
        //验证码校验
        checkSmsCode(mobile);
        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
        // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
        SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(userDetails.getAuthorities(), userDetails);
        smsAuthenticationToken.setDetails(authenticationToken.getDetails());
        return smsAuthenticationToken;
    }

    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String inputCode = request.getParameter("smsCode");

        Map<String, Object> smsCode = (Map<String, Object>) request.getSession().getAttribute("smsCode");
        if(smsCode == null){
            throw new BadCredentialsException("未检测到申请验证码");
        }
        String applyMobile = (String) smsCode.get("mobile");
        int code = (int) smsCode.get("code");

        if(!applyMobile.equals(mobile)){
            throw new BadCredentialsException("申请的手机号与登录的手机号不一致");
        }
        if(code != Integer.parseInt(inputCode)){
            throw new BadCredentialsException("验证码错误");
        }

    }

    @Override
    public boolean supports(Class<?> authentication) {
        //判断Authentication是不是SmsCodeAuthenticationToken的子类或子接口
        return  SmsAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

2.4 成功与失败处理逻辑

上面最后说到,在 SmsCodeAuthenticationFilter 的父类,会根据验证结果跳转到成功或失败处理逻辑,现在我们就编写下这个的处理。

这里之前也说过了,直接贴代码了,如果有疑问,请参考《SpringBoot集成Spring Security(6)——登录管理》

验证成功处理:

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Autowired
    ObjectMapper objectMapper;
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

验证失败处理

@Component
public class CustomAuthenticationFailurehandler implements AuthenticationFailureHandler {
    private Logger logger = LoggerFactory.getLogger(getClass());
    @Autowired
    ObjectMapper objectMapper;
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        logger.info("登陆失败");
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
    }
}

2.5 SmsCodeAuthenticationSecurityConfig

下面我们需要把我们自己写的这么多类添加进 Spring Security 框架中,在以往,我们都是直接往 WebSecurityConfig 中加,但是这样会导致 WebSecurityConfig 内容太多,难以维护。

因此我们可以为每种登录方式都建议一个专属于它的配置文件,再把这个配置文件加入到 WebSecurityConfig 中,进行解耦。

因此建立短信验证码登录的配置文件 SmsCodeAuthenticationSecurityConfig:

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    UserDetailsService userDetailsService;
    @Autowired
    CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    @Autowired
    CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    public void configure(HttpSecurity builder) throws Exception {
        SmsAuthenticationFilter smsAuthenticationFilter = new SmsAuthenticationFilter();
        smsAuthenticationFilter.setAuthenticationManager(builder.getSharedObject(AuthenticationManager.class));
        smsAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
        
        SmsAuthenticationProvider smsAuthenticationProvider = new SmsAuthenticationProvider();
        smsAuthenticationProvider.setUserDetailsService(userDetailsService);
        
        builder.authenticationProvider(smsAuthenticationProvider).addFilterAfter(smsAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

在这个配置文件中,首先给 SmsCodeAuthenticationFilter 指定了:

  1. AuthenticationManager:不指定这个上面的流程图就断掉了。
  2. 指定登录成功/失败处理逻辑,方便其父类调用。

然后指定了 SmsCodeAuthenticationProvider,并指定了 UserDetailsService ,方便在验证处理时候通过 loadUserByUsername() 读取出数据库中的用户信息。

最后将 filter 和 provider 都加入 HttpSecurity 配置中。

另外说两句:

开头就说过了,为了方便介绍,写最少的代码,因此这是一个假的短信登录。如果你看这里的 UserDetailsService 的代码话,你会发现它是从数据库中根据 name 获取信息的,我其实就是把用户名来当手机号用。

因此,如果你想根据数据库中其他字段,例如 phone 来得到用户信息,可以再写一个叫做 SmsUserDetailsService,在这里注入到 provider 中。

2.6 WebSecurityConfig

下面我们就需要把自己写的 SmsCodeAuthenticationSecurityConfig 加入到 WebSecurityConfig 中了。

首先将 SmsCodeAuthenticationSecurityConfig 注入进来,然后通过 http.apply(xxx) 添加进去。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.apply(smsCodeAuthenticationSecurityConfig)
            .and()
            .authorizeRequests()
                // 如果有允许匿名的url,填在下面
                .antMatchers("/sms/**").permitAll()
                .anyRequest().authenticated()
                .and()
                // 设置登陆页
                .formLogin().loginPage("/login")
                // 设置登陆成功页
                .defaultSuccessUrl("/").permitAll()
                .and()
                .logout().permitAll();

        // 关闭CSRF跨域
        http.csrf().disable();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 设置拦截忽略文件夹,可以对静态资源放行
        web.ignoring().antMatchers("/css/**", "/js/**");
    }
}

2.7 接口与页面

然后在 controller 中写一个读取验证码的接口,注意这个接口在 WebSecurityConfig 中要放行:

   @RequestMapping("/sms/code")
    @ResponseBody
    public void sms(String mobile, HttpSession session){
        int code = (int)Math.ceil(Math.random()*9000+1000);

        Map<String,Object> map = new HashMap<>(16);
        map.put("mobile",mobile);
        map.put("code",code);
        
        session.setAttribute("smsCode",map);
        logger.info("{}:为{}设置短信验证码:{}",session.getId(),mobile,code);
    }
}

然后修改 login.html 页面,添加短信登录的内容:

注意这里的登录 Url /sms/login 是配置在 SmsAuthenticationFilter 中的 Url。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<h1>欢迎来到登录页面</h1>
<form action="/login" method="post">
    <div>
        用户名:<input type="text" name="username">
    </div>
    <div>
        密码:<input type="password" name="password">
    </div>
    <div>
        <button type="submit">立即登录</button>
    </div>
</form>
<br>
<form method="post" action="/sms/login">
    <div>
        手机号:<input type="text" id="mobile" name="mobile" value="jitwxs">
    </div>
    <div>
        验证码:<input type="text" name="smsCode">
        <a href="javascript:;" onclick="sendSms()">获取验证码</a>
    </div>
    <div>
        <button type="submit">立即登陆</button>
    </div>
</form>

<script>
    function sendSms() {
        window.location.href = '/sms/code?mobile=' + document.getElementById("mobile").value;
    }
</script>
</body>
</html>

三、测试代码

因此只是测试,所以页面懒得弄了,当你输入手机号(实际上就是用户名),点击获取验证码,页面会跳转出去,然后自己再按一下浏览器的后退键退回来。

在控制台就会打印当前用户的 sessionId,以及为哪一个手机号所申请的验证码。