SpringSecurity5 验证码校验

回到序章

来来来,点这

正文

先实现验证码

预期效果: 请求 URL,得到验证码图片 和 验证码字符串

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import java.awt.*;
import java.awt.image.BufferedImage;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;

/**
* 验证码工具类
*
* @author 青木恭
* @version 1.0
* @date 2021-04-01
*/
public class VerifyCodeUtil {
// 定义图片的width
private static final int width = 100;
// 定义图片的height
private static final int height = 40;
// 定义图片上显示验证码的个数
private static final int codeCount = 4;
// x 轴位置
private static final int xx = 18;
// y 轴位置
private static final int codeY = 27;
// 字体大小
private static final int fontSize = 20;

private static final char[] codeSequence = {
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'
};

/**
* 生成一个map集合
* <p>
* code为生成的验证码
* codePic为生成的验证码BufferedImage对象
*/
public static Map<String, Object> generateCodeAndPic() {
// 定义图像buffer
BufferedImage buffImg = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// Graphics2D gd = buffImg.createGraphics();
// Graphics2D gd = (Graphics2D) buffImg.getGraphics();
Graphics gd = buffImg.getGraphics();
// 创建一个随机数生成器类
Random random = new Random();
// 将图像填充为白色
gd.setColor(Color.WHITE);
gd.fillRect(0, 0, width, height);
// 创建字体,字体的大小应该根据图片的高度来定。
Font font = new Font("思源黑体", Font.BOLD, fontSize);
// 设置字体。
gd.setFont(font);
// 画边框。
gd.setColor(Color.BLACK);
gd.drawRect(0, 0, width - 1, height - 1);
// 随机产生40条干扰线,使图象中的认证码不易被其它程序探测到。
gd.setColor(Color.BLACK);
for (int i = 0; i < 30; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
gd.drawLine(x, y, x + xl, y + yl);
}
// randomCode用于保存随机产生的验证码,以便用户登录后进行验证。
StringBuffer randomCode = new StringBuffer();
int red, green, blue;
// 随机产生codeCount数字的验证码。
for (int i = 0; i < codeCount; i++) {
// 得到随机产生的验证码数字。
String code = String.valueOf(codeSequence[random.nextInt(30)]);
// 产生随机的颜色分量来构造颜色值,这样输出的每位数字的颜色值都将不同。
red = random.nextInt(255);
green = random.nextInt(255);
blue = random.nextInt(255);
// 用随机产生的颜色将验证码绘制到图像中。
gd.setColor(new Color(red, green, blue));
gd.drawString(code, (i + 1) * xx, codeY);
// 将产生的四个随机数组合在一起。
randomCode.append(code);
}

Map<String, Object> map = new HashMap<>();
// 存放验证码
map.put("code", randomCode);
// 存放生成的验证码BufferedImage对象
map.put("codePic", buffImg);
return map;
}
}

调用工具类方法 VerifyCodeUtil.generateCodeAndPic(); 即可得到。

增加接口返回图片

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
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.awt.image.RenderedImage;
import java.io.IOException;
import java.util.Map;

/**
* 验证码接口
*
* @author 青木恭
* @version 1.0
* @date 2021-04-01
*/
@Controller
public class VerifyCodeController {

@GetMapping("/code")
@ResponseBody
public void doGet(HttpServletRequest req, HttpServletResponse resp) {
// 调用工具类生成的验证码和验证码图片
Map<String, Object> codeMap = VerifyCodeUtil.generateCodeAndPic();
// 将四位数字的验证码保存到 Session 中
HttpSession session = req.getSession();
session.setAttribute("code", codeMap.get("code").toString());
// 禁止图像缓存
resp.setHeader("Pragma", "no-cache");
resp.setHeader("Cache-Control", "no-cache");
resp.setDateHeader("Expires", -1);
resp.setContentType("image/jpeg");
// 将图像输出到Servlet输出流中
ServletOutputStream sos;
try {
sos = resp.getOutputStream();
ImageIO.write((RenderedImage) codeMap.get("codePic"), "jpeg", sos);
sos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

在浏览器请求 /code 出现验证码图片即成功。如果出现 302 跳转到 /login 的情况,大概率是因为没有权限,回到配置类配置放行该接口

1
2
3
http
.authorizeRequests()
.antMatchers("/code").permitAll();

前端支持

有了验证码,这会儿就应该把前端的登陆页面给搭起来,毕竟原 formLogin 只提供了 账号、密码 的输入 UI

注: 如果是前后分离,这部分可无视,直接看下一个部分

导入 thymeleaf

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

增加页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/html">
<head>
<meta charset="UTF-8">
<title>登陆页面</title>
</head>
<body>
<form action="/login" method="post">
账号: <input name="username" type="text" value="" /><br/>
密码: <input name="password" type="password" value="" /><br/>
验证码: <input name="code" type="text" value="" /><img src="/code" onclick="javascript:this.src=this.src+'?time='+Math.random()" /><br/>
<input type="submit" value="登陆" />
</form>
</body>
</html>

增加 login 接口

1
2
3
4
@GetMapping("/login")
public String login() {
return "login";
}

配置 security 和 校验验证码

检验验证码的话,是使用过滤器实现的,且优先级要先于账号密码,也就是验证码正确了再判断账号密码正确与否。

增加校验验证码的过滤器

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
import org.springframework.stereotype.Component;
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;

/**
* 验证码过滤器
*
* @author 青木恭
* @version 1.0
* @date 2021-04-01
*/
@Component
public class VerifyCodeFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws ServletException, IOException {
if ("POST".equalsIgnoreCase(req.getMethod())) {
System.out.println(req.getServletPath());
String code = req.getParameter("code");
String sessionCode = req.getSession().getAttribute("code") + "";
if (code == null || "".equals(code)) {
resp.setStatus(500);
resp.setContentType("text/html;charset=utf-8");
resp.getWriter().print("验证码不能为空");
return;
} else if (!code.equals(sessionCode)) {
resp.setStatus(500);
resp.setContentType("text/html;charset=utf-8");
resp.getWriter().print("验证码错误");
return;
}
}
chain.doFilter(req, resp);
}
}

配置 security

  • 校验账号密码前先校验验证码
  • 配置自定义的登陆页面
  • 配置失败处理
  • 禁用 csrf( 在使用自定义登陆页面的时候,因为 csrf 防护,会导致 post 请求的 /login 会报错 )
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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityWebConfiguration extends WebSecurityConfigurerAdapter {

@Autowired
private VerifyCodeFilter verifyCodeFilter;
@Autowired
private UserLoginService userLoginService;

private PasswordEncoder passwordEncoder() {
DelegatingPasswordEncoder encoder = (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
encoder.setDefaultPasswordEncoderForMatches(NoOpPasswordEncoder.getInstance());
return encoder;
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userLoginService)
.passwordEncoder(passwordEncoder())
;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 配置在校验账号密码前先校验验证码
http.addFilterBefore(verifyCodeFilter, UsernamePasswordAuthenticationFilter.class);
http
.authorizeRequests()
.antMatchers("/code", "/error").permitAll()
.antMatchers("/test/111").hasRole("test1")
.antMatchers("/test/222").hasRole("test2")
.antMatchers("/test/333").hasAuthority("ROLE_test3")
.antMatchers("/test/444").hasAuthority("ROLE_test4")
.anyRequest().authenticated()
.and()
.formLogin()
// 配置登陆页面,并放行
.loginPage("/login").permitAll()
// 配置登陆失败返回
.failureHandler((req, resp, e) -> {
resp.setStatus(500);
resp.setContentType("text/html;charset=utf-8");
resp.getWriter().print(e.getMessage());
})
.and()
// 禁用 csrf
.csrf().disable()
;
}
}

测试

请求 http://127.0.0.1:8080/login

输入账号密码验证码,点击登陆

预期效果:

  • 校验验证码
  • 校验账号密码