spring-security入门11---短信验证码(三)----短信验证码校验功能开发

本文介绍如何在Spring Security中集成短信验证码登录功能,包括自定义过滤器、认证提供者及配置等关键步骤。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


项目源码地址 https://github.com/nieandsun/security

1. 理论

前面的文章说过:
spring-security并没有实现一套关于短信验证码的校验逻辑,但是我们可以仿照用户名+密码的校验方式来自己写Filter,然后将自己写的Filter加入到spring-security的过滤器链中来实现利用短信进行认证的功能。

具体的工作内容如下图:
在这里插入图片描述

2. 具体代码 – 建议直接从github上下载下来代码查看commit记录

2.1 短信验证码校验Filter

package com.nrsc.security.core.validate.code;

import com.nrsc.security.core.properties.SecurityProperties;
import lombok.Data;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.util.AntPathMatcher;
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;
import java.util.HashSet;
import java.util.Set;

/**
 * @author: Sun Chuan
 * @date: 2019/7/15 19:43
 * Description:  直接复制的图形验证码的校验逻辑----会进行优化
 */
@Data
public class SmsCodeFilter extends OncePerRequestFilter implements InitializingBean {

    private AuthenticationFailureHandler authenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    private Set<String> urls = new HashSet<>();

    private SecurityProperties securityProperties;

    private AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getSms().getUrls(), ",");
        if (ArrayUtils.isNotEmpty(configUrls)) {
            for (String configUrl : configUrls) {
                urls.add(configUrl);
            }
        }
        urls.add("/authentication/mobile");
    }


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        boolean action = false;
        for (String url : urls) {
            if (pathMatcher.match(url, request.getRequestURI())) {
                action = true;
            }
        }
        if (action) {
            try {
                validate(new ServletWebRequest(request));
            } catch (ValidateCodeException e) {
                authenticationFailureHandler.onAuthenticationFailure(request, response, e);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {

        ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request,
                ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");

        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "smsCode");

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

        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }

        if (codeInSession.isExpried()) {
            sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
            throw new ValidateCodeException("验证码已过期");
        }

        if (!StringUtils.equals(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }

        sessionStrategy.removeAttribute(request, ValidateCodeProcessor.SESSION_KEY_PREFIX + "SMS");
    }
}

2.2 SmsCodeAuthenticationFilter

package com.nrsc.security.core.authentication.mobile;

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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @author: Sun Chuan
 * @date: 2019/7/15 19:30
 * Description:
 */
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    // ~ Static fields/initializers
    // =====================================================================================

    public static final String NRSC_FORM_MOBILE_KEY = "mobile";

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

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

    public SmsCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
    }

    // ~ Methods
    // ========================================================================================================
    @Override
    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);
        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.
     *
     * @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, SmsCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }
//---------下面这一块是用户登陆个性化配置有关的内容,比如说spring-security默认的登陆名字段为username,密码为password---------
//---------但可以在配置类中通过.usernameParameter("XXX")和.passwordParameter("YYY")修改其默认值----------------------

//    /**
//     * Sets the parameter name which will be used to obtain the username from the login
//     * request.
//     *
//     * @param usernameParameter the parameter name. Defaults to "username".
//     */
//    public void setUsernameParameter(String usernameParameter) {
//        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
//        this.usernameParameter = usernameParameter;
//    }
//
//    /**
//     * Sets the parameter name which will be used to obtain the password from the login
//     * request..
//     *
//     * @param passwordParameter the parameter name. Defaults to "password".
//     */
//    public void setPasswordParameter(String passwordParameter) {
//        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
//        this.passwordParameter = passwordParameter;
//    }
//
//    /**
//     * 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 getUsernameParameter() {
//        return usernameParameter;
//    }
//
//    public final String getPasswordParameter() {
//        return passwordParameter;
//    }
}

2.3 SmsCodeAuthenticationProvider和相应的token – SmsCodeAuthenticationToken

package com.nrsc.security.core.authentication.mobile;

import lombok.Data;
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;

/**
 * @author: Sun Chuan
 * @date: 2019/7/15 19:40
 * Description:  相比于用户名密码使用的AbstractUserDetailsAuthenticationProvider这里简化了很多
 */
@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());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}
package com.nrsc.security.core.authentication.mobile;

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

import java.util.Collection;

/**
 * @author: Sun Chuan
 * @date: 2019/7/15 19:23
 * Description: 模仿UsernamePasswordAuthenticationToken
 */
public class SmsCodeAuthenticationToken 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 SmsCodeAuthenticationToken(String mobile) {
        super(null);
        this.principal = mobile;
        //this.credentials = credentials;
        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 credentials
     * @param authorities
     */
    public SmsCodeAuthenticationToken(Object principal,
                                      Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        //this.credentials = credentials;
        super.setAuthenticated(true); // must use super, as we override
    }

    // ~ Methods
    // ========================================================================================================
    @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");
        }

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
        //credentials = null;
    }
}

2.4 将写好的Filter加入到spring-security的过滤器链

2.4.1 电话验证Filter — 单独一个配置文件

package com.nrsc.security.core.authentication.mobile;

import org.springframework.beans.factory.annotation.Autowired;
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.core.userdetails.UserDetailsService;
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;
import org.springframework.stereotype.Component;

/**
 * @author: Sun Chuan
 * @date: 2019/7/15 19:53
 * Description: 将smsCodeAuthenticationProvider 和 smsCodeAuthenticationFilter 放到spring-security过滤器链
 */
@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private AuthenticationSuccessHandler NRSCAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler NRSCAuthenticationFailureHandler;

    @Autowired
    private UserDetailsService NRSCDetailsService;

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

        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        //可以查看AbstractAuthenticationProcessingFilter源码,AuthenticationManager需要进行set进来
        //UsernamePasswordAuthenticationFilter之所以没有进行set,是因为配置文件里默认给UsernamePasswordAuthenticationFilter进行了set
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));

        //可以查看AbstractAuthenticationProcessingFilter源码,该Filter给了一个默认的成功处理器和失败处理器
        //为了使用我们自己写的,这里也需要重新set一下
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(NRSCAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        //注意这里直接将NRSCDetailsService给set进去了,实际业务中要在NRSCDetailsService里判断我们的数据库有没有该电话号码
        smsCodeAuthenticationProvider.setUserDetailsService(NRSCDetailsService);

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

2.4.2 将短信验证码校验Filter和2.4.1中的配置引入到继承了WebSecurityConfigurerAdapter配置文件中,使其真正在spring-security过滤器链中生效

package com.nrsc.security.browser.config;

import com.nrsc.security.core.authentication.mobile.SmsCodeAuthenticationSecurityConfig;
import com.nrsc.security.core.properties.SecurityProperties;
import com.nrsc.security.core.validate.code.SmsCodeFilter;
import com.nrsc.security.core.validate.code.ValidateCodeFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;

import javax.sql.DataSource;

@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private AuthenticationSuccessHandler NRSCAuthenticationSuccessHandler;

    @Autowired
    private AuthenticationFailureHandler NRSCAuthenticationFailureHandler;

    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private UserDetailsService NRSCDetailsService;

    @Autowired
    //springboot会根据yml文件中的spring:datasource将数据源注入到spring容器
    //所以这里直接通过 @Autowired就可以拿到数据源
    private DataSource dataSource;

    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;


    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);
        // 第一次启动的时候自动建表(建议不用这句话,因为第二次启动会报错)
        // 建表语句可在JdbcTokenRepositoryImpl源码中找到
        // tokenRepository.setCreateTableOnStartup(true);
        return tokenRepository;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);
        validateCodeFilter.setSecurityProperties(securityProperties);
        //调用afterPropertiesSet,初始化urls
        validateCodeFilter.afterPropertiesSet();

        //直接复制的上面的代码,之后会进行优化
        SmsCodeFilter smsCodeFilter = new SmsCodeFilter();
        smsCodeFilter.setAuthenticationFailureHandler(NRSCAuthenticationFailureHandler);
        smsCodeFilter.setSecurityProperties(securityProperties);
        //调用afterPropertiesSet,初始化urls
        smsCodeFilter.afterPropertiesSet();


        //将图形验证码的校验逻辑放在用户名和密码校验逻辑之前
        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(smsCodeFilter,UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .loginPage("/authentication/require")//登陆时进入的url-->相当于进入登陆页面
                .loginProcessingUrl("/authentication/form")//告诉spring-security点击登陆时访问的url为/authentication/form
                // ---->当spring-security接收到此url的请求后,会自动调用
                //com.nrsc.security.browser.action.NRSCDetailsService中的loadUserByUsername
                //.usernameParameter("user")-->与UsernamePasswordAuthenticationFilter中的usernameParameter对应,可修改其默认值
                //.passwordParameter("code")-->与UsernamePasswordAuthenticationFilter中的passwordParameter对应,可修改其默认值
                //进行登陆校验
                .successHandler(NRSCAuthenticationSuccessHandler)//指定使用NRSCAuthenticationSuccessHandler处理登陆成功后的行为
                .failureHandler(NRSCAuthenticationFailureHandler)//指定使用NNRSCAuthenticationFailureHandler处理登陆失败后的行为
                .and()

                //Remember相关配置
                .rememberMe()
                .tokenRepository(persistentTokenRepository())//指定使用的tokenRepository
                .tokenValiditySeconds(securityProperties.getBrowser().getRememberMeSeconds())//指定记住我的时间(秒)
                .userDetailsService(NRSCDetailsService)//指定进行登陆认证的UserDetailsService

                .and()
                .authorizeRequests()

                //不进认证的url
                .antMatchers("/code/*", "/authentication/require", securityProperties.getBrowser().getLoginPage())//指定不校验的url
                .permitAll()

                //除了不进行认证的url其他请求都需要认证
                .anyRequest()
                .authenticated()

                .and()
                .csrf().disable() //关闭csrf

                //将smsCodeAuthenticationSecurityConfig配置文件加到该配置文件里
                .apply(smsCodeAuthenticationSecurityConfig);
    }
}

3. 测试

不具体展示,应该达到的效果

  • 原来的用户名/密码登陆+图形验证码功能+记住我功能都能正常运行
  • 利用短信验证码可以完成登陆,并且登陆成功后如果配置的成功处理器为返回校验成功的json对象,则应可以看到下面的校验结果

在这里插入图片描述

4. 代码优化 – 已上传github

基本本文的代码优化已经提交到github上:https://github.com/nieandsun/security
主要优化点有三个

  • 将项目里用到的所有常量封装到一起 —> 便于管理
  • 短信验证码+图形验证码优化 —> 消除重复代码
  • 配置类优化 —> 将不同类型的配置分开,去耦合
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

nrsc

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值