Spring Security技术栈开发企业级认证与授权(九)开发图形验证码接口

2020-04-03 16:22:56 浏览数 (1)

在设计登录模块的时候,图形验证码基本上都是标配,本篇博客重点介绍开发可重用的图形验证码接口,该接口支持用户自定义配置,比如验证码的长度、验证码图形的宽度和高度等信息。

本文的目标是开发一个图形验证码接口,该验证码支持用户自定义长度,以及生成图片后图片的宽度和高度、验证码的过期时间等。接下来按照整个设计思路介绍开发流程。

一、开发图形验证码实体类及属性类
1)图形验证码实体类

图形验证码一般都需要一个实体类来进行承载,在这里我设置了三个属性,分别是BufferedImage类型的属性、String类型的验证码以及LocalDateTime类型的时间参数。具体的代码如下:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import lombok.Data;

import java.awt.image.BufferedImage;
import java.time.LocalDateTime;

/**
 * 图片验证码
 *
 * @author lemon
 * @date 2018/4/6 下午4:34
 */
@Data
public class ImageCode {

    private BufferedImage image;

    private String code;

    private LocalDateTime expireTime;

    public ImageCode(BufferedImage image, String code, LocalDateTime expireTime) {
        this.image = image;
        this.code = code;
        this.expireTime = expireTime;
    }

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

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

这里设置了两个有参构造方法,常用的第二个有参构造方法的最后一个参数指定了验证码的过期时间,也就是在多少秒后失效。具体的判断方法由LocalDateTime.now().isAfter(expireTime)来进行判断的。

2)图形验证码属性类

图形验证码的实体类是承载验证码的具体信息,而属性类是为了定义图形验证码的长度、图片的宽度高度以及验证码的过期时间等基本属性。这些属性支持用户在YAML配置文件中进行配置的,当然也具备了默认值。具体代码如下:

代码语言:javascript复制
package com.lemon.security.core.properties;

import lombok.Data;

/**
 * 图形验证码的默认配置
 *
 * @author lemon
 * @date 2018/4/6 下午9:42
 */
@Data
public class ImageCodeProperties {

    /**
     * 验证码宽度
     */
    private int width = 67;
    /**
     * 验证码高度
     */
    private int height = 23;
    /**
     * 验证码长度
     */
    private int length = 4;
    /**
     * 验证码过期时间
     */
    private int expireIn = 60;

    /**
     * 需要验证码的url字符串,用英文逗号隔开
     */
    private String url;
    
}

为了保持和之前的浏览器的基本设置保持一致,这里包装一层配置,代码如下:

代码语言:javascript复制
package com.lemon.security.core.properties;

import lombok.Data;

/**
 * 封装多个配置的类
 *
 * @author lemon
 * @date 2018/4/6 下午9:45
 */
@Data
public class ValidateCodeProperties {

    private ImageCodeProperties image = new ImageCodeProperties();
}

再将这个类包装到SecurityProperties类中,代码如下:

代码语言:javascript复制
package com.lemon.security.core.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author lemon
 * @date 2018/4/5 下午3:08
 */
@Data
@ConfigurationProperties(prefix = "com.lemon.security")
public class SecurityProperties {

    private BrowserProperties browser = new BrowserProperties();

    private ValidateCodeProperties code = new ValidateCodeProperties();
}

那么在配置文件中配置的方法如下:

代码语言:javascript复制
# 配置图形验证码
com:
  lemon:
    security:
      code:
        image:
          length: 6
          url: /user,/user/*

这个配置相当于用户自定义了验证码的长度为6,以及需要验证码的URI/user/uset/*,在默认的情况下,长度为4。这几个类基本完成了图形验证码的自定义功能。

二、编写图形验证码生成接口和实现类

图形验证码其实是完全不需要编写接口的,这里编写接口是为了方便用户可以自定义接口的实现类,这样就可以自己写生成验证码的逻辑,而不是使用系统默认的生成方式。具体的接口如下:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import org.springframework.web.context.request.ServletWebRequest;

/**
 * @author lemon
 * @date 2018/4/7 上午11:06
 */
public interface ValidateCodeGenerator {

    /**
     * 生成图片验证码
     *
     * @param request 请求
     * @return ImageCode实例对象
     */
    ImageCode generate(ServletWebRequest request);
}

这里为什么会传入一个ServletWebRequest类型的参数,是因为这个有许多对请求中参数操作的方法,十分方便,请看后面的实现类:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import lombok.Data;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;

import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.Random;

/**
 * @author lemon
 * @date 2018/4/7 上午11:09
 */
@Data
public class ImageCodeGenerator implements ValidateCodeGenerator {

    private static final String IMAGE_WIDTH_NAME = "width";
    private static final String IMAGE_HEIGHT_NAME = "height";
    private static final Integer MAX_COLOR_VALUE = 255;

    private SecurityProperties securityProperties;

    @Override
    public ImageCode generate(ServletWebRequest request) {
        int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, securityProperties.getCode().getImage().getWidth());
        int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME, 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);
        }

		// 生成数字验证码
        StringBuilder sRand = new StringBuilder();
        for (int i = 0; i < securityProperties.getCode().getImage().getLength(); i  ) {
            String rand = String.valueOf(random.nextInt(10));
            sRand.append(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.toString(), securityProperties.getCode().getImage().getExpireIn());
    }

    /**
     * 生成随机背景条纹
     *
     * @param fc 前景色
     * @param bc 背景色
     * @return RGB颜色
     */
    private Color getRandColor(int fc, int bc) {
        Random random = new Random();
        if (fc > MAX_COLOR_VALUE) {
            fc = MAX_COLOR_VALUE;
        }
        if (bc > MAX_COLOR_VALUE) {
            bc = MAX_COLOR_VALUE;
        }
        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);
    }
}

这里提供了生成图片验证码的具体实现,其中两行代码:

代码语言:javascript复制
int width = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_WIDTH_NAME, securityProperties.getCode().getImage().getWidth());
int height = ServletRequestUtils.getIntParameter(request.getRequest(), IMAGE_HEIGHT_NAME, securityProperties.getCode().getImage().getHeight());

这个是由Spring提供的工具类来获取请求中的参数,第一个参数是HttpServletRequest请求,第二个是参数名字,第三个是默认值,如果没有获取到指定名称的参数的值,那么就使用这个默认值。从这两行代码中可知,请求参数的宽度和高度的优先级将大于YAML配置文件中的参数,更加大于默认参数。本来这个类是可以使用@Component注解来标记为SpringBean的,但是没有这么做,这是因为这个实现类是本项目默认的,不一定完全符合用户的需求,所以可以将其进行配置,而不是一定成为SpringBean。具体的配置如下代码:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author lemon
 * @date 2018/4/7 上午11:22
 */
@Configuration
public class ValidateCodeBeanConfig {

    private final SecurityProperties securityProperties;

    @Autowired
    public ValidateCodeBeanConfig(SecurityProperties securityProperties) {
        this.securityProperties = securityProperties;
    }

    @Bean
    @ConditionalOnMissingBean(name = "imageCodeGenerator")
    public ValidateCodeGenerator imageCodeGenerator() {
        ImageCodeGenerator imageCodeGenerator = new ImageCodeGenerator();
        imageCodeGenerator.setSecurityProperties(securityProperties);
        return imageCodeGenerator;
    }
}

其实这个配置和在ImageCodeGenerator类上使用@Component注解效果是一致的,都会被标记为SpringBean,但是在这里,在配置的过程中使用了一个条件:@ConditionalOnMissingBean(name = "imageCodeGenerator"),也就是说上下文环境中如果没有名称为imageCodeGeneratorSpring Bean的话,那么就配置项目默认的Bean,否则将不配置这个Bean,这也就是说,如果用户自定义了一个类实现了ValidateCodeGenerator接口,并且实现类的在Spring容器中Bean的名字为imageCodeGenerator,那么将使用用户的实现类来生成图形验证码。到现在这一步,基本完成了图形验证码的核心需求。

三、编写图形验证码生成接口

图形验证码接口将生成一个JPEG的图片,那么在前端就可以写一个img标签,src属性指向接口。具体的Controller方法如下所示:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.request.ServletWebRequest;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author lemon
 * @date 2018/4/6 下午4:41
 */
@RestController
public class ValidateCodeController {

    static final String SESSION_KEY = "SESSION_KEY_IMAGE_CODE";
    private static final String FORMAT_NAME = "JPEG";

    private final ValidateCodeGenerator imageCodeGenerator;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    @Autowired
    public ValidateCodeController(ValidateCodeGenerator imageCodeGenerator) {
        this.imageCodeGenerator = imageCodeGenerator;
    }

    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 第一步:根据请求生成一个图形验证码对象
        ImageCode imageCode = imageCodeGenerator.generate(new ServletWebRequest(request));
        // 第二步:将图形验证码对象存到session中,第一个参数可以从传入的请求中获取session
        sessionStrategy.setAttribute(new ServletRequestAttributes(request), SESSION_KEY, imageCode);
        // 第三步:将生成的图片写到接口的响应中
        ImageIO.write(imageCode.getImage(), FORMAT_NAME, response.getOutputStream());
    }
}

这里使用imageCodeGenerator对象的generate方法生成了图形验证码,并将验证码存入到了session中,最后将图片写回到输出流中。

四、编写验证码的校验逻辑

验证码生成以后自动写回到了浏览器页面上,并以图片的形式进行了展示,与此同时,生成的图形验证码被设置了过期时间,并存入到session中,当用户登录的时候,正确的逻辑是将登录的验证码参数取出来和session中的验证码进行对比,如果验证码对比通过后才开始验证用户名和密码,由于用户名和密码的验证用的是UsernamePasswordAuthenticationFilter来进行验证的,所以这里也需要写一个过滤器,并且将这个过滤器放在UsernamePasswordAuthenticationFilter之前。先来编写过滤器:

代码语言:javascript复制
package com.lemon.security.core.validate.code;

import com.lemon.security.core.properties.SecurityProperties;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.apache.commons.lang3.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.Arrays;
import java.util.HashSet;
import java.util.Set;

/**
 * @author lemon
 * @date 2018/4/6 下午8:23
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

    private static final String SUBMIT_FORM_DATA_PATH = "/authentication/form";

    private AuthenticationFailureHandler authenticationFailureHandler;

    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

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

    private SecurityProperties securityProperties;

    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getImage().getUrl(), ",");
        urls.addAll(Arrays.asList(configUrls));
        // 登录的链接是必须要进行验证码验证的
        urls.add(SUBMIT_FORM_DATA_PATH);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean action = false;
        for (String url : urls) {
            // 如果实际访问的URL可以与用户在YML配置文件中配置的相同,那么就进行验证码校验
            if (antPathMatcher.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);
    }

    /**
     * 验证码校验逻辑
     *
     * @param request 请求
     * @throws ServletRequestBindingException 请求异常
     */
    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        // 从session中获取图片验证码
        ImageCode imageCodeInSession = (ImageCode) sessionStrategy.getAttribute(request, ValidateCodeController.SESSION_KEY);
        // 从请求中获取用户填写的验证码
        String imageCodeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "imageCode");
        if (StringUtils.isBlank(imageCodeInRequest)) {
            throw new ValidateCodeException("验证码不能为空");
        }
        if (null == imageCodeInSession) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (imageCodeInSession.isExpired()) {
            sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equalsIgnoreCase(imageCodeInRequest, imageCodeInSession.getCode())) {
            throw new ValidateCodeException("验证码不匹配");
        }
        // 验证成功,删除session中的验证码
        sessionStrategy.removeAttribute(request, ValidateCodeController.SESSION_KEY);
    }
}

这个过滤器继承了OncePerRequestFilter,这就保证了一次请求仅仅会运行一次过滤器,不会重复运行。而实现InitializingBean是为了当前类作为Spring Bean进行实例化完成(成员属性全部初始化完成)的时候,会自动调用这个接口的afterPropertiesSet方法,当然,如果这个类没有被Spring进行实例化,那么就需要手动调用这个方法,这里就是使用的手动调用afterPropertiesSet方法。这里afterPropertiesSet方法是将用户配置的需要对验证码进行校验的连接进行装配,将以英文逗号隔开的连接装配到字符串数组中。在后面的doFilterInternal方法中,将遍历这个字符串数组,如果当前访问的链接包含在这个数组中,将进行校验操作,否则该过滤器直接放行。具体的校验逻辑请看上面的代码,很简单。前面已经说了,需要将该过滤器加入到UsernamePasswordAuthenticationFilter之前,具体的做法就是使用addFilterBefore方法,具体的代码如下:

代码语言:javascript复制
package com.lemon.security.browser;

import com.lemon.security.core.properties.SecurityProperties;
import com.lemon.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.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;

/**
 * 浏览器安全验证的配置类
 *
 * @author lemon
 * @date 2018/4/3 下午7:35
 */
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {

    private final SecurityProperties securityProperties;
    private final AuthenticationSuccessHandler lemonAuthenticationSuccessHandler;
    private final AuthenticationFailureHandler lemonAuthenticationFailureHandler;

    @Autowired
    public BrowserSecurityConfig(SecurityProperties securityProperties, AuthenticationSuccessHandler lemonAuthenticationSuccessHandler, AuthenticationFailureHandler lemonAuthenticationFailureHandler) {
        this.securityProperties = securityProperties;
        this.lemonAuthenticationSuccessHandler = lemonAuthenticationSuccessHandler;
        this.lemonAuthenticationFailureHandler = lemonAuthenticationFailureHandler;
    }

    /**
     * 配置了这个Bean以后,从前端传递过来的密码将被加密
     *
     * @return PasswordEncoder实现类对象
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

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

        ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
        validateCodeFilter.setAuthenticationFailureHandler(lemonAuthenticationFailureHandler);
        validateCodeFilter.setSecurityProperties(securityProperties);
        validateCodeFilter.afterPropertiesSet();

        http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
                .formLogin()
                .loginPage("/authentication/require")
                .loginProcessingUrl("/authentication/form")
                .successHandler(lemonAuthenticationSuccessHandler)
                .failureHandler(lemonAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage(), "/code/image").permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf().disable();
    }
}

这就完成了全部的需求和功能,这时候启动项目,访问登陆界面,就可以看到如下图片所示的情景:

对于简单的需求,生成验证码的逻辑很简单,直接使用一个Controller即可,但是这里为什么使用绕这么多的逻辑,这是因为这样设计有框架设计的思想,给予了用户更多的自定义条件,而不是一味的写死。代码很简单,思想很重要!

0 人点赞