Spring Security-6-添加图形验证码

前面的文章中主要实现了登录认证的功能,本文来在登录的时候加个图形验证码,然后做成一个可配置的图形验证码,先来看一下图形验证码该如何实现

图形验证码

  • 首先我们要生成一个图片验证码
  • 这个验证码需要放到session中或者缓存中,用来跟用户输入的做验证
  • 然后还要把这个验证加到我们的认证流程中去
  • 然后将生成图片的接口接到前端的页面上去

这就是一个简单的图形验证码的步骤,下面先来看一下如何实现

图形验证码Model

首先我们需要一个实体类来放这个图形验证码的信息, 这些就都放在 core 项目中,因为web或者app都可能用的到, 新建类 ImageCode ,代码如下:

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
@Data
public class ImageCode implements Serializable {

/**
* 图片对象
* transient 序列化的时候这个字段不会被序列化
*/
private transient BufferedImage image;

/**
* 验证码
*/
private String code;

/**
* 过期时间
*/
private LocalDateTime expireTime;

/**
* 验证码是否过期
* @return
*/
public boolean expired(){
return LocalDateTime.now().isAfter(expireTime);
}

/**
* 构造
* @param imageCode 图片对象
* @param code 验证码
* @param expireIn 过期秒数
*/
public ImageCode (ImageCode imageCode, String code, int expireIn){
this.image = image;
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
}

生成验证码

前端需要调用我们的接口,拿到验证码,所以这里需要写一个接口来获取验证码,如下:

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
@RestController
public class ValidateCodeController {

private static final int EXPIRE = 60;

@Autowired
private RedisTemplate redisTemplate;

@GetMapping("code/image")
public void getImageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 根据随机数生成数字
ImageCode imageCode = createImageCode();
// 将随机数存到缓存中
String redisKey = Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId();
redisTemplate.opsForValue().set(redisKey, imageCode);

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

private ImageCode createImageCode() {
ValidateCode code = new ValidateCode();
return new ImageCode(code.getBuffImg(), code.getCode(), EXPIRE);
}

}

这里是把验证码放到了redis中, 关于redis的用法这里不多做介绍,当然也可以放到session里面去 , 生成图片验证码的话,网上有很多种方式 ,随便复制一直过来就好,最后拼成 ImageCode 对象就ok了

然后前端的页面也需要改一下,加入如下代码 如下:

1
2
3
<input class="text" type="text" name="imageCode" placeholder="验证码" required="">
<br>
<img src="/code/image">

一个验证码的输入框和图片标签, 图片的src就是我们上面接口的路径

最后在配置里面把这个接口加到不需要验证权限里面去

1
2
3
4
5
.antMatchers(
"/authentication/require",
"/code/image",
securityProperties.getBrowser().getLoginPage())
.permitAll()

现在就可以启动项目看下效果了,如下:
image

验证

验证码生成完了之后,就还差验证了,我们需要一个过滤器,来放在 UsernamePasswordAuthenticationFilter 这个过滤器前面, 就是先验证验证码,然后再验证 用户名密码

这里我们要写一个过滤器, 新建一个类 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
@Data
@Slf4j
public class ValidateCodeFilter extends OncePerRequestFilter {

private AuthenticationFailureHandler authenticationFailureHandler;

private RedisTemplate redisTemplate;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (StringUtils.equals("/authentication/from", request.getRequestURI())
&&
StringUtils.equalsIgnoreCase(request.getMethod(), "post")) {

try {
// 这里做验证
validate(request);
} catch (ValidateCodeException exception) {
// 验证失败调用验证失败的处理
authenticationFailureHandler.onAuthenticationFailure(request, response, exception);
// 直接返回 不走下一个过滤器了
return;
}

}
doFilter(request, response, filterChain);
}

private void validate(HttpServletRequest request) throws ServletRequestBindingException {
// 缓存中拿出验证码来
String redisKey = Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId();
log.info("从缓存里取验证码,redisKey:{}", redisKey);
ImageCode code = (ImageCode) redisTemplate.opsForValue().get(redisKey);

// 参数中拿出验证码来,作比较
String codeInRequest = ServletRequestUtils.getStringParameter(request, "imageCode");
if (StringUtils.isBlank(codeInRequest)) {
throw new ValidateCodeException("验证码的值不能为空");
}

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

if (code.expired()) {
redisTemplate.delete(Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId());
throw new ValidateCodeException("验证码已过期");
}

if (!StringUtils.equalsIgnoreCase(code.getCode(), codeInRequest)) {
throw new ValidateCodeException("验证码错误");
}

// 最后通过的话也从缓存清除掉
redisTemplate.delete(Constants.IMAGE_CODE_KEY_PREFIX + request.getSession().getId());
}
}

看一下代码,继承 OncePerRequestFilter 表示这个过滤器只会被执行一次, 首先是判断,只处理 /authentication/from 这个请求, 然后必须是post请求, 然后是 下面是做验证, catch里面要捕获 ValidateCodeException ,这个异常是自定义的异常,然后如果捕获到异常了,那就掉失败处理器把这个异常传给失败处理器,然后直接return掉,就不走下面的逻辑了

然后是 ValidateCodeException 的代码,如下:

1
2
3
4
5
6
7
public class ValidateCodeException extends AuthenticationException {

public ValidateCodeException(String msg) {
super(msg);
}

}

这里就继承 ValidateCodeException 然后写个构造就ok了

错误信息处理

在我们自定义的失败处理器上,修改一下, 只返回错误信息,那些堆栈信息什么的,就不返回了, MyAuthenticationFailureHandler 中修改,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

log.info("认证失败...");

if (LoginResponseType.JSON.equals(securityProperties.getBrowser().getLoginResponseType())) {
// 只返回错误消息
Map<String, Object> map = new HashMap<>(1);
map.put("message", exception.getMessage());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(map));
} else {
super.onAuthenticationFailure(request, response, exception);
}

}

配置过滤器使其生效

最后一步,就是把这个过滤器加到 UsernamePasswordAuthenticationFilter 的前面

修改 BrowserSecurityConfig ,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Autowired
private RedisTemplate redisTemplate;

@Override
protected void configure(HttpSecurity http) throws Exception {
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
validateCodeFilter.setRedisTemplate(redisTemplate);

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

先是实例化了我们刚创建的 ValidateCodeFilter, 然后设置了失败处理器和 redisTemplete,最后在下面加到 UsernamePasswordAuthenticationFilter 前面就ok了

测试

上面的配置完成之后,启动项目做一下测试

首先看一下输入一个错误的验证码,效果如下:
image

再看一下输入一个正确的,如下:
image

总结

本文主要就是加入了一个自定义的验证, 以及如何在 security 的过滤器链上加一个过滤器, 要继承 OncePerRequestFilter 表示这个过滤器只会被执行一次, 然后再配置类中通过 .addFilterBefore(),方法来指定过滤器的位置

本文代码已上传github,传送门