欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Spring Security短信验证码实现详解

程序员文章站 2022-06-22 15:42:23
目录需求实现步骤获取短信验证码短信验证码校验过滤器短信验证码登录认证配置类进行综合组装需求 输入手机号码,点击获取按钮,服务端接受请求发送短信 用户输入验证码点击登录 手机号码必须属...

需求

Spring Security短信验证码实现详解

  • 输入手机号码,点击获取按钮,服务端接受请求发送短信
  • 用户输入验证码点击登录
  • 手机号码必须属于系统的注册用户,并且唯一
  • 手机号与验证码正确性及其关系必须经过校验
  • 登录后用户具有手机号对应的用户的角色及权限

实现步骤

  • 获取短信验证码
  • 短信验证码校验过滤器
  • 短信验证码登录认证过滤器
  • 综合配置

获取短信验证码

在这一步我们需要写一个controller接收用户的获取验证码请求。注意:一定要为“/smscode”访问路径配置为permitall访问权限,因为spring security默认拦截所有路径,除了默认配置的/login请求,只有经过登录认证过后的请求才会默认可以访问。

@slf4j
@restcontroller
public class smscontroller {

    @autowired
    private userdetailsservice userdetailsservice;

    //获取短信验证码
    @requestmapping(value="/smscode",method = requestmethod.get)
    public string sms(@requestparam string mobile, httpsession session) throws ioexception {
         //先从数据库中查找,判断对应的手机号是否存在
        userdetails userdetails = userdetailsservice.loaduserbyusername(mobile);
        //这个地方userdetailsservice如果使用spring security提供的话,找不到用户名会直接抛出异常,走不到这里来
        //即直接去了登录失败的处理器
        if(userdetails == null){
            return "您输入的手机号不是系统注册用户";
        }
        //commons-lang3包下的工具类,生成指定长度为4的随机数字字符串
        string randomnumeric = randomstringutils.randomnumeric(4);
        //验证码,过期时间,手机号
        smscode smscode = new smscode(randomnumeric,60,mobile);
        //todo 此处调用验证码发送服务接口
        //这里只是模拟调用
        log.info(smscode.getcode() + "=》" + mobile);

        //将验证码存放到session中
        session.setattribute("sms_key",smscode);

        return "短信息已经发送到您的手机";
    }
}

上文中我们只做了短信验证码接口调用的模拟,没有真正的向手机发送验证码。此部分接口请结合短信发送服务提供商接口实现。

短信验证码发送之后,将验证码“谜底”保存在session中。

使用smscode封装短信验证码的谜底,用于后续登录过程中进行校验。

public class smscode {
    private string code;  //短信验证码
    private localdatetime expiretime; //验证码的过期时间
    private string mobile; //发送手机号

    public smscode(string code,int expireafterseconds,string mobile){
        this.code = code;
        this.expiretime = localdatetime.now().plusseconds(expireafterseconds);
        this.mobile = mobile;
    }

    public boolean isexpired(){
        return  localdatetime.now().isafter(expiretime);
    }

    public string getcode() {
        return code;
    }

    public string getmobile() {
       return mobile;
    }

}

前端初始化短信登录界面

<h1>短信登陆</h1>
<form action="/smslogin" method="post">
    <span>手机号码:</span><input type="text" name="mobile" id="mobile"> <br>
    <span>短信验证码:</span><input type="text" name="smscode" id="smscode" >
    <input type="button" onclick="getsmscode()" value="获取"><br>
    <input type="button" onclick="smslogin()" value="登陆">
</form>

<script>

   function getsmscode()
   {
     $.ajax({
       type: "get",
       url: "/smscode",
        data:{"mobile":$("#mobile").val()},
       success: function (res) {
         console.log(res)
       },
       error: function (e) {
         console.log(e.responsetext);
       }
     });
   }

    function smslogin() {
      var mobile = $("#mobile").val();
      var smscode = $("#smscode").val();
      if (mobile === "" || smscode === "") {
        alert('手机号和短信验证码均不能为空');
        return;
      }
      $.ajax({
        type: "post",
        url: "/smslogin",
        data: {
          "mobile": mobile,
          "smscode": smscode
        },
        success: function (res) {
          console.log(res)
        },
        error: function (e) {
          console.log(e.responsetext);
        }
      });
    }
</script>

Spring Security短信验证码实现详解

spring security配置类

@configuration
public class securityconfig extends websecurityconfigureradapter {

   private objectmapper objectmapper=new objectmapper();

   @resource
   private captchacodefilter captchacodefilter;

   @bean
   passwordencoder passwordencoder() {
      return nooppasswordencoder.getinstance();
   }

   @override
   public void configure(websecurity web) throws exception {
      web.ignoring().antmatchers("/js/**", "/css/**","/images/**");
   }

   //数据源注入
   @autowired
   datasource datasource;

   //持久化令牌配置
   @bean
   jdbctokenrepositoryimpl jdbctokenrepository() {
      jdbctokenrepositoryimpl jdbctokenrepository = new jdbctokenrepositoryimpl();
      jdbctokenrepository.setdatasource(datasource);
      return jdbctokenrepository;
   }


   //用户配置
   @override
   @bean
   protected userdetailsservice userdetailsservice() {
      jdbcuserdetailsmanager manager = new jdbcuserdetailsmanager();
      manager.setdatasource(datasource);
      if (!manager.userexists("dhy")) {
         manager.createuser(user.withusername("dhy").password("123").roles("admin").build());
      }
      if (!manager.userexists("大忽悠")) {
         manager.createuser(user.withusername("大忽悠").password("123").roles("user").build());
      }
      //模拟电话号码
      if (!manager.userexists("123456789")) {
         manager.createuser(user.withusername("123456789").password("").roles("user").build());
      }
      return manager;
   }

   @override
   protected void configure(httpsecurity http) throws exception {
      http.//处理需要认证的请求
              authorizerequests()
              //放行请求,前提:是对应的角色才行
              .antmatchers("/admin/**").hasrole("admin")
              .antmatchers("/user/**").hasrole("user")
              //无需登录凭证,即可放行
              .antmatchers("/kaptcha","/smscode").permitall()//放行验证码的显示请求
              //剩余的请求都需要认证才可以放行
              .anyrequest().authenticated()
              .and()
              //表单形式登录的个性化配置
              .formlogin()
              .loginpage("/login.html").permitall()
              .loginprocessingurl("/login").permitall()
              .defaultsuccessurl("/main.html")//可以记住上一次的请求路径
              //登录失败的处理器
              .failurehandler(new myfailhandler())
              .and()
              //退出登录相关设置
              .logout()
              //退出登录的请求,是再没退出前发出的,因此此时还有登录凭证
              //可以访问
              .logouturl("/logout")
              //此时已经退出了登录,登录凭证没了
              //那么想要访问非登录页面的请求,就必须保证这个请求无需凭证即可访问
              .logoutsuccessurl("/logout.html").permitall()
              //退出登录的时候,删除对应的cookie
              .deletecookies("jsessionid")
              .and()
              //记住我相关设置
              .rememberme()
              //预定义key相关设置,默认是一串uuid
              .key("dhy")
              //令牌的持久化
              .tokenrepository(jdbctokenrepository())
              .and()
              .addfilterbefore(captchacodefilter, usernamepasswordauthenticationfilter.class)
              //csrf关闭
              .csrf().disable();

   }

   //角色继承
   @bean
   rolehierarchy rolehierarchy() {
      rolehierarchyimpl hierarchy = new rolehierarchyimpl();
      hierarchy.sethierarchy("role_admin > role_user");
      return hierarchy;
   }

}

短信验证码校验过滤器

短信验证码的校验过滤器,和图片验证码的验证实现原理是一致的。都是通过继承onceperrequestfilter实现一个spring环境下的过滤器。其核心校验规则如下:

  • 用户登录时手机号不能为空
  • 用户登录时短信验证码不能为空
  • 用户登陆时在session中必须存在对应的校验谜底(获取验证码时存放的)
  • 用户登录时输入的短信验证码必须和“谜底”中的验证码一致
  • 用户登录时输入的手机号必须和“谜底”中保存的手机号一致
  • 用户登录时输入的手机号必须是系统注册用户的手机号,并且唯一
@component
public class smscodevalidatefilter extends onceperrequestfilter {

    @resource
    userdetailsservice userdetailsservice;

    @resource
    myfailhandler myauthenticationfailurehandler;

    @override
    protected void dofilterinternal(httpservletrequest request,
                                    httpservletresponse response,
                                    filterchain filterchain)
            throws servletexception, ioexception {
       //该过滤器只负责拦截验证码登录的请求
       //并且请求必须是post
        if (request.getrequesturi().equals("/smslogin")
                && request.getmethod().equalsignorecase("post")) {
            try {
                validate(new servletwebrequest(request));

            }catch (authenticationexception e){
                myauthenticationfailurehandler.onauthenticationfailure(
                        request,response,e);
                return;
            }
        }
        filterchain.dofilter(request,response);
    }

    private void validate(servletwebrequest request) throws servletrequestbindingexception {
        httpsession session = request.getrequest().getsession();
        //从session取出获取验证码时,在session中存放验证码相关信息的类
        smscode codeinsession = (smscode) session.getattribute("sms_key");
        //取出用户输入的验证码
        string codeinrequest = request.getparameter("smscode");
        //取出用户输入的电话号码
        string mobileinrequest = request.getparameter("mobile");
        
        //common-lang3包下的工具类
        if(stringutils.isempty(mobileinrequest)){
            throw new sessionauthenticationexception("手机号码不能为空!");
        }
        if(stringutils.isempty(codeinrequest)){
            throw new sessionauthenticationexception("短信验证码不能为空!");
        }
        if(objects.isnull(codeinsession)){
            throw new sessionauthenticationexception("短信验证码不存在!");
        }
        if(codeinsession.isexpired())
        {
            //从session中移除保存的验证码相关信息
            session.removeattribute("sms_key");
            throw new sessionauthenticationexception("短信验证码已过期!");
        }
        if(!codeinsession.getcode().equals(codeinrequest)){
            throw new sessionauthenticationexception("短信验证码不正确!");
        }

        if(!codeinsession.getmobile().equals(mobileinrequest)){
            throw new sessionauthenticationexception("短信发送目标与该手机号不一致!");
        }
        //数据库查询当前手机号是否注册过
        userdetails myuserdetails = userdetailsservice.loaduserbyusername(mobileinrequest);
        if(objects.isnull(myuserdetails)){
            throw new sessionauthenticationexception("您输入的手机号不是系统的注册用户");
        }
        //校验完毕并且没有抛出异常的情况下,移除session中保存的验证码信息
        session.removeattribute("sms_key");
    }
}

注意:一定要为"/smslogin"访问路径配置为permitall访问权限

Spring Security短信验证码实现详解

到这里,我们可以讲一下整体的短信验证登录流程,如上面的时序图。

  • 首先用户发起“获取短信验证码”请求,smscodecontroller中调用短信服务商接口发送短信,并将短信发送的“谜底”保存在session中。
  • 当用户发起登录请求,首先要经过smscodevalidatefilter对谜底和用户输入进行比对,比对失败则返回短信验证码校验失败
  • 当短信验证码校验成功,继续执行过滤器链中的smscodeauthenticationfilter对用户进行认证授权。

短信验证码登录认证

Spring Security短信验证码实现详解

我们可以仿照的流程,完成相关类的动态替换

由上图可以看出,短信验证码的登录认证逻辑和用户密码的登录认证流程是一样的。所以:

smscodeauthenticationfilter仿造usernamepasswordauthenticationfilter进行开发

smscodeauthenticationprovider仿造daoauthenticationprovider进行开发。

模拟实现:只不过将用户名、密码换成手机号进行认证,短信验证码在此部分已经没有用了,因为我们在smscodevalidatefilter已经验证过了。

/**
 * 仿造usernamepasswordauthenticationfilter开发
 */
public class smscodeauthenticationfilter extends abstractauthenticationprocessingfilter {

    public static final string spring_security_form_mobile_key = "mobile";
    private string mobileparameter = spring_security_form_mobile_key ;    //请求中携带手机号的参数名称
    private boolean postonly = true;    //指定当前过滤器是否只处理post请求
    //默认处理的请求
    private static final antpathrequestmatcher default_ant_path_request_matcher = new antpathrequestmatcher("/smslogin", "post");

    public smscodeauthenticationfilter() {
        //指定当前过滤器处理的请求
        super(default_ant_path_request_matcher);
    }

    //尝试进行认证
    public authentication attemptauthentication(
            httpservletrequest request,
            httpservletresponse response)
            throws authenticationexception {
        if (this.postonly && !request.getmethod().equals("post")) {
            throw new authenticationserviceexception("authentication method not supported: " + request.getmethod());
        } else {
            string mobile = this.obtainmobile(request);
            if (mobile == null) {
                mobile = "";
            }
            mobile = mobile.trim();
            //认证前---手机号码是认证主体
            smscodeauthenticationtoken authrequest = new smscodeauthenticationtoken(mobile);
            //设置details---默认是sessionid和remoteaddr
            this.setdetails(request, authrequest);
            return this.getauthenticationmanager().authenticate(authrequest);
        }
    }


    protected string obtainmobile(httpservletrequest request) {
        return request.getparameter(this.mobileparameter);
    }


    protected void setdetails(httpservletrequest request, smscodeauthenticationtoken authrequest) {
        authrequest.setdetails(this.authenticationdetailssource.builddetails(request));
    }

    public void setmobileparameter(string mobileparameter) {
        assert.hastext(mobileparameter, "username parameter must not be empty or null");
        this.mobileparameter = mobileparameter;
    }


    public void setpostonly(boolean postonly) {
        this.postonly = postonly;
    }

    public final string getmobileparameter() {
        return this.mobileparameter;
    }

}

认证令牌也需要替换:

public class smscodeauthenticationtoken extends abstractauthenticationtoken {
    private static final long serialversionuid = springsecuritycoreversion.serial_version_uid;

    //存放认证信息,认证之前存放手机号,认证之后存放登录的用户
    private final object principal;

//认证前
    public smscodeauthenticationtoken(string mobile) {
        super((collection)null);
        this.principal = mobile;
        this.setauthenticated(false);
    }

//认证后,会设置相关的权限
    public smscodeauthenticationtoken(object principal, collection<? extends grantedauthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setauthenticated(true);
    }

    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");
        } else {
            super.setauthenticated(false);
        }
    }

    public void erasecredentials() {
        super.erasecredentials();
    }
}

当前还需要提供能够对我们当前自定义令牌对象起到认证作用的provider,仿照daoauthenticationprovider

public class smscodeauthenticationprovider implements authenticationprovider{


    private userdetailsservice userdetailsservice;

    public userdetailsservice getuserdetailsservice() {
        return userdetailsservice;
    }

    public void setuserdetailsservice(userdetailsservice userdetailsservice) {
        this.userdetailsservice = userdetailsservice;
    }

    /**
     * 进行身份认证的逻辑
     * @param authentication    就是我们传入的token
     * @return
     * @throws authenticationexception
     */
    @override
    public authentication authenticate(authentication authentication) throws authenticationexception {

        //利用userdetailsservice获取用户信息,拿到用户信息后重新组装一个已认证的authentication
        smscodeauthenticationtoken authenticationtoken = (smscodeauthenticationtoken)authentication;
        userdetails user = userdetailsservice.loaduserbyusername((string) authenticationtoken.getprincipal());  //根据手机号码拿到用户信息
        if(user == null){
            throw new internalauthenticationserviceexception("无法获取用户信息");
        }
        //设置新的认证主体
        smscodeauthenticationtoken authenticationresult = new smscodeauthenticationtoken(user,user.getauthorities());
        //copy details
        authenticationresult.setdetails(authenticationtoken.getdetails());
        //返回新的令牌对象
        return authenticationresult;
    }

    /**
     * authenticationmanager挑选一个authenticationprovider
     * 来处理传入进来的token就是根据supports方法来判断的
     * @param aclass
     * @return
     */
    @override
    public boolean supports(class<?> aclass) {
        //isassignablefrom: 判断当前的class对象所表示的类,
        // 是不是参数中传递的class对象所表示的类的父类,超接口,或者是相同的类型。
        // 是则返回true,否则返回false。
        return smscodeauthenticationtoken.class.isassignablefrom(aclass);
    }
}

配置类进行综合组装

最后我们将以上实现进行组装,并将以上接口实现以配置的方式告知spring security。因为配置代码比较多,所以我们单独抽取一个关于短信验证码的配置类smscodesecurityconfig,继承自securityconfigureradapter。

@component
public class smscodesecurityconfig extends securityconfigureradapter<defaultsecurityfilterchain, httpsecurity> {

    @resource
    private myfailhandler myauthenticationfailurehandler;

      //这里不能直接注入,否则会造成依赖注入的问题发生
    private userdetailsservice myuserdetailsservice;

    @resource
    private smscodevalidatefilter smscodevalidatefilter;

    @override
    public void configure(httpsecurity http) throws exception {

        smscodeauthenticationfilter smscodeauthenticationfilter = new smscodeauthenticationfilter();
        smscodeauthenticationfilter.setauthenticationmanager(http.getsharedobject(authenticationmanager.class));
        //有则配置,无则不配置
        //smscodeauthenticationfilter.setauthenticationsuccesshandler(myauthenticationsuccesshandler);
        smscodeauthenticationfilter.setauthenticationfailurehandler(myauthenticationfailurehandler);

        // 获取验证码登录令牌校验的提供者
        smscodeauthenticationprovider smscodeauthenticationprovider = new smscodeauthenticationprovider();
        smscodeauthenticationprovider.setuserdetailsservice(myuserdetailsservice);

        //在用户密码过滤器前面加入短信验证码校验过滤器
        http.addfilterbefore(smscodevalidatefilter, usernamepasswordauthenticationfilter.class);
        //在用户密码过滤器后面加入短信验证码认证授权过滤器        
        http.authenticationprovider(smscodeauthenticationprovider)
            .addfilterafter(smscodeauthenticationfilter, usernamepasswordauthenticationfilter.class);

    }
}

该配置类可以用以下代码,集成到securityconfig中。

Spring Security短信验证码实现详解

完整配置

@configuration
public class securityconfig extends websecurityconfigureradapter {

   private objectmapper objectmapper=new objectmapper();

   @resource
   private captchacodefilter captchacodefilter;

   @resource
   private smscodesecurityconfig smscodesecurityconfig;

   @bean
   passwordencoder passwordencoder() {
      return nooppasswordencoder.getinstance();
   }

   @override
   public void configure(websecurity web) throws exception {
      web.ignoring().antmatchers("/js/**", "/css/**","/images/**");
   }

   //数据源注入
   @autowired
   datasource datasource;

   //持久化令牌配置
   @bean
   jdbctokenrepositoryimpl jdbctokenrepository() {
      jdbctokenrepositoryimpl jdbctokenrepository = new jdbctokenrepositoryimpl();
      jdbctokenrepository.setdatasource(datasource);
      return jdbctokenrepository;
   }


   //用户配置
   @override
   @bean
   protected userdetailsservice userdetailsservice() {
      jdbcuserdetailsmanager manager = new jdbcuserdetailsmanager();
      manager.setdatasource(datasource);
      if (!manager.userexists("dhy")) {
         manager.createuser(user.withusername("dhy").password("123").roles("admin").build());
      }
      if (!manager.userexists("大忽悠")) {
         manager.createuser(user.withusername("大忽悠").password("123").roles("user").build());
      }
      //模拟电话号码
      if (!manager.userexists("123456789")) {
         manager.createuser(user.withusername("123456789").password("").roles("user").build());
      }
      return manager;
   }

   @override
   protected void configure(httpsecurity http) throws exception {

 //设置一下userdetailservice
      smscodesecurityconfig.setmyuserdetailsservice(userdetailsservice());

      http.//处理需要认证的请求
              authorizerequests()
              //放行请求,前提:是对应的角色才行
              .antmatchers("/admin/**").hasrole("admin")
              .antmatchers("/user/**").hasrole("user")
              //无需登录凭证,即可放行
              .antmatchers("/kaptcha","/smscode","/smslogin").permitall()//放行验证码的显示请求
              //剩余的请求都需要认证才可以放行
              .anyrequest().authenticated()
              .and()
              //表单形式登录的个性化配置
              .formlogin()
              .loginpage("/login.html").permitall()
              .loginprocessingurl("/login").permitall()
              .defaultsuccessurl("/main.html")//可以记住上一次的请求路径
              //登录失败的处理器
              .failurehandler(new myfailhandler())
              .and()
              //退出登录相关设置
              .logout()
              //退出登录的请求,是再没退出前发出的,因此此时还有登录凭证
              //可以访问
              .logouturl("/logout")
              //此时已经退出了登录,登录凭证没了
              //那么想要访问非登录页面的请求,就必须保证这个请求无需凭证即可访问
              .logoutsuccessurl("/logout.html").permitall()
              //退出登录的时候,删除对应的cookie
              .deletecookies("jsessionid")
              .and()
              //记住我相关设置
              .rememberme()
              //预定义key相关设置,默认是一串uuid
              .key("dhy")
              //令牌的持久化
              .tokenrepository(jdbctokenrepository())
              .and()
              //应用手机验证码的配置
              .apply(smscodesecurityconfig)
              .and()
              //图形验证码
              .addfilterbefore(captchacodefilter, usernamepasswordauthenticationfilter.class)
              //csrf关闭
              .csrf().disable();

   }

   //角色继承
   @bean
   rolehierarchy rolehierarchy() {
      rolehierarchyimpl hierarchy = new rolehierarchyimpl();
      hierarchy.sethierarchy("role_admin > role_user");
      return hierarchy;
   }

} 

以上就是spring security短信验证码实现详解的详细内容,更多关于spring security短信验证码的资料请关注其它相关文章!