Spring字符验证码

1. 前言

用户登录几乎是一个线上系统必不可少且使用相对比较频繁的一个模块,为了防止恶意暴力尝试,防止洪水攻击、防止脚本自动提交等,验证码是一个较为便捷且行之有效的预防手段。

1731772755697.png

2. VerifyUtil图片绘制核心

以下的代码都可以直接复制使用,可以在其中带有注释的地方修改验证码的复杂度:

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
package com.example.codedemo;

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

/**
* 图形验证码生成
*/
public class VerifyUtil {
// 默认验证码字符集
private static final char[] chars = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'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'};
// 默认字符数量
private final Integer SIZE;
// 默认干扰线数量
private final int LINES;
// 默认宽度
private final int WIDTH;
// 默认高度
private final int HEIGHT;
// 默认字体大小
private final int FONT_SIZE;
// 默认字体倾斜
private final boolean TILT;

private final Color BACKGROUND_COLOR;

/**
* 初始化基础参数
*
* @param builder
*/
private VerifyUtil(Builder builder) {
SIZE = builder.size;
LINES = builder.lines;
WIDTH = builder.width;
HEIGHT = builder.height;
FONT_SIZE = builder.fontSize;
TILT = builder.tilt;
BACKGROUND_COLOR = builder.backgroundColor;
}

/**
* 实例化构造器对象
*
* @return
*/
public static Builder newBuilder() {
return new Builder();
}

/**
* @return 生成随机验证码及图片
* Object[0]:验证码字符串;
* Object[1]:验证码图片。
*/
public Object[] createImage() {
StringBuffer sb = new StringBuffer();
// 创建空白图片
BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
// 获取图片画笔
Graphics2D graphic = image.createGraphics();
// 设置抗锯齿
graphic.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 设置画笔颜色
graphic.setColor(BACKGROUND_COLOR);
// 绘制矩形背景
graphic.fillRect(0, 0, WIDTH, HEIGHT);
// 画随机字符
Random ran = new Random();

//graphic.setBackground(Color.WHITE);

// 计算每个字符占的宽度,这里预留一个字符的位置用于左右边距
int codeWidth = WIDTH / (SIZE + 1);
// 字符所处的y轴的坐标
int y = HEIGHT * 3 / 4;

for (int i = 0; i < SIZE; i++) {
// 设置随机颜色
graphic.setColor(getRandomColor());
// 初始化字体
Font font = new Font(null, Font.BOLD + Font.ITALIC, FONT_SIZE);

if (TILT) {
// 随机一个倾斜的角度 -45到45度之间
int theta = ran.nextInt(45);
// 随机一个倾斜方向 左或者右
theta = (ran.nextBoolean() == true) ? theta : -theta;
AffineTransform affineTransform = new AffineTransform();
affineTransform.rotate(Math.toRadians(theta), 0, 0);
font = font.deriveFont(affineTransform);
}
// 设置字体大小
graphic.setFont(font);

// 计算当前字符绘制的X轴坐标
int x = (i * codeWidth) + (codeWidth / 2);

// 取随机字符索引
int n = ran.nextInt(chars.length);
// 得到字符文本
String code = String.valueOf(chars[n]);
// 画字符
graphic.drawString(code, x, y);

// 记录字符
sb.append(code);
}
// 画干扰线
for (int i = 0; i < LINES; i++) {
// 设置随机颜色
graphic.setColor(getRandomColor());
// 随机画线
graphic.drawLine(ran.nextInt(WIDTH), ran.nextInt(HEIGHT), ran.nextInt(WIDTH), ran.nextInt(HEIGHT));
}
// 返回验证码和图片
return new Object[]{sb.toString(), image};
}

/**
* 随机取色
*/
private Color getRandomColor() {
Random ran = new Random();
Color color = new Color(ran.nextInt(256), ran.nextInt(256), ran.nextInt(256));
return color;
}

/**
* 构造器对象
*/
public static class Builder {
// 默认字符数量
private int size = 4;
// 默认干扰线数量
private int lines = 10;
// 默认宽度
private int width = 80;
// 默认高度
private int height = 35;
// 默认字体大小
private int fontSize = 25;
// 默认字体倾斜
private boolean tilt = true;
//背景颜色
private Color backgroundColor = Color.LIGHT_GRAY;

public Builder setSize(int size) {
this.size = size;
return this;
}

public Builder setLines(int lines) {
this.lines = lines;
return this;
}

public Builder setWidth(int width) {
this.width = width;
return this;
}

public Builder setHeight(int height) {
this.height = height;
return this;
}

public Builder setFontSize(int fontSize) {
this.fontSize = fontSize;
return this;
}

public Builder setTilt(boolean tilt) {
this.tilt = tilt;
return this;
}

public Builder setBackgroundColor(Color backgroundColor) {
this.backgroundColor = backgroundColor;
return this;
}

public VerifyUtil build() {
return new VerifyUtil(this);
}
}
}

3. 图片绘制

根据2中编写的图片绘制工具包,依照下列的参数列表,可以生成自己:像要的验证码位数:

1
2
// 返回的数组第一个参数是生成的验证码,第二个参数是生成的图片
Object[] objs = VerifyUtil.newBuilder().build().createImage();

下面是详细的代码解释:

1
2
3
4
5
6
7
8
9
10
11
12
// 这个根据自己的需要设置对应的参数来实现个性化
// 返回的数组第一个参数是生成的验证码,第二个参数是生成的图片
Object[] objs = VerifyUtil.newBuilder()
.setWidth(120) //设置图片的宽度
.setHeight(35) //设置图片的高度
.setSize(6) //设置字符的个数
.setLines(10) //设置干扰线的条数
.setFontSize(25) //设置字体的大小
.setTilt(true) //设置是否需要倾斜
.setBackgroundColor(Color.WHITE) //设置验证码的背景颜色
.build() //构建VerifyUtil项目
.createImage(); //生成图片

4. redis介绍

引入redis相关依赖 用于保存验证码验证重试次数验证码失效等数据

Redis 是一个开源的高性能键值存储数据库,它通常用于缓存、会话存储、实时数据处理和消息队列等场景。以下是 Redis 的主要特点和用途:

主要特点

  1. 内存存储:Redis 将数据存储在内存中,提供极快的读写速度。
  2. 键值对存储:数据以键值对的形式存储,支持多种数据结构,如字符串、哈希、列表、集合和有序集合。
  3. 持久化:虽然是内存数据库,Redis 还支持将数据持久化到磁盘,以防数据丢失。
  4. 高可用性:支持主从复制和分片,提供高可用性和负载均衡。
  5. 支持事务:Redis 提供原子性操作,可以通过事务保证一组操作的完整性。
  6. 丰富的功能:支持发布/订阅、Lua 脚本、地理位置索引等功能。

主要用途

  1. 缓存:Redis 常用于缓存数据库查询结果或计算结果,以减少数据库的负载,提高应用性能。
  2. 会话存储:可以用作 Web 应用的会话存储,快速获取用户会话信息。
  3. 实时数据分析:支持快速的实时数据处理和分析,适合需要快速响应的应用场景。
  4. 消息队列:Redis 可以用于实现消息队列,用于任务调度和异步处理。
  5. 排行榜:利用有序集合,可以方便地实现排行榜功能。
  6. 数据共享:在分布式系统中,Redis 可以作为数据共享的中心,便于不同服务之间的数据同步。

依赖引人

  1. 他是属于springframework框架自带的一个管理,可以直接进行引入
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

5. 控制层

依赖引入

由于用到了web网页服务,向网页中传递了cookie值来确保验证码的对应和有效性

添加上下面两个的依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>4.0.4</version> <!-- Updated to 4.0.4 -->
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

控制层

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
package com.example.codedemo;

import org.springframework.web.bind.annotation.*;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.OutputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/verify")
public class VerifyController {

// 使用 HashMap 存储验证码
private final Map<String, String> verifyCodeStore = new HashMap<>();
private final Map<String, Long> codeExpirationStore = new HashMap<>();
private static final long EXPIRATION_TIME = TimeUnit.MINUTES.toMillis(5); // 5分钟过期时间

@PostMapping("/getcode")
public void getCode(HttpServletResponse response, HttpServletRequest request) throws Exception {
HttpSession session = request.getSession();
String sessionId = session.getId();
System.out.println("Session ID: " + sessionId);
// 生成验证码
Object[] objs = VerifyUtil.newBuilder()
.setWidth(120)
.setHeight(35)
.setSize(2)
.setLines(10)
.setFontSize(25)
.setTilt(true)
.setBackgroundColor(Color.LIGHT_GRAY)
.build()
.createImage();

String code = (String) objs[0];
System.out.println("生成验证码"+code);
BufferedImage image = (BufferedImage) objs[1];

// 存储验证码和过期时间
verifyCodeStore.put(sessionId, code);
codeExpirationStore.put(sessionId, System.currentTimeMillis() + EXPIRATION_TIME);

// 输出验证码图片
response.setContentType("image/png");
try (OutputStream os = response.getOutputStream()) {
ImageIO.write(image, "png", os);
} catch (Exception e) {
e.printStackTrace();
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "验证码生成失败");
}
}

@GetMapping("/checkcode")
public String checkCode(String code, HttpServletRequest request) {
HttpSession session = request.getSession();
String sessionId = session.getId();
System.out.println("Session ID: " + sessionId);
// 检查验证码是否存在并未过期
Long expirationTime = codeExpirationStore.get(sessionId);
System.out.println("codeExpirationStore:"+expirationTime);
if (expirationTime == null || System.currentTimeMillis() > expirationTime) {
return "验证码失效!";
}

String serverCode = verifyCodeStore.get(sessionId);
System.out.println("verifyCodeStore:"+serverCode);
if (serverCode == null || code == null || !serverCode.equalsIgnoreCase(code)) {
return "验证码错误!";
}

// 验证通过后,删除验证码
verifyCodeStore.remove(sessionId);
codeExpirationStore.remove(sessionId);

return "验证码正确!";
}
}

6. 代码测试

获取验证码

这里使用apipost对端口发送post请求

1
http://localhost:8080/verify/getcode

image-20241216105308226

image-20241216105320204

可以看到控制台显示了申请对象的session值和对应的验证码

提交验证码

提交的讲究可就多了,尤其是在跨域时,回导致提交的时候没有办法将来自后端的cookie从新返回给后端,由于产生了跨域问题,此时会生成一个新的cookie上传,但是在后端服务器中,没有生成这个cookie中的session对应的验证码,就会报出验证码无效或者超时的错误,所以这里直接将html放在后端中,这样就可以避免跨域问题使得他们同时运行在一个端口号上。

1
http://localhost:8080/verify/checkcode?code=7F

image-20241216105751308

7. 前端

为了达到更加贴合现实编码的要求,这里编写了一个html页面来获取和提交验证码;

将项目以spring格式运行后,使用url地址访问到其中的页面。

1
http://localhost:8080/code.html
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="initial-scale=1.0, width=device-width, user-scalable=no"/>
<title>验证码验证</title>
</head>
<body>
<p>方式一</p>
<img alt="验证码" id="code1" onclick="check1()" />
<br />
<input type="text" id="codeInput" placeholder="请输入验证码" />
<button onclick="submitCode()">提交</button>
<p id="responseMessage"></p>

<script>
document.addEventListener("DOMContentLoaded", function() {
getCode1();

// 刷新验证码
window.check1 = function() {
getCode1();
};

// 获取验证码
function getCode1() {
var url = "/verify/getcode";
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.responseType = "blob";

xhr.onload = function () {
if (this.status === 200) {
var res = this.response;
var imgElement = document.getElementById("code1");
imgElement.src = URL.createObjectURL(res);

// 打印会话 ID
var sessionId = xhr.getResponseHeader("Session-ID");
if (sessionId) {
console.log('Session ID: ', sessionId);
} else {
console.log('未在响应头中找到 Session ID。');
}
} else {
console.error('获取验证码时出错: ', this.statusText);
}
};

xhr.onerror = function() {
console.error('获取验证码时发生网络错误。');
};

xhr.send();
}

// 提交验证码
window.submitCode = function() {
var code = document.getElementById("codeInput").value;
var url = "/verify/checkcode?code=" + encodeURIComponent(code);
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.onload = function () {
if (this.status === 200) {
document.getElementById("responseMessage").innerText = this.responseText;
} else {
console.error('检查验证码时出错: ', this.statusText);
}
};
xhr.onerror = function() {
console.error('检查验证码时发生网络错误。');
};
xhr.send();
};
});
</script>
</body>
</html>

image-20241216105815174

这样就可以大概率解决跨页问题。

8. 项目结构

image-20241216105843987

其中webapp可以不创建,这里没有使用到Tomcat。

完整的pom依赖如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>Codedemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>Codedemo</name>
<description>Codedemo</description>
<packaging>war</packaging>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>4.0.4</version> <!-- Updated to 4.0.4 -->
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>