转载

12.SpringSecurity-实现图形验证码

前言

实现图形验证码:

  1. 开发生成图形验证码接口
  2. 在认证流程中加入图形验证码校验
  3. 重构代码

内容

1.开发生成图形验证码接口

根据随机数生成图片

将随机数存到session中

将生成图片写到接口的响应中

1.1 验证码对象封装

放到公用模块core中,用于app,web端公用。

public class ImageCode {
    private BufferedImage image;
    /**
     * code是一个随机数,图片是根据随机数生成的,
     * 存放到session里面,后面用户提交登录请求时候要去验证的
     */
    private String code;
    /**
     * 过期时间
     */
    private LocalDateTime expireTime;
    public ImageCode(BufferedImage image,String code,int expireIn){
      this.image=image;
      this.code=code;
        /**
         * 过期时间传递的参数应该是一个秒数:根据这个秒数去计算过期时间
         */
        this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
    }

    public BufferedImage getImage() {
        return image;
    }

    public void setImage(BufferedImage image) {
        this.image = image;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public LocalDateTime getExpireTime() {
        return expireTime;
    }

    public void setExpireTime(LocalDateTime expireTime) {
        this.expireTime = expireTime;
    }
}

1.2.验证码逻辑处理接口

@RestController
public class ValidateCodeController {
    private static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        /**
         * 1.根据随机数生成图片
         * 2.将随机数存到session中
         * 3.将生成图片写到接口的响应中
         */
        ImageCode imageCode = createImageCode(request);
        sessionStrategy.setAttribute(new ServletWebRequest(request),SESSION_KEY,imageCode);
        ImageIO.write(imageCode.getImage(),"JPEG",response.getOutputStream());
    }

    private ImageCode createImageCode(HttpServletRequest request) {
        int width = 67;
        int height = 23;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();

        Random random = new Random();

        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < 155; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

        String sRand = "";
        for (int i = 0; i < 4; i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }

        g.dispose();
        return new ImageCode(image, sRand, 60);
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc
     * @param bc
     * @return
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > 255) {
            fc = 255;
        }
        if (bc > 255) {
            bc = 255;
        }
        int r = fc + random.nextInt(bc - fc);
        int g = fc + random.nextInt(bc - fc);
        int b = fc + random.nextInt(bc - fc);
        return new Color(r, g, b);
    }
}

1.3.前端获取验证码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
<h2>标准登录页面</h2>
<h3>表单登录</h3>
<form action="/authentication/form" method="post">
    <table>
        <tr>
            <td>用户名:</td>
            <td><input type="text" name="username"></td>
        </tr>
        <tr>
            <td>密码:</td>
            <td><input type="password" name="password"></td>
        </tr>
        <tr>
            <td>图形验证码</td>
            <td>
                <input type="text" name="imageCode">
                <img src="/code/image"> 
            </td>
        </tr>
        <tr>
            <td colspan="2"><button type="submit">登录</button></td>
        </tr>
    </table>
</form>
</body>
</html>

1.4 在授权模块添加:允许验证码生成请求permitAll()

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder  passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    /**
     * 定义web安全配置类:覆盖config方法
     * 1.参数为HttpSecurity
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /**
         * 定义了任何请求都需要表单认证
         */
       http.formLogin()//表单登录---指定了身份认证方式
          // .loginPage("/login.html")
           .loginPage("/authentication/require")
           .loginProcessingUrl("/authentication/form")//配置UsernamePasswordAuthenticationFilter需要拦截的请求
           .successHandler(myAuthenticationSuccessHandler)//表单登录成功之后用自带的处理器
           .failureHandler(myAuthenticationFailureHandler)//表单登录失败之后用自带的处理器
       // http.httpBasic()//http的basic登录
          .and()
          .authorizeRequests()//对请求进行授权
          .antMatchers("/authentication/require",securityProperties.getBrowser().getLoginPage(),"/code/image").permitAll()//对匹配login.html的请求允许访问
          .anyRequest()//任何请求
          .authenticated()
           .and()
           .csrf().disable();//都需要认证
    }
}

2.在认证流程中加入图形验证码校验 过滤器

12.SpringSecurity-实现图形验证码

我们在实现登录请求时候 都是在实现Spring提供的接口;并且加密解密实现都是Spring已经提供给我们自己的,但是spring并没有给我们提供图形验证码,因为spring security他的基本原理就是一个过滤器链。在这个链上我们可以加入自己写的过滤器。我们在UsernamePasswordAuthticationFilter前加一个自定义的过滤器。 extends OncePerRequestFilter。实现:doFilterInternal方法。

public class ValidateCodeFilter extends OncePerRequestFilter {
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

    @Autowired
    private SessionStrategy sessionStrategy;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        logger.info("验证码过滤器:doFilterInternal: requestURI:[{}]  requestMethod:[{}]",request.getRequestURI(),request.getMethod());
        /**
         * 如果是需要认证请求,我们进行家宴
         * 如果校验失败,使用我们自定义的校验失败处理类处理
         * 如果不需要认证,我们放行进入下一个Filter
         */
        if(StringUtils.equals("/authentication/form",request.getRequestURI()) && StringUtils.endsWithIgnoreCase(request.getMethod(),"post")){
           try{
               validate(new ServletWebRequest(request));
           }catch (ValidateCodeException e){
               authenticationFailureHandler.onAuthenticationFailure(request,response,e);
           }
        }
        filterChain.doFilter(request,response);
    }

    private void validate(ServletWebRequest servletWebRequest) throws ServletRequestBindingException {
           //1.获取存放到session中的验证码
        ImageCode codeInSession = (ImageCode)sessionStrategy.getAttribute(servletWebRequest, ValidateCodeController.SESSION_KEY);
           //2.获取请求中的验证码
        String codeInRequest = ServletRequestUtils.getStringParameter(servletWebRequest.getRequest(), "imageCode");
          if(StringUtils.isBlank(codeInRequest)){
              throw new ValidateCodeException("验证码的值不能为空");
          }

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

          if(codeInSession.isExpried()){
              sessionStrategy.removeAttribute(servletWebRequest,ValidateCodeController.SESSION_KEY);
              throw new ValidateCodeException("验证码已过期");
          }

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

          sessionStrategy.removeAttribute(servletWebRequest,ValidateCodeController.SESSION_KEY);
    }
}

需要把自定义的过滤器加到UsernamePasswordAuthenticationFilter前面去。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SecurityProperties securityProperties;

    @Autowired
    private MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    private MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Bean
    public PasswordEncoder  passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    /**
     * 定义web安全配置类:覆盖config方法
     * 1.参数为HttpSecurity
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /**
         * 定义了任何请求都需要表单认证
         */
        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
               .formLogin()//表单登录---指定了身份认证方式
          // .loginPage("/login.html")
           .loginPage("/authentication/require")
           .loginProcessingUrl("/authentication/form")//配置UsernamePasswordAuthenticationFilter需要拦截的请求
           .successHandler(myAuthenticationSuccessHandler)//表单登录成功之后用自带的处理器
           .failureHandler(myAuthenticationFailureHandler)//表单登录失败之后用自带的处理器
       // http.httpBasic()//http的basic登录
          .and()
          .authorizeRequests()//对请求进行授权
          .antMatchers("/authentication/require",securityProperties.getBrowser().getLoginPage(),"/code/image").permitAll()//对匹配login.html的请求允许访问
          .anyRequest()//任何请求
          .authenticated()
           .and()
           .csrf().disable();//都需要认证
    }
}

我们重启服务,测试:

1.web端输入:( http://127.0.0.1 :8088/login.html)

输入用户名/密码;但是不输入验证码时候;

12.SpringSecurity-实现图形验证码

后台报了验证码异常:

12.SpringSecurity-实现图形验证码

然后走了我们自己失败处理:由于项目配置的不是json格式:

12.SpringSecurity-实现图形验证码

所以排除异常:跳转到SpringBoot提供的页面:

12.SpringSecurity-实现图形验证码

我们修改下demo的配置:

登录类型改为:JSON

12.SpringSecurity-实现图形验证码

然后重启服务尝试登录:此时报出的异常为:

12.SpringSecurity-实现图形验证码

缺点:1.打印出了堆栈信息

2.将认证的信息:用户名/密码;说明我们调用到了后端的UsernamePasswordFilter过滤器了(按理我们应该不能调用到)

针对第一个问题:在自定义错误处理器中,我们返回给前端的返回异常消息即可。

@Component("myAuthenticationFailureHandler")
public class MyAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler{
    private Logger logger = LoggerFactory.getLogger(getClass());

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        /**
         * 1.第三个参数不是Authentication了,因为是登录失败抛异常了,所以是:AuthenticationException
         * 2.因为是登录失败,所以我们返回的时候状态码不再是200,而是500
         */
        logger.info("登录失败");

        if(LoginType.JSON.equals(securityProperties.getBrowser().getLoginType())){//JSON
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            //返回给前端错误信息时候,打印出异常消息即可。
            response.getWriter().write(objectMapper.writeValueAsString(new SimpleResponse(exception.getMessage())));
        }else {
            super.onAuthenticationFailure(request,response,exception);
        }
    }
}

12.SpringSecurity-实现图形验证码

针对第二个问题:在ValidateCodeFilter校验失败后立马结束。

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        logger.info("验证码过滤器:doFilterInternal: requestURI:[{}]  requestMethod:[{}]",request.getRequestURI(),request.getMethod());
        /**
         * 如果是需要认证请求,我们进行家宴
         * 如果校验失败,使用我们自定义的校验失败处理类处理
         * 如果不需要认证,我们放行进入下一个Filter
         */
        if(StringUtils.equals("/authentication/form",request.getRequestURI()) && StringUtils.endsWithIgnoreCase(request.getMethod(),"post")){
           try{
               validate(new ServletWebRequest(request));
           }catch (ValidateCodeException e){
               authenticationFailureHandler.onAuthenticationFailure(request,response,e);
               //抛出异常校验失败,不再走小面过滤器执行链
               return;
           }
        }
        filterChain.doFilter(request,response);
    }

正常正度得到如下结果:

12.SpringSecurity-实现图形验证码

3. 重构代码

重构图形验证码主要是下面3个方面:

  1. 验证码基本参数可配置
  2. 验证码拦截的接口可配置
  3. 验证码的生成逻辑可配置

3.1 验证码基本参数可配置:

  1. 验证码长宽可配置
  2. 验证码位数可配置
  3. 验证码有效时间可配置

12.SpringSecurity-实现图形验证码

针对于基本参数配置,我们做成3级配置。

12.SpringSecurity-实现图形验证码

默认配置写在spring-security-core里面,

应用级别配置是自己应用的配置。

请求级配置:调用接口时传递参数配置,在各个请求时候图形验证码大小可能不一致的。

3.1.1 默认配置

将验证码长宽可配置、验证码位数可配置、验证码有效时间可配置作为属性参数写到配置中;

ImageCodeProperties:

public class ImageCodeProperties {
    private int width = 67;
    private int height = 23;
    private int length = 4;
    private int expireIn = 60;
    //getter setter
}

我们后面还会讲解短信验证码,所以我们的配置做成多个类级别配置:ValidateCodeProperties;

public class ValidateCodeProperties {
    private ImageCodeProperties image = new ImageCodeProperties();

    public ImageCodeProperties getImage() {
        return image;
    }
    public void setImage(ImageCodeProperties image) {
        this.image = image;
    }
}

最后封装到:

//此类读取配置文件里所有以yxm.security开头的配置
@ConfigurationProperties(prefix = "yxm.security")
public class SecurityProperties {
    //其中yxm.security.browser开头的配置否会读取到BrowserProperties中
    private BrowserProperties browser = new BrowserProperties();

    private ValidateCodeProperties code = new ValidateCodeProperties();
    //getter setter
}

3.1.2 应用级配置

我们在spring-security-demo应用下自己在配置文件里面配置:

yxm.security.code.image.length=6

3.1.3 请求级配置

请求级别配置就是在ValidateCodeController里面验证码的生成:从request前端获取不到值 就用默认配置,能获取到值就用前端传递过来的配置。

public ImageCode generate(ServletWebRequest request) {
        //从request前端获取不到值 就用默认配置,能获取到值就用前端传递过来的配置
        int width = ServletRequestUtils.getIntParameter(request.getRequest(),"width",securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(),"height",securityProperties.getCode().getImage().getHeight());

        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);

        Graphics g = image.getGraphics();

        Random random = new Random();

        g.setColor(getRandColor(200, 250));
        g.fillRect(0, 0, width, height);
        g.setFont(new Font("Times New Roman", Font.ITALIC, 20));
        g.setColor(getRandColor(160, 200));
        for (int i = 0; i < 155; i++) {
            int x = random.nextInt(width);
            int y = random.nextInt(height);
            int xl = random.nextInt(12);
            int yl = random.nextInt(12);
            g.drawLine(x, y, x + xl, y + yl);
        }

        String sRand = "";
        for (int i = 0; i < securityProperties.getCode().getImage().getLength(); i++) {
            String rand = String.valueOf(random.nextInt(10));
            sRand += rand;
            g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
            g.drawString(rand, 13 * i + 6, 16);
        }

        g.dispose();
        return new ImageCode(image, sRand, securityProperties.getCode().getImage().getExpireIn());
    }

我们将页面请求参数设置为:200,应用demo配置中改为100;

<tr>
            <td>图形验证码</td>
            <td>
                <input type="text" name="imageCode">
                <img src="/code/image?width=200">
            </td>
        </tr>
yxm.security.code.image.width=100

我们启动应用,发现:我们请求的url中width=200的值会覆盖掉:spring-security-demo里面的width=100的值;spring-security-demo里面的length=6的值覆盖掉系统默认的4。

12.SpringSecurity-实现图形验证码

3.2 验证码拦截接口可配置

目前我们验证码过滤器拦截的接口是写死的: /authentication/form

但是我们这个校验的逻辑是可以运用在多个应用上的。比如我们的用户访问接口:"/user"也需要验证码校验的,那么这个时候,我们可以把其配置到验证码拦截接口url上去。应用使用验证码时候可以指定哪些服务需要校验验证码。

12.SpringSecurity-实现图形验证码

我们在ImageCodeProperties里面添加:url

12.SpringSecurity-实现图形验证码

3.3 验证码生成逻辑可配置

我们现在图形验证码逻辑是写死的,是一个相对简单的逻辑,有些项目可能需要更复杂的验证码,我们通过配置来替换这一段验证码。

12.SpringSecurity-实现图形验证码

原文  https://segmentfault.com/a/1190000022037076
正文到此结束
Loading...