Spring Security-7-可配置图形验证码

上文中,实现了一个简单的图形验证码,但是这个是写死的, 图片的长宽,验证码的位数都是写死的,然后我们希望做成一个可配置的,就是由调用方去决定这些可变的参数
还有验证码拦截的接口,我们现在是只拦截登录请求,这个也可以做成一个可配置的,由调用方去决定,拦截哪些请求

最后就是把验证码的生成逻辑也做成可配置的,由我们的项目提供一个默认的生成逻辑,然后调用方可以去覆盖的这种

验证码参数可配置

跟之前配置过的登录页一样,首先我们的安全模块里面先给一个默认的配置, 然后调用安全模块的项目可以覆盖掉这个配置,最后,验证码的长和宽可以通过前端发的参数覆盖掉应用级的配置(不同的页面可能长宽不一样),如下图:

image

跟之前的登录页配置是一样的 ,这里我们需要一个放图形验证码的配置的配置类 ImageCodeProperties ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class ImageCodeProperties {
/**
* 图片的宽度
*/
private int width = 160;

/**
* 图片的高度
*/
private int height = 40;

/**
* 验证码字符个数
*/
private int codeCount = 4;

/**
* 过期秒数
*/
private int expireIn = 60;
}

然后把这个类再包一层,新建一个 ValidateCodeProperties ,因为后面可能还有别的验证码,所以这里再包一层,代码如下:

1
2
3
4
5
6
7
8
9
10
@Data
public class ValidateCodeProperties {

/**
* 图片验证码配置
*/
private ImageCodeProperties imageCode = new ImageCodeProperties();


}

最后放到总的配置 SecurityProperties 中去,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
@ConfigurationProperties(prefix = "core.security")
public class SecurityProperties {

/**
* 浏览器配置
*/
private BrowserProperties browser = new BrowserProperties();

/**
* 验证码配置
*/
private ValidateCodeProperties code = new ValidateCodeProperties();
}

这样在调用方,也就是demo项目中的 application.yml,就可以配置验证码的参数了, 如下:

1
2
3
4
5
core:
security:
code:
imageCode:
codeCount: 6

这里就是设置了验证码的长度是6位,我们默认的是4位

最后就是请求级的配置了,修改 ValidateCodeController ,修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@RestController
@Slf4j
public class ValidateCodeController {

@Autowired
private SecurityProperties securityProperties;

@Autowired
private RedisTemplate redisTemplate;

@GetMapping("code/image")
public void getImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 根据随机数生成数字
ImageCode imageCode = createImageCode(request);

// 将随机数存到缓存中
String redisKey = Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId();
log.info("将验证码放到缓存中,redisKey:{}", request.getSession().getId());
redisTemplate.opsForValue().set(redisKey, imageCode);

// 将生成的图片写到接口的响应中
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

private ImageCode createImageCode(HttpServletRequest request) {
// 长宽先从请求中取
int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImageCode().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImageCode().getHeight());

// 过期时间和长度不能通过请求指定
int codeCount = securityProperties.getCode().getImageCode().getCodeCount();
int expire = securityProperties.getCode().getImageCode().getExpireIn();

ValidateCode code = new ValidateCode(width, height, codeCount);
return new ImageCode(code.getBuffImg(), code.getCode(), expire);
}

}

这里主要是修改了 createImageCode() 方法,长和宽先从request中去取, 取不到的话,就从配置中取,就是我们之前写死的参数,都通过配置去拿

测试

到这儿,验证码的基本参数可配置就修改完成了,最后可以在页面中拿验证码的请求中加上参数,如下:

1
<img src="/code/image?width=200">

这里请求中指定了验证码的长度是200, 然后上面 application.yml 中指定了验证码的长度是6, 然后启动项目,看下效果,如图:
image

可以看到,不论是请求的配置还是应用级的配置都生效了

验证码拦截接口可配置

在我们之前的 ValidateCodeFilter 中,是只拦截了一个 /authentication/from 这个登录的请求,我们在其他请求中,也可能需要用到图片验证码,所以这里的拦截可以做成一个可配置的拦截,下面来看一下具体的实现

首先在 ImageCodeProperties 增加一个属性

1
2
3
4
/**
* 要拦截的请求 逗号隔开
*/
private String urls;

这样的话,在应用中的 application.yml 中,就可以配置了, 如下:

1
2
3
4
5
core:
security:
code:
imageCode:
urls: /user/**,/test/*

这里我们使用了通配符去匹配

然后修改过滤器 ValidateCodeFilter 中的判断, 部分修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Data
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {

private AuthenticationFailureHandler authenticationFailureHandler;

private RedisTemplate redisTemplate;

private SecurityProperties securityProperties;

/**
* 要拦截的URL
*/
private Set<String> urls = new HashSet<>();

/**
* 用来匹配url
*/
private AntPathMatcher pathMatcher = new AntPathMatcher();

@Override
public void afterPropertiesSet() throws ServletException {

super.afterPropertiesSet();
// 登录是必须拦截的,直接加进去
urls.add("/authentication/from");
// 如果没有配置的话,return掉
if (StringUtils.isEmpty(securityProperties.getCode().getImageCode().getUrls())){
return;
}

String[] configUrls = StringUtils.splitByWholeSeparatorPreserveAllTokens(securityProperties.getCode().getImageCode().getUrls(), ",");
for (String configUrl : configUrls) {
urls.add(configUrl);
}
}

@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(request);
} catch (ValidateCodeException exception) {
// 验证失败调用验证失败的处理
authenticationFailureHandler.onAuthenticationFailure(request, response, exception);
// 直接返回 不走下一个过滤器了
return;
}
}
doFilter(request, response, filterChain);
}

// ...省略 validate() 方法

}

这里首先是实现了一个 InitializingBean 目的是为了在其他的参数都组装完毕之后,初始化urls的值
然后重写了 afterPropertiesSet() 方法,里面把配置中的urls取出来,加到set集合中去

后面修改了doFilterInternal() 方法中的判断逻辑, 因为我们使用了通配符的这种,所以用到了 AntPathMatcher 去做验证

最后需要在配置类 BrowserSecurityConfig 中修改配置, 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 @Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
validateCodeFilter.setRedisTemplate(redisTemplate);

// 本章加入的两个配置
validateCodeFilter.setSecurityProperties(securityProperties);
validateCodeFilter.afterPropertiesSet();

// formLogin 表示表单认证
http
.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
// ... 省略其他配置
}

就是把过滤器里面需要用到的 securityProperties 传了进去,然后调用了初始化属性的方法

测试

上面的配置都ok之后,就可以启动项目看下效果了, 我们在 application.yml 中配置的是 /user/**,/test/*

首先是 /user/me 接口,如下:
image

然后是 /test/1 接口,如下:
image

可覆盖的图形验证码逻辑

我们上面生成的图形验证码,逻辑是写死的,只能按照我们写死的逻辑来,现在我们想实现的就是,我们这个生成图片验证码的逻辑作为一个默认的方式,然后调用方可以自己去重写一个逻辑来覆盖我们的这个逻辑,下面看一下如何实现:

首先需要声明一个接口 ValidateCodeGenerator ,代码如下:

1
2
3
4
5
6
7
8
9
10
public interface ValidateCodeGenerator {

/**
* 生成图形验证码
* @param request
* @return
*/
ImageCode generate(HttpServletRequest request);

}

然后需要一个实现类,把实现图形验证码的代码都放在里面,新建 ImageCodeGenerator, 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
public class ImageCodeGenerator implements ValidateCodeGenerator {

private SecurityProperties securityProperties;

@Override
public ImageCode generate(HttpServletRequest request) {
// 长宽先从请求中取
int width = ServletRequestUtils.getIntParameter(request, "width", securityProperties.getCode().getImageCode().getWidth());
int height = ServletRequestUtils.getIntParameter(request, "height", securityProperties.getCode().getImageCode().getHeight());

// 过期时间和长度不能通过请求指定
int codeCount = securityProperties.getCode().getImageCode().getCodeCount();
int expire = securityProperties.getCode().getImageCode().getExpireIn();

ValidateCode code = new ValidateCode(width, height, codeCount);
return new ImageCode(code.getBuffImg(), code.getCode(), expire);
}

}

这里就是把controller中的逻辑都复制过来了,然后controller中的代码需要修改一下,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@RestController
@Slf4j
public class ValidateCodeController {

@Autowired
private SecurityProperties securityProperties;

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private ValidateCodeGenerator imageCodeGenerator;

@GetMapping("code/image")
public void getImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 根据随机数生成数字
ImageCode imageCode = imageCodeGenerator.generate(request);

// 将随机数存到缓存中
String redisKey = Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId();
log.info("将验证码放到缓存中,redisKey:{}", request.getSession().getId());
redisTemplate.opsForValue().set(redisKey, imageCode);

// 将生成的图片写到接口的响应中
ImageIO.write(imageCode.getImage(), "JPEG", response.getOutputStream());
}

}

就是把我们刚刚创建的生成器注入进来,这里调用就ok

到这儿,我们已经把验证码的生成逻辑放到一个接口的实现里面去了, 那如何把这个接口的实现改成可配置的,这里还需要加一个配置类,新建 ValidateCodeBeanConfig ,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class ValidateCodeBeanConfig {

@Autowired
private SecurityProperties securityProperties;

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

这里就是把我们的 ImageCodeGenerator 交给 Spring 去管理, 这里的重点是 @ConditionalOnMissingBean(name = "imageCodeGenerator") 这个注解的意思是,首先先会在容器中找一个名字是 imageCodeGenerator 的bean,找到的话就用找到的,找不到的话,再用我们下面方法中实例化的那个bean

有了这个注解就可以实现应用层的重写覆盖了

到这里就可以先看一下效果了, 因为我们并没有在应用层重写生成的逻辑,所以还是用的默认的逻辑,如下:
image

应用层覆盖实现逻辑

最后我们在应用层,也就是 demo 项目中,重新写一个图片验证码的生成器,新建一个类,实现我们的 ValidateCodeGenerator 接口,代码如下:

1
2
3
4
5
6
7
8
9
10
@Slf4j
@Component("imageCodeGenerator")
public class MyImageCodeGenerator implements ValidateCodeGenerator {
@Override
public ImageCode generate(HttpServletRequest request) {
log.info("这里实现应用层的验证码生成....");
// 此处省略
return null;
}
}

具体的生成逻辑这里就不写了,主要是@Component("imageCodeGenerator")这个注解,这里的名字要和上面配置的 @ConditionalOnMissingBean(name = "imageCodeGenerator") 名字对应, 然后启动项目看下效果

控制台输出如下:
image

这里已经进入到我们的应用层中的逻辑了,因为这儿返回的是空所以会报空指针

总结

这里,我们的一个可配置的图形验证码的逻辑就实现了, 主要是以下几点

  • 实现 InitializingBean 接口,重写 afterPropertiesSet() 方法可以在其他的参数都组装完毕之后,做一些初始化的逻辑
  • 使用 @Bean 注入一个bean的时候可以 @ConditionalOnMissingBean 来做一个条件,如果条件不满足的情况下,才使用 @Bean 注入

本文代码传送门