Spring Security---验证码详解

x33g5p2x  于2021-11-20 转载在 Spring  
字(14.6k)|赞(0)|评价(0)|浏览(370)

验证码实现的三种方式

  • session存储验证码,不适用于集群应用
  • 共享session存储验证码,适用于集群应用
  • 基于对称算法的验证码,适用于集群应用

验证码的组成部分

验证码实际上和谜语有点像,分为谜面和谜底。谜面通常是图片,谜底通常为文字。谜面用于展现,谜底用于校验。

  • 对于字符型验证码。比如:谜面是显示字符串"ABGH"的图片,谜底是字符串"ABGH"
  • 对于计算类验证码。比如:谜面是“1+1=”的图片,谜底是“2”
  • 对于拖拽类的验证码。比如:谜面是一个拖拽式的拼图,谜底是拼图位置的坐标

总之,不管什么形式的谜面,最后用户的输入内容要和谜底进行验证。

session存储验证码

图中蓝色为服务端、澄粉色为客户端。

这是一种最典型的验证码实现方式,实现方式也比较简单。

  • 应用服务端随机的生成验证码文字
  • 将验证码文字存到session里面
  • 根据验证码文字生成验证码图片,响应给客户端
  • 检查用户输入的内容与验证码谜底是否一致

这种实现方式的优点就是比较简单,缺点就是:因为一套应用部署一个session,当我们把应用部署多套如:A、B、C,他们各自有一个session并且不共享。导致的结果就是验证码和图片由A生成,但是验证请求发送到了B,这样就不可能验证通过。

共享session存储验证码

分布式应用验证码的实现,实际上不是验证码的问题,而是如何保证session唯一性或共享性的问题。主要的解决方案有两种:

  • 通常我们实现负载均衡应用的前端都是使用nginx或者haproxy,二者都可以配置负载均衡策略。其中一种策略就是:你的客户端ip上一次请求的是A应用,你的下一次请求还转发给A应用。这样就保证了session的唯一性。但是这种方式有可能会导致A、B、C应用其中一个或两个分配了大量的请求,而另外一个处理很少的请求,导致负载并不均衡。
  • 另外一种非常通用的方式就是将分布式应用的session统一管理,也就是说原来A、B、C各自的session都存在自己的内存中,现在更改为统一存储到一个地方,大家一起用。这样就实现了session的唯一和共享,是实现分布式应用session管理的有效途径。在Spring框架内,最成熟的解决方案就是spring session + redis 。可自行参考实现。

基于对称算法的验证码

可能出于主机资源的考虑,可能出于系统架构的考量,有些应用是无状态的

  • 什么是无状态应用:就是不保存用户状态的应用。
  • 什么是用户状态:比如当你登陆之后,在session中保存的用户的名称、组织等等信息。
  • 所以可以简单的理解,无状态应用就是无session应用。当然这并不完全准确。

那么对于这些无状态的应用,我们就无法使用session,或者换个说法从团队开发规范上就不让使用session。那么我们的验证码该怎么做?

  • 同样,首先要生成随机的验证码(谜底),但是不做任何存储操作
  • 将谜底(验证码文字)加上时间串、应用信息等组成一个字符串进行加密。必须是对称加密,也就是说可以解密的加密算法。
  • 生成验证码图片,并与加密后的密文,通过cookies一并返回给客户端。
  • 当用户输入验证码提交登录之后,服务端解密cookies中的密文(主要是验证码文字),与用户的输入进行验证比对。

这种做法的缺陷是显而易见的:实际上就是将验证码文字在客户端服务端之间走了一遍。虽然是加密后的验证码文字,但是有加密就必须有解密,否则无法验证。所以更为稳妥的做法是为每一个用户生成密钥,并将密钥保存到数据库里面,在对应的阶段内调用密钥进行加密或者解密。
从密码学的角度讲,没有一种对称的加密算法是绝对安全的。所以更重要的是保护好你的密钥。正如没有一把锁头是绝对安全的,更重要的是保护好你的钥匙。

基于session的图片验证码实现

本节基于google开源的验证码实现类库kaptcha,作为验证码工具实现验证码功能开发。验证码工具类通常要具有以下三种功能方法:

  • 生成验证码文字或其他用于校验的数据形式(即谜底)
  • 生成验证码前端显示图片或拼图等(即谜面)
  • 用于校验用户输入与谜底的校验方法(如果是纯文字,就自己比对以下就可以。如果是基于物理图形拖拽、旋转等方式,需要专用的校验方法)

基于session的图片验证码实现

本节基于google开源的验证码实现类库kaptcha,作为验证码工具实现验证码功能开发。验证码工具类通常要具有以下三种功能方法:

  • 生成验证码文字或其他用于校验的数据形式(即谜底)
  • 生成验证码前端显示图片或拼图等(即谜面)
  • 用于校验用户输入与谜底的校验方法(如果是纯文字,就自己比对以下就可以。如果是基于物理图形拖拽、旋转等方式,需要专用的校验方法)

这种验证码类库有很多,但是都是基于以上逻辑。我们本节使用kaptcha。

验证码生成之配置使用kaptcha

<dependency>
   <groupId>com.github.penggle</groupId>
   <artifactId>kaptcha</artifactId>
   <version>2.3.2</version>
   <exclusions>
      <exclusion>
         <artifactId>javax.servlet-api</artifactId>
         <groupId>javax.servlet</groupId>
      </exclusion>
   </exclusions>
</dependency>
  • 假设我们的配置文件是application.yml,新建一个单独的文件叫做kaptcha.properties。因为kaptcha的配置不符合yaml的规范格式,所以只能采用properties。需配合注解PropertySourc使用。
  • 假设我们的配置文件是application.properties,将下面这段代码加入进去即可,不用单独建立文件。
  • 下面的验证码配置,从英文单词的角度很容易理解,当我们需要调整验证码的边框、颜色、大小、字体等属性的时候,可以修改这些配置。
kaptcha.border=no
kaptcha.border.color=105,179,90
kaptcha.image.width=100
kaptcha.image.height=45
kaptcha.session.key=code
kaptcha.textproducer.font.color=blue
kaptcha.textproducer.font.size=35
kaptcha.textproducer.char.length=4
kaptcha.textproducer.font.names=宋体,楷体,微软雅黑

下面的代码加载了配置文件中的kaptcha配置(参考Spring Boot的配置加载),如果是独立的properties文件,需加上PropertySource注解说明。

另外,我们通过加载完成的配置,初始化captchaProducer的Spring Bean,用于生成验证码。

@Component
@PropertySource(value = {"classpath:kaptcha.properties"})
public class CaptchaConfig {

    @Value("${kaptcha.border}")
    private String border;
    @Value("${kaptcha.border.color}")
    private String borderColor;
    @Value("${kaptcha.textproducer.font.color}")
    private String fontColor;
    @Value("${kaptcha.image.width}")
    private String imageWidth;
    @Value("${kaptcha.image.height}")
    private String imageHeight;
    @Value("${kaptcha.textproducer.char.length}")
    private String charLength;
    @Value("${kaptcha.textproducer.font.names}")
    private String fontNames;
    @Value("${kaptcha.textproducer.font.size}")
    private String fontSize;
    @Value("${kaptcha.session.key}")
    private String sessionKey;

    @Bean(name = "captchaProducer")
    public DefaultKaptcha getKaptchaBean() {
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        Properties properties = new Properties();
        properties.setProperty("kaptcha.border", border);
        properties.setProperty("kaptcha.border.color", borderColor);
        properties.setProperty("kaptcha.textproducer.font.color", fontColor);
        properties.setProperty("kaptcha.image.width", imageWidth);
        properties.setProperty("kaptcha.image.height", imageHeight);
        properties.setProperty("kaptcha.session.key", sessionKey);
        properties.setProperty("kaptcha.textproducer.char.length", charLength);
        properties.setProperty("kaptcha.textproducer.font.names", fontNames);
        properties.setProperty("kaptcha.textproducer.font.size",fontSize);
        //kapcha的配置类
        Config config = new Config(properties);
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }

}

至此,Kaptcha开源验证码软件的配置我们就完成了,如果发现IDEA环境下配置文件读取中文乱码,修改如下配置。

验证码生成之session保存

生成验证码的Controller。同时需要开放路径"/kaptcha"的访问权限,配置成不需登录也无需任何权限即可访问的路径。

  • 通过captchaProducer.createText()生成验证码文字,并和失效时间一起保存到CaptchaImageVO中。
  • 将CaptchaImageVO验证码信息类对象,保存到session中。(这个类的代码后文有介绍)
  • 通过captchaProducer.createImage(capText)生成验证码图片,并通过ServletOutputStream返回给前端
@Controller
public class CodeController {

    //kapcha验证码生成
    @Resource
    DefaultKaptcha captchaProducer;
 
    @RequestMapping("/kaptcha")
    public void getKaptchaImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
        HttpSession session = request.getSession();
        // 禁止服务器缓存
        response.setDateHeader("Expires",0);
        // 设置标准的 HTTP/1.1 no-cache headers.
        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
        // 设置IE扩展 HTTP/1.1 no-cache headers (use addHeader).
        response.addHeader("Cache-Control", "post-check=0, pre-check=0");
        // 设置标准 HTTP/1.0 不缓存图片
        response.setHeader("Pragma", "no-cache");
        // 返回一个 jpeg图片, 默认是text/html
        response.setContentType("image/jpeg");
 
        // 生成验证码
        String capText = captchaProducer.createText(); // 为图片创建文本
        //创建验证码对象----验证码,过期时间
        CaptchaImageVO captchaImageVO = new CaptchaImageVO(capText,2 * 60);
        //将验证码存到session
        session.setAttribute(Constants.KAPTCHA_SESSION_KEY, captchaImageVO);
        //将图片返回给前端
        try(ServletOutputStream out = response.getOutputStream();) {
            BufferedImage bi = captchaProducer.createImage(capText);
            ImageIO.write(bi, "jpg", out);
            out.flush();
        }//使用try-with-resources不用手动关闭流

    }
}

我们要把CaptchaImageVO保存到session里面。所以该类中不要加图片,只保存验证码文字和失效时间,用于后续验证即可。把验证码图片保存起来既没有用处,又浪费内存。

@Data
public class CaptchaImageVO {

    //验证码文字
    private String code;
    //验证码失效时间
    private LocalDateTime expireTime;

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

    //验证码是否失效
    public boolean isExpried() {
        return LocalDateTime.now().isAfter(expireTime);
    }

    public String getCode() {
        return code;
    }
}

验证码用户访问

把如下代码加入到登录页面合适的位置,注意图片img标签放到登录表单中。

<img src="/kaptcha" id="kaptcha" width="110px" height="40px"/>

<script>
    window.onload=function(){
        var kaptchaImg = document.getElementById("kaptcha");
        kaptchaImg.onclick = function(){
            kaptchaImg.src = "/kaptcha?" + Math.floor(Math.random() * 100)
        }
    }
</script>
  • 实现的效果是,页面初始化即加载验证码。以后每一次点击,都会更新验证码。
  • 注意:一定设置width和height,否则图片无法显示。
  • 需要为“/kaptcha”配置permitAll公开访问权限,否则无法访问到
http.authorizeRequests()
              .antMatchers("/admin/**").hasRole("admin")
              .antMatchers("/user/**").hasRole("user")
              .antMatchers("/kaptcha").permitAll()//放行验证码的显示请求,不需要认证
              .anyRequest().authenticated()
              ....

验证码之安全校验

  • 编写我们的自定义图片验证码过滤器CaptchaCodeFilter,过滤器中拦截登录请求
  • CaptchaCodeFilter过滤器中从seesion获取验证码文字与用户输入比对,比对通过执行其他过滤器链
  • 比对不通过,抛出SessionAuthenticationException异常,交给AuthenticationFailureHandler处理
  • 最后将CaptchaCodeFilter放在UsernamePasswordAuthenticationFilter表单过滤器之前执行。
import com.google.code.kaptcha.Constants;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.stereotype.Component;
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.annotation.Resource;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Objects;

@Component
public class CaptchaCodeFilter extends OncePerRequestFilter {

    @Resource
    MyFailHandler myAuthenticationFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
         // 必须是登录的post请求才能进行验证,其他的直接放行
        if("/login".equals(request.getRequestURI())
                &&request.getMethod().equalsIgnoreCase("post")){
            try{
                //1.验证谜底与用户输入是否匹配
                validate(new ServletWebRequest(request));
            }catch(AuthenticationException e){
                 //2.捕获步骤1中校验出现异常,交给失败处理类进行进行处理
                myAuthenticationFailureHandler.onAuthenticationFailure(request,response,e);
                return;
            }

        }
        //通过校验,就放行
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest request) throws ServletRequestBindingException {

        HttpSession session = request.getRequest().getSession();
        //获取用户登录界面输入的code
        String codeInRequest = ServletRequestUtils.getStringParameter(
                request.getRequest(),"code");
        if(codeInRequest.isEmpty()){
            throw new SessionAuthenticationException("验证码不能为空");
        }

        // 获取session池中的验证码谜底
        CaptchaImageVO codeInSession = (CaptchaImageVO)
                session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
        if(Objects.isNull(codeInSession)) {
            throw new SessionAuthenticationException("您输入的验证码不存在");
        }

        // 校验服务器session池中的验证码是否过期
        if(codeInSession.isExpried()) {
            session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
            throw new SessionAuthenticationException("验证码已经过期");
        }

        // 请求验证码校验
        if(!codeInSession.getCode().equals(codeInRequest)) {
            throw new SessionAuthenticationException("验证码不匹配");
        }

    }
}
  • 上面代码中之所以抛出SessionAuthenticationException异常,因为该异常是AuthenticationException的子类,同时也是针对Session数据校验的异常。可以在doFilterInternal中被捕获,交给MyAuthenticationFailureHandler处理。MyAuthenticationFailureHandler 只认识AuthenticationException及其子类
  • codeInRequest是用户请求输入的验证码
  • codeInSession是用户请求验证码图片时,保存在session中的验证码谜底。
@Component
//继承该类,是因为其默认的实现,可以简化我们的代码
public class MyFailHandler extends SimpleUrlAuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        String errorMsg="用户名或密码错误";
        //验证码错误
        //如果是验证码错误,响应JSON数据给前端
        if(e instanceof SessionAuthenticationException)
        {
            errorMsg=e.getMessage();
            httpServletResponse.setContentType("text/plain;charset=UTF-8");
            httpServletResponse.getWriter().write(errorMsg);
            return;
        }
        //如果是用户名密码错误,调用父类的方法,默认跳转到登录页面
        super.onAuthenticationFailure(httpServletRequest,httpServletResponse,e);
    }
}

最后将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行。login.html登录请求中要传递参数:code

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

   @Override
   protected void configure(HttpSecurity http) throws Exception {
      http.//处理需要认证的请求
              authorizeRequests()
              //放行请求,前提:是对应的角色才行
              .antMatchers("/admin/**").hasRole("admin")
              .antMatchers("/user/**").hasRole("user")
              //无需登录凭证,即可放行
              .antMatchers("/kaptcha").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()
              //将CaptchaCodeFilter过滤器放到用户名密码登录过滤器之前执行
              .addFilterBefore(captchaCodeFilter, UsernamePasswordAuthenticationFilter.class)
              //csrf关闭
              .csrf().disable();

   }

   //角色继承
   @Bean
   RoleHierarchy roleHierarchy() {
      RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
      hierarchy.setHierarchy("ROLE_admin > ROLE_user");
      return hierarchy;
   }

}

前端代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<body>
  用户名:<input type="text" name="username" id="name"/><br/>
  密码:<input type="password" name="password" id="pwd"/><br/>
  验证码: <input type="text" id="code"/>
  <img src="/kaptcha" id="kaptcha" width="110px" height="40px"/></br>
  记住我: <input type="checkbox" name="remember-me" id="remember-me"/><br/>
  <input type="submit" id="loginBtn" value="登录" />
</body>
</html>

<script>
  window.onload=function()
  {
    //获取验证码的dom对象
    var kaptchaImg = document.getElementById("kaptcha");
   //图片被点击
    kaptchaImg.onclick = function(){
      //重新请求设置一遍路径,路径后面跟上随机数,防止从浏览器缓存中获取数据
      kaptchaImg.src = "/kaptcha?" + Math.floor(Math.random() * 100)
    }
  }

  $("#loginBtn").click(function ()
  {
    $.ajax({
      url:'login',
      type:'post',
      data: {
        'username':$("#name").val(),'password':$("#pwd").val(),
        'code':$("#code").val(),'remember-me':$("#remember-me").val()
      }
      ,success: function (res)
      {
         alert(res)
      }
    })
  })

</script>

测试

不输入验证码:

验证码输入错误:

验证码过期:

验证码输入成功,但是用户名密码密码错误,失败处理器调用父类方法调回到登录页面:

验证码输入成功,用户名密码输入成功,跳转到主页

相关文章