【SpringSecurity系列】SpringBoot集成SpringSecurity添加手机验证码登录

现在大多数平台都是通过手机号+验证码的形式进行登录,但是SpringSecurity本身并没有直接提供我们这样的封装,所以我们需要根据自己的流程,自定义我们的操作,来满足我们的需求。

首先我们需要定义创建声明手机验证码的流程,这其实和生成图片验证码的流程相似,这里不详细说明,详细说明可以看这篇博客图片验证码登录,这里我直接上代码:

首先定义一个用来接收验证码的类来存放验证码:

public class ValidateCode {

    /** 验证码 */
    private String code;

    /** 判断过期时间 */
    private LocalDateTime expireTime;
    

    public ValidateCode(String code, int expireIn) {
        this.code = code;
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    public ValidateCode(String code, LocalDateTime expireTime) {
        this.code = code;
        this.expireTime = expireTime;
    }

    public boolean isExpried() {
        return LocalDateTime.now().isAfter(expireTime);
    }

    //省去get/set方法

}

然后就是生成验证码的流程:

@GetMapping("/code/sms")
    public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletRequestBindingException {
        ValidateCode smsCode = createSmsCode(new ServletWebRequest(request));
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_key_SMS,smsCode);
        String mobile = ServletRequestUtils.getRequiredStringParameter(request,"mobile");
        smsCodeSender.send(mobile, smsCode.getCode());
    }


    //具体生成验证码类的方法
    private ValidateCode createSmsCode(ServletWebRequest servletWebRequest) {
        String code = RandomStringUtils.randomNumeric(4);

        return new ValidateCode(code,60);
    }

其中短信验证码和图片验证码的流程都三个步骤,都是第一步生成验证码实体,第二步存入session,第三步发送验证码,只是第三步的具体实现不同,这样我们就可以通过模版模式对我们的代码进行封装,不知道模版模式的小伙伴可以看一下这篇博客模版方法模式,最后我会想重构前后的代码发到git上,有兴趣的可以对比一下,这里不具体讲怎么进行代码重构,只实现功能。

发送验证码的具体步骤不展开,根据每个人的需求,具体是一样,这里直接写一个简单方法:

//发送验证码的接口
public interface SmsCodeSender {

    void send(String mobile, String code);
}



@Component
//具体发送验证码的实现,并注入到SPringBean中
public class DefaultSmsCodeSender implements SmsCodeSender {


    @Override
    public void send(String mobile, String code) {
        System.out.println("向手机"+mobile+"发送验证码"+code);
    }
}

验证码的生成的步骤就完成了!

下面就是如何去校验验证码完成登录的操作,首先我们来看一下SpringSecurity账号密码的登录流程:

从上图可以看出,密码登录首先通过Filter拦截器从登录请求中拿到用户名密码,生成AuthenticationToken对象,然后传给Manager,然后Manager会从一堆Provider去找到一个Provider来处理我们的AuthenticationToken对象,在处理过程中就会调用DetailsService来获取用户的信息,去和Token中信息进行比对来判断是否可以认证成功,认证通过后就把Authentication设置成已认证,然后放进session。

因为我们就仿照上述来定义我们的手机号短信认证流程,那么首先我们来定义一个SmsFilter拦截器来处理我们的请求:

public class SmsCodeAuthenticationFilter extends
        AbstractAuthenticationProcessingFilter {
    // ~ Static fields/initializers
    // =====================================================================================

    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

    private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
    //表示只支持post请求
    private boolean postOnly = true;

    // ~ Constructors
    public SmsCodeAuthenticationFilter() {
        //过滤的请求是什么
        super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
    }


    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();

        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(
                mobile);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        
        //调用Manager
        return this.getAuthenticationManager().authenticate(authRequest);
    }


    /**
     * 获得手机号
     */
    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.
     *
     * 
     */
    protected void setDetails(HttpServletRequest request,
                              SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

   
    public void setMobileParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.mobileParameter = usernameParameter;
    }


 
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getMobileParameter() {
        return mobileParameter;
    }

}

然后定义我们用来Filter拿到的手机号等信息,来封装一个SmsToken,代码如下:

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;


    //放我们的认证信息,验证起放手机号,验证后放用户信息
    private final Object principal;

   
    /**
     * 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 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
    }

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

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

Manager整个系统只有一个,所以我们直接使用就可以,我们只需要定义处理手机验证的Provider类,代码如下:

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());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;

    }

    @Override
    //根据support方法使Manager调用那个provider来处理
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }


    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

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

上面这个流程只是根据手机号进行用户的认证,具体判断验证码是否正确,只在用户认证之前,再加一个过滤器,来验证我们的验证码是否匹配,过滤器代码如下:

/**
 * 定义一个验证码的拦截器
 * @author hdd
 */
public class ValidateCodeFilter extends OncePerRequestFilter {

    private DemoAuthenticationFailureHandler demoAuthenticationFailureHandler;


    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        if (StringUtils.equals("/authentication/form", request.getRequestURI()) &&
                StringUtils.endsWithIgnoreCase(request.getMethod(), "post")) {
            try {
                validate(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                demoAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }
        }
        filterChain.doFilter(request,response);
    }

    //具体的验证流程
    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        ImageCode codeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");

        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpried()) {
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
    }

    public DemoAuthenticationFailureHandler getDemoAuthenticationFailureHandler() {
        return demoAuthenticationFailureHandler;
    }

    public void setDemoAuthenticationFailureHandler(DemoAuthenticationFailureHandler demoAuthenticationFailureHandler) {
        this.demoAuthenticationFailureHandler = demoAuthenticationFailureHandler;
    }
}

那么手机号+验证码登录请求就完成了,最后我们来手机号配置验证码验证流程:

/**
 *手机验证码配置
 *
 */
@Configuration
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain,HttpSecurity> {

    @Autowired
    private DemoAuthenticationSuccessHandler demoAuthenticationSuccessHandler;

    @Autowired
    private DemoAuthenticationFailureHandler demoAuthenticationFailureHandler;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(demoAuthenticationFailureHandler);
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(demoAuthenticationSuccessHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
    }
}

最后讲配置放到我们的主配置上:

 @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setDemoAuthenticationFailureHandler(demoAuthenticationFailureHandler);

        SmsCodeFilter smsCodeFilter = new SmsCodeFilter();
        smsCodeFilter.setDemoAuthenticationFailureHandler(demoAuthenticationFailureHandler);

        http.addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(validateCodeFilter,UsernamePasswordAuthenticationFilter.class)//在UsernamePasswordAuthenticationFilter添加新添加的拦截器
             .formLogin()//表示使用form表单提交
            .loginPage("/login.html")//我们定义的登录页
            .loginProcessingUrl("/authentication/form")//因为SpringSecurity默认是login请求为登录请求,所以需要配置自己的请求路径
            .successHandler(demoAuthenticationSuccessHandler)//登录成功的操作
            .failureHandler(demoAuthenticationFailureHandler)//登录失败的操作
                .and()
                .rememberMe()
                .tokenRepository(persistentTokenRepository())

            .and()
            .authorizeRequests()//对请求进行授权
            .antMatchers("/login.html","/code/*").permitAll()//表示login.html路径不会被拦截
            .anyRequest()//表示所有请求
            .authenticated()//需要权限认证
            .and()
            .csrf().disable()
            .apply(smsCodeAuthenticationSecurityConfig);//添加手机号验证码校验
}

最后写一下测试页面:

<h3>表单登录</h3>
<form action="/authentication/mobile" method="post">
    <table>
        <tr>
            <td>手机号:</td>
            <td><input type="text" name="mobile" value="13012345678"></td>
        </tr>

        <tr>
            <td>短信验证码:</td>
            <td>
                <input type="text" name="smsCode">
                <a href="/code/sms?mobile=13012345678">发送验证码</a>
            </td>
        </tr>
        <tr>
            <td colspan="2"><button type="submit">登录</button></td>
        </tr>
    </table>
</form>

最后测试也就就是如此:

完整项目代码请从git上拉取git地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值