程序项目代做,有需求私信(vue、React、Java、爬虫、电路板设计、嵌入式linux等)

Spring Security -- 短信验证码登录(转载)

Spring Security -- 添加图形验证码一节中,我们已经实现了基于Spring Boot + Spring Security的账号密码登录,并集成了图形验证码功能。当前另一种非常常见的网站登录方式为手机短信验证码登录,但Spring Security默认只提供了账号密码的登录认证逻辑,所以要实现手机短信验证码登录认证功能,我们需要模仿Spring Security账号密码登录逻辑代码来实现一套自己的认证逻辑。

一、短信验证码生成

我们在Spring Security -- 添加图形验证码的基础上来集成短信验证码登录的功能。

1、SmsCode实体类

和图形验证码类似,我们先定义一个短信验证码对象SmsCode:

package com.zy.example.entity;

import lombok.Data;

import java.time.LocalDateTime;

/**
 * @Author: zy
 * @Description: 手机验证码实体类
 * @Date: 2020-2-9
 */
@Data
public class SmsCode {
    /**
     * code验证码
     */
    private String code;

    /**
     * 过期时间 单位秒
     */
    private LocalDateTime expireTime;

    /**
     * 判断验证码是否过期
     * @return
     */
    public boolean isExpire() {
        return LocalDateTime.now().isAfter(expireTime);
    }

    /**
     * 构造函数
     * @param code
     * @param expireIn
     */
    public SmsCode(String code, int expireIn) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    /**
     * 构造函数
     * @param code
     * @param expireTime
     */
    public SmsCode(String code, LocalDateTime expireTime) {
        this.code = code;
        this.expireTime = expireTime;
    }

}

SmsCode对象包含了两个属性:code验证码和expireTime过期时间。isExpire方法用于判断短信验证码是否已过期。

2、ValidateCodeController

接着在ValidateCodeController中加入生成短信验证码相关请求对应的方法:

/**
     * 手机验证码
     */
    public final static String SESSION_KEY_SMS_CODE = "SESSION_KEY_SMS_CODE";


    /**
     * 用于生成手机验证码
     * @param request:请求
     * @param response:响应
     * @param mobile:手机号码
     * @throws IOException:异常
     */
    @RequestMapping("/sms")
    public void createSmsCode(HttpServletRequest request, HttpServletResponse response,@RequestParam String mobile) throws IOException {
        //生成手机验证码对象
        SmsCode smsCode = createSMSCode();
        //生成的验证码对象存储到Session中
        sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY_SMS_CODE + mobile, smsCode);
        // 输出验证码到控制台代替短信发送服务
        System.out.println("您的登录验证码为:" + smsCode.getCode() + ",有效时间为60秒");
    }


    /**
     * 用于生成手机验证码对象
     * @return
     */
    private SmsCode createSMSCode() {
        String code = RandomStringUtils.randomNumeric(6);
        return new SmsCode(code, 60);
    }

这里我们使用createSMSCode方法生成了一个6位的纯数字随机数,有效时间为60秒。然后通过SessionStrategy对象的setAttribute方法将短信验证码保存到了Session中,对应的key为SESSION_KEY_SMS_CODE。

至此,短信验证码生成模块编写完毕,下面开始改造登录页面。

二、登录页

我们在登录页面中加入一个与手机短信验证码认证相关的Form表单:

<div id="content">
        <div id="box">
            <div class="title">短信验证码登录</div>
            <div class="input">
                <form name="f" action="/login/mobile" method="post">
                    <input type="text" placeholder="手机号" name="mobile" value="17777777777" required="required"/>
                    <br>
                    <input type="text" name="smsCode" placeholder="短信验证码" style="width: 50%;"/>
                    <a href="/code/sms?mobile=17777777777" target="_blank">发送验证码</a>
                    <br>
                    <input type="submit" value="登录" />
                </form>
            </div>
        </div>
    </div>

其中a标签的href属性值对应我们的短信验证码生成方法的请求URL。同时,我们需要在Spring Security中配置/code/sms路径免验证:

 @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加验证码校验过滤器
                .authorizeRequests()    // 授权配置
                .antMatchers("/code/image","/code/sms")
                .permitAll()       // 无需认证的请求路径
                .anyRequest()       // 任何请求
                .authenticated()    //都需要身份认证
                .and()
                .formLogin()         // 或者httpBasic()
                .loginPage("/login")  // 指定登录页的路径
                .loginProcessingUrl("/login")  // 指定自定义form表单请求的路径
                .successHandler(authenticationSucessHandler)    // 处理登录成功
                .failureHandler(authenticationFailureHandler) // 处理登录失败
                // 必须允许所有用户访问我们的登录页(例如未验证的用户,否则验证流程就会进入死循环)
                // 这个formLogin().permitAll()方法允许所有用户基于表单登录访问/login这个page。
                .permitAll()
                .and()
                .rememberMe()
                .tokenRepository(persistentTokenRepository)  // 配置 token 持久化仓库
                .tokenValiditySeconds(3600)      // remember 过期时间,单为秒
                .userDetailsService(userDetailService)   // 处理自动登录逻辑
                .and()
                .logout()
                .permitAll()
                .and()
                //默认都会产生一个hiden标签 里面有安全相关的验证 防止请求伪造 这边我们暂时不需要 可禁用掉
                .csrf().disable();
    }

这里有一点需要注意,我们的form表单action是将请求提交到/login/mobile页面,而在Spring Security中配置的 .loginProcessingUrl("/login") 值为/login,这两者为什么不一样呢?这样做的目的是通过指定Spring Security中的UsernamePasswordAuthenticationFilter的拦截目标为post请求/login,从而使得该过滤器不会拦截/login/mobile请求;那么针对/login/mobile请求我们会仿照UsernamePasswordAuthenticationFilter定义自己的过滤器,然后对其进行认证;

重启项目,访问http://localhost:8080/login

点击发送验证码,控制台输出如下:

您的登录验证码为:788974,有效时间为60秒

接下来开始实现使用短信验证码登录认证逻辑。

三、添加短信验证码认证

1、实现原理

在Spring Security中,使用用户名密码认证的过程大致如下图所示:

  • Spring Security使用UsernamePasswordAuthenticationFilter过滤器来拦截用户名密码认证请求(默认只有当请求方法为post、请求页面为/login时过滤器才生效,但是可以通过.loginProcessingUrl("/url")修改拦截路径 ),将用户名和密码封装成一个UsernamePasswordAuthenticationToken对象交给AuthenticationManager处理;
  • AuthenticationManager将挑出一个支持处理UsernamePasswordAuthenticationToken对象的的AuthenticationProvider(这里为DaoAuthenticationProvider,AuthenticationProvider的其中一个实现类)来进行认证;
  • 认证过程中DaoAuthenticationProvider将调用UserDetailService的loadUserByUsername方法来处理认证,如果认证通过(即UsernamePasswordAuthenticationToken中的用户名和密码相符)则返回一个UserDetails类型对象,并将认证信息保存到Session中,认证后我们便可以通过Authentication对象获取到认证的信息了;

我们这里来具体介绍一下DaoAuthenticationProvider是如何进行密码认证?

其主要包含两个用户数据获取和一个比较:

  • 第一个用户数据获取:从浏览器输入的用户和密码,用户名和密码会传递到Spring Security内部,最终封装成UsernamePasswordAuthenticationToken对象authentication;
  • 第二个用户数据获取:利用loadUserByUsername函数根据用户名从数据库获得用户详情;
  • 一个比较:将前面两个数据获取进行比较,匹配则通过验证,否则验证不通过;

DaoAuthenticationProvider其具体认证方法为authenticate,定义在父抽象类AbstractUserDetailsAuthenticationProvider中:

public Authentication authenticate(Authentication authentication)   // 重点关注这个函数,不重要先略去
      throws AuthenticationException {
 
   ......
   try {
      preAuthenticationChecks.check(user);
      // 重点看这个地方,additionalAuthenticationChecks这个函数会被DaoAuthenticationProvider实现。所以我们要看看DaoAuthenticationProvider的additionalAuthenticationChecks函数实现
      additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);         
   }
   catch (AuthenticationException exception) {
      if (cacheWasUsed) {
         // There was a problem, so try again after checking
         // we're using latest data (i.e. not from the cache)
         cacheWasUsed = false;
         user = retrieveUser(username,
               (UsernamePasswordAuthenticationToken) authentication);
         preAuthenticationChecks.check(user);
         additionalAuthenticationChecks(user,
               (UsernamePasswordAuthenticationToken) authentication);
      }
      else {
         throw exception;
      }
   }
 
   postAuthenticationChecks.check(user);
 
  ......
 
   return createSuccessAuthentication(principalToReturn, authentication, user);
}

其中user的是通过调用loadUserByUsername获取的UserDetails对象,而authentication就是表单用户名和密码封装后的UsernamePasswordAuthenticationToken对象;

protected void additionalAuthenticationChecks(UserDetails userDetails,
      UsernamePasswordAuthenticationToken authentication)
      throws AuthenticationException {
   if (authentication.getCredentials() == null) {
      logger.debug("Authentication failed: no credentials provided");
 
 
      throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
   }
 
 
   String presentedPassword = authentication.getCredentials().toString();
   // 密码比较就在这个地方,前面这个是用户输入的密码,后面这个是数据库存的密码,一致则通过
   if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
      logger.debug("Authentication failed: password does not match stored value");
 
 
      throw new BadCredentialsException(messages.getMessage(
            "AbstractUserDetailsAuthenticationProvider.badCredentials",
            "Bad credentials"));
   }
}

通过这两步分析,应该很清楚密码的比较了吧。

注意:上图仅仅是用户名和密码的认证流程,并不包括授权流程;

由于Spring Security并没用提供短信验证码认证的流程,所以我们需要仿照上面这个流程来实现:

  • 在这个流程中,我们自定义了一个名为SmsAuthenticationFitler的过滤器来拦截短信验证码登录请求(只有当请求方法为post、请求页面为/login/mobile时过滤器才生效),并将手机号码封装到一个叫SmsAuthenticationToken的对象中,在Spring Security中,认证处理都需要通过AuthenticationManager来代理,所以这里我们依旧将SmsAuthenticationToken交由AuthenticationManager处理;
  • 接着我们需要定义一个支持处理SmsAuthenticationToken对象的SmsAuthenticationProvider;
  • SmsAuthenticationProvider调用UserDetailService的loadUserByUsername方法来处理认证,与用户名密码认证不一样的是,这里是通过SmsAuthenticationToken中的手机号去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails对象中返回,并将认证后的信息保存到Authentication对象中;

为了实现这个流程,我们需要定义SmsAuthenticationFitler、SmsAuthenticationToken和SmsAuthenticationProvider,并将这些组建组合起来添加到Spring Security中。下面我们来逐步实现这个过程。

2、定义SmsAuthenticationToken

查看UsernamePasswordAuthenticationToken的源码,将其复制出来重命名为SmsAuthenticationToken,并稍作修改,修改后的代码如下所示:

package com.zy.example.entity;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
 * @Author: zy
 * @Description: 手机验证token实体
 * @Date: 2020-2-9
 */
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    // ~ Instance fields
    // ================================================================================================

    private final Object principal;

    // ~ Constructors
    // ===================================================================================================

    /**
     * This constructor can be safely used by any code that wishes to create a
     * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
     * will return <code>false</code>.
     *
     */
    public SmsAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        setAuthenticated(false);
    }

    /**
     * This constructor should only be used by <code>AuthenticationManager</code> or
     * <code>AuthenticationProvider</code> implementations that are satisfied with
     * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
     * authentication token.
     *
     * @param principal
     * @param authorities
     */
    public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }

    // ~ Methods
    // ========================================================================================================

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

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

    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");
        }

        super.setAuthenticated(false);
    }

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

SmsAuthenticationToken包含一个principal属性,从它的两个构造函数可以看出,在认证之前principal存的是手机号,认证之后存的是用户信息。UsernamePasswordAuthenticationToken原来还包含一个credentials属性用于存放密码,这里不需要就去掉了。

3、定义SmsAuthenticationFilter

定义完SmsAuthenticationToken后,我们接着定义用于处理短信验证码登录请求的过滤器SmsAuthenticationFilter,同样的复制UsernamePasswordAuthenticationFilter源码并稍作修改:

package com.zy.example.filter;

import com.zy.example.entity.SmsAuthenticationToken;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;import org.springframework.util.Assert;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @Author: zy
 * @Description: 用于处理短信验证码登录请求的过滤器
 * @Date: 2020-2-9
 */public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    // ~ Static fields/initializers
    // =====================================================================================

    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

    private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
    private boolean postOnly = true;

    // ~ Constructors
    // ===================================================================================================

    public SmsAuthenticationFilter() {
        super(new AntPathRequestMatcher("/login/mobile", "POST"));
    }

    // ~ Methods
    // ========================================================================================================

    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String mobile  = obtainMobile(request);

        if (mobile == null) {
            mobile = "";
        }


        mobile = mobile.trim();

        SmsAuthenticationToken authRequest = new SmsAuthenticationToken(
                mobile);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * Enables subclasses to override the composition of the username, such as by
     * including additional values and a separator.
     *
     * @param request so that request attributes can be retrieved
     *
     * @return the username that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    @Nullable
    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

    /**
     * Provided so that subclasses may configure what is put into the authentication
     * request's details property.
     *
     * @param request that an authentication request is being created for
     * @param authRequest the authentication request object that should have its details
     * set
     */
    protected void setDetails(HttpServletRequest request,
                              SmsAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    /**
     * Sets the parameter name which will be used to obtain the username from the login
     * request.
     *
     * @param mobileParameter the parameter name. Defaults to "username".
     */
    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Username parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    /**
     * Defines whether only HTTP POST requests will be allowed by this filter. If set to
     * true, and an authentication request is received which is not a POST request, an
     * exception will be raised immediately and authentication will not be attempted. The
     * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
     * authentication.
     * <p>
     * Defaults to <tt>true</tt> but may be overridden by subclasses.
     */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getMobileParameter() {
        return mobileParameter;
    }
}

构造函数中指定了当请求为/login/mobile,请求方法为POST的时候该过滤器生效。mobileParameter属性值为mobile,对应登录页面手机号输入框的name属性。attemptAuthentication方法从请求中获取到mobile参数值,并调用SmsAuthenticationToken的SmsAuthenticationToken(String mobile)构造方法创建了一个SmsAuthenticationToken。下一步就如流程图中所示的那样,SmsAuthenticationFilter将SmsAuthenticationToken交给AuthenticationManager处理。

4、定义SmsAuthenticationProvider

在创建完SmsAuthenticationFilter后,我们需要创建一个支持处理SmsAuthenticationToken的类,即SmsAuthenticationProvider,该类需要实现AuthenticationProvider的两个抽象方法:

package com.zy.example.service;

import com.zy.example.entity.SmsAuthenticationToken;
import lombok.Getter;
import lombok.Setter;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;

/**
 * @Author: zy
 * @Description: 用于处理SmsAuthenticationToken
 * @Date: 2020-2-9
 */
@Service
public class SmsAuthenticationProvider implements AuthenticationProvider {

    @Getter
    @Setter
    private UserDetailsService userDetailsService;

    /**
     * 具体的身份认证逻辑
     * @param authentication:SmsAuthenticationToken
     * @return
     * @throws AuthenticationException
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;

        //通过手机号去查询用户,如果存在该用户则认证通过,否则认证失败
        UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());

        if (userDetails == null)
            throw new InternalAuthenticationServiceException("未找到与该手机号对应的用户");

        //函数构造一个认证通过的Token,包含了用户信息和用户权限
        SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    /**
     * 判断authentication是否是SmsAuthenticationToken类型的对象
     * @param authentication:token
     * @return
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return SmsAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

其中supports方法指定了支持处理的Token类型为SmsAuthenticationToken,authenticate方法用于编写具体的身份认证逻辑。

在authenticate方法中,我们从SmsAuthenticationToken中取出了手机号信息,并调用了UserDetailService的loadUserByUsername方法。

该方法在用户名密码类型的认证中,主要逻辑是通过用户名查询用户信息,如果存在该用户并且密码一致则认证成功;而在短信验证码认证的过程中,该方法需要通过手机号去查询用户,如果存在该用户则认证通过。认证通过后接着调用SmsAuthenticationToken的SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities)构造函数构造一个认证通过的Token,包含了用户信息和用户权限。

你可能会问,为什么这一步没有进行短信验证码的校验呢?实际上短信验证码的校验是在SmsAuthenticationFilter之前完成的,即只有当短信验证码正确以后才开始走认证的流程。所以接下来我们需要定一个过滤器来校验短信验证码的正确性。

5、定义SmsCodeFilter

package com.zy.example.filter;

import com.zy.example.controller.ValidateCodeController;
import com.zy.example.entity.SmsCode;
import com.zy.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @Author: zy
 * @Description:  手机验证码过滤器,用于对手机验证码进行校验
 * @Date: 2020-2-9
 */
@Service
public class SmsCodeFilter  extends OncePerRequestFilter {

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    //使用sessionStrategy将生成的验证码对象存储到Session中
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    /**
     * 如果请求是/login/mobile、对图片验证码进行校验
     * @param httpServletRequest
     * @param httpServletResponse
     * @param filterChain
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
        //判断请求页面是否为/login/mobile、该路径对应登录form表单的action路径,请求的方法是否为POST,是的话进行验证码校验逻辑,否则直接执行filterChain.doFilter让代码往下走
        System.out.println(httpServletRequest.getRequestURI());
        if (StringUtils.equalsIgnoreCase("/login/mobile", httpServletRequest.getRequestURI())
                && StringUtils.equalsIgnoreCase(httpServletRequest.getMethod(), "post")) {
            try {
                validateSmsCode(new ServletWebRequest(httpServletRequest));
            } catch (ValidateCodeException e) {
                authenticationFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
                return;
            }
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }

    /**
     * 对手机验证码进行校验
     * @param servletWebRequest:请求参数 包含表单提交的手机验证码信息
     * @throws ServletRequestBindingException
     */
    private void validateSmsCode(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
        //获取表单提交的手机号
        String mobile = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "mobile");

        //从Session获取保存在服务器端的验证码
        SmsCode codeInSession = (SmsCode) sessionStrategy.getAttribute(servletWebRequest, ValidateCodeController.SESSION_KEY_SMS_CODE + mobile);

        //获取表单提交的手机验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "smsCode");

        //验证码空校验
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码不能为空!");
        }

        //验证码校验
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在,请重新发送!");
        }

        //验证码过期校验
        if (codeInSession.isExpire()) {
            sessionStrategy.removeAttribute(servletWebRequest, ValidateCodeController.SESSION_KEY_IMAGE_CODE);
            throw new ValidateCodeException("验证码已过期!");
        }

        //判断是否相等
        if (!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("验证码不正确!");
        }

        //从Session移除该字段信息
        sessionStrategy.removeAttribute(servletWebRequest, ValidateCodeController.SESSION_KEY_SMS_CODE + mobile);

    }
}

方法的基本逻辑和之前定义的ValidateCodeFilter一致,这里不再赘述。

6、配置生效

在定义完所需的组件后,我们需要进行一些配置,将这些组件组合起来形成一个和上面流程图对应的流程。创建一个配置类SmsAuthenticationConfig:

package com.zy.example.config;

import com.zy.example.filter.SmsAuthenticationFilter;
import com.zy.example.service.CustomUserDetailsService;
import com.zy.example.service.SmsAuthenticationProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @Author: zy
 * @Description: 手机验证码登录配置类
 * @Date: 2020-2-9
 */
@Configuration
public class SmsAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private SmsAuthenticationProvider authenticationProvider;


    @Override
    public void configure(HttpSecurity http) throws Exception {
        SmsAuthenticationFilter authenticationFilter = new SmsAuthenticationFilter();
        authenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        authenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        authenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);

        authenticationProvider.setUserDetailsService(userDetailsService);

        http.authenticationProvider(authenticationProvider)
                .addFilterAfter(authenticationFilter, UsernamePasswordAuthenticationFilter.class);

    }
}
  • 在流程中第一步需要配置SmsAuthenticationFilter,分别设置了AuthenticationManager、AuthenticationSuccessHandler和AuthenticationFailureHandler属性。这些属性都是来自SmsAuthenticationFilter继承的AbstractAuthenticationProcessingFilter类中;
  • 第二步配置SmsAuthenticationProvider,这一步只需要将我们自个的UserDetailService注入进来即可;
  • 最后调用HttpSecurity的authenticationProvider方法指定了AuthenticationProvider为SmsAuthenticationProvider,并将SmsAuthenticationFilter过滤器添加到了UsernamePasswordAuthenticationFilter后面,至于为什么可以放在UsernamePasswordAuthenticationFilter后面可以参考Spring Security基本原理

到这里我们已经将短信验证码认证的各个组件组合起来了,最后一步需要做的是配置短信验证码校验过滤器,并且将短信验证码认证流程加入到Spring Security中。在BrowserSecurityConfig的configure方法中添加如下配置:

 @Autowired
    private SmsCodeFilter smsCodeFilter;

    @Autowired
    private SmsAuthenticationConfig smsAuthenticationConfig;

    /**
     * 配置拦截请求资源
     * @param http:HTTP请求安全处理
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加图片验证码校验过滤器
                .addFilterBefore(smsCodeFilter, UsernamePasswordAuthenticationFilter.class)  // 添加手机短信验证码校验过滤器
                .authorizeRequests()    // 授权配置
                .antMatchers("/code/image","/code/sms")
                .permitAll()       // 无需认证的请求路径
                .anyRequest()       // 任何请求
                .authenticated()    //都需要身份认证
                .and()
                .formLogin()         // 或者httpBasic()
                .loginPage("/login")  // 指定登录页的路径
                .loginProcessingUrl("/login")  // 指定自定义form表单请求的路径
                .successHandler(authenticationSucessHandler)    // 处理登录成功
                .failureHandler(authenticationFailureHandler) // 处理登录失败
                // 必须允许所有用户访问我们的登录页(例如未验证的用户,否则验证流程就会进入死循环)
                // 这个formLogin().permitAll()方法允许所有用户基于表单登录访问/login这个page。
                .permitAll()
                .and()
                .rememberMe()
                .tokenRepository(persistentTokenRepository)  // 配置 token 持久化仓库
                .tokenValiditySeconds(3600)      // remember 过期时间,单为秒
                .userDetailsService(userDetailsService)   // 处理自动登录逻辑
                .and()
                .logout()
                .permitAll()
                .and()
                //默认都会产生一个hiden标签 里面有安全相关的验证 防止请求伪造 这边我们暂时不需要 可禁用掉
                .csrf().disable()
                .apply(smsAuthenticationConfig); // 将短信验证码认证配置加到 Spring Security 中
    }

7、测试

重启项目,访问http://localhost:8080/login,点击发送验证码,控制台输出如下:

您的登录验证码为:372647,有效时间为60秒

输入该验证码,点击登录后页面如下所示:

具体的流程如下:

  • 当请求/login页面时,将会加载login.ftl文件,并返回给客户端;
  • 当点击发送验证码时,将会请求/code/sms,由于该请求被配置允许访问,因此不会进行授权认证,执行路由函数将短信验证码输出在控制台;
  • 当在/login页面输入完信息时,点击登录将会请求/login/mobile页面;
  • 请求首先被validateCodeFilter过滤器拦截,validateCodeFilter过滤器只有当请求页面为/login且提交方法为post时,才进行图片验证码验证,否者将会放行;
  • 然后,请求将会到达SmsCodeFilter过滤器,SmsCodeFilter过滤器在请求页面为/login/mobile且提交方法为post时,将会进行手机短信验证码验证,验证失败,执行自定义的登录失败逻辑;
  • 如果验证通过,此时将会执行SmsAuthenticationFilter过滤器,该过滤器对请求页面为/login/mobile且提交方法为post的请求进行认证;
  • 如果验证通过,则执行自定义的登录成功逻辑;

四、代码下载

springsecurity

参考文章:

[1] Spring Security短信验证码登录

posted @ 2020-02-09 17:34  大奥特曼打小怪兽  阅读(3293)  评论(0编辑  收藏  举报
如果有任何技术小问题,欢迎大家交流沟通,共同进步