使用 Redis 实现验证码业务

HYF Lv3

验证码是当前网络应用中常见的一种安全认证机制,用于验证用户身份和防止恶意行为。然而,随着用户量的增加和安全需求的提高,验证码业务的设计和实现变得愈发复杂和关键。在这篇博客中,我们将探讨如何利用 Redis 和 Java来实现验证码业务,以满足现代网络应用对安全和效率的要求。

需求分析

验证码的生成

验证码的生成应当符合以下最低要求:

系统需要能够生成包含数字、字母或其他字符的验证码

需要限制验证码的有效期以及使用次数,以确保安全性

验证码的存储方式

验证码是一种用于验证用户身份的重要安全机制。在设计验证码业务时,选择合适的存储方式至关重要,它直接影响着系统的安全性、性能和用户体验。除了常见的在服务端使用 Redis 存储验证码之外,还有一种备受争议的存储方式,即在客户端使用 Session 存储验证码。

  • 在服务端使用 Redis 存储验证码是一种常见且可靠的方式,他有着以下的优点:
    • 数据安全性: 验证码存储在服务端,不易被窃取或篡改,相对较安全。

    • 扩展性: Redis 是一个高性能的内存数据库,能够轻松应对高并发的验证码生成和验证需求。

    • 灵活性: 可以通过配置 Redis 的过期时间来控制验证码的有效期,提高系统的灵活性和安全性。

  • 另一种存储验证码的方式是将验证码存储在客户端的 Session 中。这种方式虽然较少见,但在特定场景下也有一定的适用性。以下是一些优缺点:
    • 减轻服务器压力: 将验证码存储在客户端的 Session 中可以减轻服务器的负载,特别是在大规模并发场景下。
    • 用户体验: 由于验证码存储在客户端,用户在填写验证码时无需与服务器进行频繁的交互,提升了用户体验。
    • 安全性考虑: 但需要注意的是,客户端存储的验证码可能会受到 XSS(跨站脚本攻击)等安全威胁的影响,需要采取额外的安全措施保护用户数据的安全。

本文将使用 Redis 来实现验证码业务需求,故此不在描述 Session 相关实现。

验证码的验证

用户提交验证码进行登录/注册时,系统需要验证其有效性。

验证过程中需要从 Redis 中检索相应的验证码,并与用户提交的进行比较。

一旦验证码被验证过或过期,应立即从 Redis 中删除。

防止滥用

需要实现一些安全保护机制来防止验证码、登录接口被滥用,如限制验证码验证次数、限制验证码请求频率等。

需求设计思路

  • 验证码存储在服务端的 Redis 中:
    • 在用户登录时,系统会生成一个随机验证码,并将验证码存储在 Redis 中,以确保安全性。
    • 无论用户登录是否成功,系统都会在 Redis 中清除相应的验证码,确保验证码的一次性使用。
  • 前端直接引用验证码图片或返回字节信息:
    • 前端页面可以直接引用验证码图片的相对路径地址,或者通过接口返回验证码图片的字节信息,以便在页面上显示验证码。
  • 验证码获取逻辑:
    • 当用户输入账号后,前端页面会监听失去焦点事件,并根据具体业务逻辑决定是否显示验证码框。
    • 前端通过调用 API 接口 getCode 来获取验证码,同时携带用户的登录名。
    • 后端接口处理逻辑为:根据用户登录名获取用户 ID,再根据用户 ID 获取验证码,并在 Redis 中存储该用户 ID 下获取验证码的次数。
    • 验证码的有效期为 30 分钟,在此期间内用户可以获取验证码;若用户尝试获取验证码次数超过 10 次,则系统会禁止继续获取,并可能暂时禁止登录。
  • Redis 中的存储规则:
    • 验证码在 Redis 中的键为用户 ID 加上 “_code”,用于存储用户的验证码。
    • 登录次数在 Redis 中的键为用户 ID 加上 “_count”,用于存储用户获取验证码的次数。
    • 若用户成功登录,则系统会删除相应用户的获取验证码次数计数器。

需求实现

Redis 配置

我们首先需要简单的配置一下我们的 Redis(引入 maven 依赖不在此处复述)

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

/**
* @author -侑枫
* @date 2023/2/20 15:57:17
*/
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setConnectionFactory(factory);
//key序列化方式
template.setKeySerializer(redisSerializer);
//value序列化
template.setValueSerializer(redisSerializer);
//value hashmap序列化
template.setHashValueSerializer(jackson2JsonRedisSerializer);
return template;
}

@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
RedisSerializer<String> redisSerializer = new StringRedisSerializer();
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
//解决查询缓存转换异常的问题
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 配置序列化(解决乱码的问题),过期时间600秒
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(600))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}

生成验证码的工具类

网上有大量关于验证码生成的代码和插件可供使用,在本文中,我们提供了一段当年作者在毕业设计作品所用到的代码:

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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
/**
* @author -侑枫
* @date 2023/2/16 0:56:00
* 生成验证码工具类
*/

public class CheckCodeUtils {
private static final String VERIFY_CODES = "123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
private static final Random RANDOM = new Random();
private static final String[] patch = {"00000", "0000", "000", "00", "0", ""};

public static Map<String, byte[]> generateCode() throws Exception {
// 线上环境的存储地址
// String path = "../tmp/tmp.jpg";
// 本地使用的地址
String path = "src/main/resources/static/checkCode.jpg";
OutputStream fos = Files.newOutputStream(Paths.get(path));
Map<String, byte[]> map = new HashMap<>(8);
String checkCode = outputVerifyImage(100, 40, fos, 4);
byte[] byte64 = getByte64(path);
map.put(checkCode, byte64);
return map;
}

public static byte[] getByte64(String path) {
File file = new File(path);
FileInputStream fileInputStream = null;
byte[] imgData = null;
try {
imgData = new byte[(int) file.length()];
//read file into bytes[]
fileInputStream = new FileInputStream(file);
fileInputStream.read(imgData);

} catch (IOException e) {
e.printStackTrace();
} finally {
if (fileInputStream != null) {
try {
fileInputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return imgData;
}

public static String generateTel(String tel) {
//随机密钥
int encryption = 44311688;
//获取tel的哈希值并异或加密
long result = tel.hashCode() ^ encryption;
//与当前时间异或加密
result = result ^ System.currentTimeMillis();
//取正数且不大于6位数
String code = result < 0 ? (-result % 1000000) + "" : result % 1000000 + "";
return code + patch[code.length() - 1];
}

/**
* 输出随机验证码图片流,并返回验证码值(一般传入输出流,响应response页面端,Web项目用的较多)
*
* @param width 图片宽度
* @param height 图片高度
* @param os 输出流
* @param verifySize 数据长度
* @return 验证码数据
*/
public static String outputVerifyImage(int width, int height, OutputStream os, int verifySize) throws IOException {
String verifyCode = generateVerifyCode(verifySize);
outputImage(width, height, os, verifyCode);
return verifyCode;
}

/**
* 使用系统默认字符源生成验证码
*
* @param verifySize 验证码长度
*/
public static String generateVerifyCode(int verifySize) {
return generateVerifyCode(verifySize, VERIFY_CODES);
}

/**
* 使用指定源生成验证码
*
* @param verifySize 验证码长度
* @param sources 验证码字符源
*/
public static String generateVerifyCode(int verifySize, String sources) {
// 未设定展示源的字码,赋默认值大写字母+数字
if (sources == null || sources.length() == 0) {
sources = VERIFY_CODES;
}
int codesLen = sources.length();
Random rand = new Random(System.currentTimeMillis());
StringBuilder verifyCode = new StringBuilder(verifySize);
for (int i = 0; i < verifySize; i++) {
verifyCode.append(sources.charAt(rand.nextInt(codesLen - 1)));
}
return verifyCode.toString();
}

/**
* 生成随机验证码文件,并返回验证码值 (生成图片形式,用的较少)
*/
public static String outputVerifyImage(int w, int h, File outputFile, int verifySize) throws IOException {
String verifyCode = generateVerifyCode(verifySize);
outputImage(w, h, outputFile, verifyCode);
return verifyCode;
}

/**
* 生成指定验证码图像文件
*/
public static void outputImage(int w, int h, File outputFile, String code) throws IOException {
if (outputFile == null) {
return;
}
File dir = outputFile.getParentFile();
//文件不存在
if (!dir.exists()) {
//创建
dir.mkdirs();
}
try {
outputFile.createNewFile();
FileOutputStream fos = new FileOutputStream(outputFile);
outputImage(w, h, fos, code);
fos.close();
} catch (IOException e) {
throw e;
}
}

/**
* 输出指定验证码图片流
*/
public static void outputImage(int w, int h, OutputStream os, String code) throws IOException {
int verifySize = code.length();
BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
Random rand = new Random();
Graphics2D g2 = image.createGraphics();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 创建颜色集合,使用java.awt包下的类
Color[] colors = new Color[5];
Color[] colorSpaces = new Color[]{Color.WHITE, Color.CYAN,
Color.GRAY, Color.LIGHT_GRAY, Color.MAGENTA, Color.ORANGE,
Color.PINK, Color.YELLOW};
float[] fractions = new float[colors.length];
for (int i = 0; i < colors.length; i++) {
colors[i] = colorSpaces[rand.nextInt(colorSpaces.length)];
fractions[i] = rand.nextFloat();
}
Arrays.sort(fractions);
// 设置边框色
g2.setColor(Color.GRAY);
g2.fillRect(0, 0, w, h);

Color c = getRandColor(200, 250);
// 设置背景色
g2.setColor(c);
g2.fillRect(0, 2, w, h - 4);

// 绘制干扰线
Random random = new Random();
// 设置线条的颜色
g2.setColor(getRandColor(160, 200));
for (int i = 0; i < 20; i++) {
int x = random.nextInt(w - 1);
int y = random.nextInt(h - 1);
int xl = random.nextInt(6) + 1;
int yl = random.nextInt(12) + 1;
g2.drawLine(x, y, x + xl + 40, y + yl + 20);
}

// 添加噪点
// 噪声率
float yawpRate = 0.05f;
int area = (int) (yawpRate * w * h);
for (int i = 0; i < area; i++) {
int x = random.nextInt(w);
int y = random.nextInt(h);
// 获取随机颜色
int rgb = getRandomIntColor();
image.setRGB(x, y, rgb);
}
// 添加图片扭曲
shear(g2, w, h, c);

g2.setColor(getRandColor(100, 160));
int fontSize = h - 4;
Font font = new Font("Algerian", Font.ITALIC, fontSize);
g2.setFont(font);
char[] chars = code.toCharArray();
for (int i = 0; i < verifySize; i++) {
AffineTransform affine = new AffineTransform();
affine.setToRotation(Math.PI / 4 * rand.nextDouble() * (rand.nextBoolean() ? 1 : -1), (w / verifySize) * i + fontSize / 2, h / 2);
g2.setTransform(affine);
g2.drawChars(chars, i, 1, ((w - 10) / verifySize) * i + 5, h / 2 + fontSize / 2 - 10);
}
g2.dispose();
ImageIO.write(image, "jpg", os);
}

/**
* 随机颜色
*/
private static Color getRandColor(int fc, int bc) {
if (fc > 255) {
fc = 255;
}
if (bc > 255) {
bc = 255;
}
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);
}

private static int getRandomIntColor() {
int[] rgb = getRandomRgb();
int color = 0;
for (int c : rgb) {
color = color << 8;
color = color | c;
}
return color;
}

private static int[] getRandomRgb() {
int[] rgb = new int[3];
for (int i = 0; i < 3; i++) {
rgb[i] = RANDOM.nextInt(255);
}
return rgb;
}

private static void shear(Graphics g, int w1, int h1, Color color) {
shearX(g, w1, h1, color);
shearY(g, w1, h1, color);
}

private static void shearX(Graphics g, int w1, int h1, Color color) {

int period = RANDOM.nextInt(2);

boolean borderGap = true;
int frames = 1;
int phase = RANDOM.nextInt(2);

for (int i = 0; i < h1; i++) {
double d = (double) (period >> 1)
* Math.sin((double) i / (double) period
+ (6.2831853071795862D * (double) phase)
/ (double) frames);
g.copyArea(0, i, w1, 1, (int) d, 0);
g.setColor(color);
g.drawLine((int) d, i, 0, i);
g.drawLine((int) d + w1, i, w1, i);
}

}

private static void shearY(Graphics g, int w1, int h1, Color color) {

int period = RANDOM.nextInt(40) + 10; // 50;

boolean borderGap = true;
int frames = 20;
int phase = 7;
for (int i = 0; i < w1; i++) {
double d = (double) (period >> 1)
* Math.sin((double) i / (double) period
+ (6.2831853071795862D * (double) phase)
/ (double) frames);
g.copyArea(i, 0, 1, h1, 0, (int) d);
if (borderGap) {
g.setColor(color);
g.drawLine(i, (int) d, i, 0);
g.drawLine(i, (int) d + h1, i, h1);
}

}

}
}

generateCode() 方法中,需要说明的是,根据业务需求,作者设计返回一个 Map 对象,其中 key 为验证码字符串, value 为验证码图片的字节流,即 byte64 变量的值。

这样的设计思路是为了将验证码字符串存储在 Redis 中,而将验证码图片的字节流直接返回给前端的 getCode() 方法。前端可以直接获取到该字节信息,在页面上显示验证码。

当然,还有一种更简单的方法,即由前端直接在相对或绝对路径地址获取该图片验证码。本文不再实现这种方式

验证码获取接口 getCode () 实现

Controller 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author -侑枫
* @date 2023/2/19 1:04:49
*/
@RestController
@RequestMapping("/checks")
@Api(tags = "图片验证码处理")
@RequiredArgsConstructor
public class CheckCodeController {
private final ICkCodeService service;

@PassToken
@Operation(summary = "获取图片验证码")
@GetMapping("/{loginName}")
public Results generate(@PathVariable String loginName) {
return Results.ok().data("code", service.generate(loginName));
}
}

Service 实现:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* @author -侑枫
* @date 2023/2/19 0:35:04
*/
public interface ICkCodeService {
/**
* 获取图片验证码
*
* @return 验证码字符串答案
*/
byte[] generate(String loginName);
}

ServiceImpl 实现:

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
/**
* @author -侑枫
* @date 2023/2/19 0:38:22
*/
@Service
@RequiredArgsConstructor
public class CkCodeServiceImpl implements ICkCodeService {

private final RedisTemplate<String, String> redisTemplate;
private final UserService userService;
private static final ThreadLocal<DateFormat> THREAD_LOCAL = ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

@Override
public byte[] generate(String loginName) {
/*
作者有回收站这个功能需求,所以在业务里设计的逻辑删除字段为 LogicDelTime
null 表示正常,有 date_time 表示逻辑删除,now Date() - date_time > 30 天表示真实删除
所以此处判断条件为 isNull(逻辑删除字段)
一般情况为 .eq(逻辑删除字段, 0)
*/
User user = userService.lambdaQuery()
.eq(user::getMobile, loginName)
.isNull(user::getLogicDelTime)
.one();
if (ObjectUtil.isEmpty(user)) {
return null;
}

String userId = user.getId();
String codeKey = String.format("%s_code", userId);
String countKey = String.format("%s_count", userId);
String prohibitedKey = String.format("%s_prohibited", userId);

// 获取用户已获取验证码的次数,默认为 0
int count = Optional.ofNullable(redisTemplate.opsForValue().increment(countKey))
.map(Number::intValue)
.orElse(0);

// 获取剩余过期时间,如果过期时间不存在 -1(第一次申请),则设置默认过期时间为 1800 秒
Long ttl = Optional.ofNullable(redisTemplate.getExpire(countKey, TimeUnit.SECONDS))
.filter(expire -> expire != -1)
.orElse(1800L);

// 若用户尝试获取验证码次数超过 10 次,则禁止继续获取
if (count >= 10) {
// 可以扩展需求禁止该用户登录时间,使用 redis 存储
if (!redisTemplate.hasKey(key)) {
redisTemplate.opsForValue()
.set(prohibitedKey, THREAD_LOCAL.get().format(new Date()), 30, TimeUnit.MINUTES);
}
throw new CloudException(Results.ERROR, "获取验证码次数已达上限,请稍后再试。");
}

// 生成验证码
Map.Entry<String, byte[]> entry;
try {
entry = CheckCodeUtils.generateCode().entrySet().iterator().next();
} catch (Exception e) {
throw new CloudException(Results.ERROR, Results.CHECK_IO_ERR);
}

// 存储验证码并更新用户获取验证码的次数和过期时间
redisTemplate.opsForValue().set(codeKey, entry.getKey(), 3, TimeUnit.MINUTES);
redisTemplate.expire(countKey, ttl, TimeUnit.SECONDS);

return entry.getValue();
}
}

以上代码都是毕业设计项目中使用的代码,封装性较差。现在我们来重新封装一下时间处理和与 Redis 相关的代码。

RedisUtils

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
/**
* @author -侑枫
* @date 2024/5/5 14:19:57
*/
@Component
@RequiredArgsConstructor
public class RedisUtils {

private final RedisTemplate<String, Object> redisTemplate;

/**
* 删除key
*
* @param key
*/
public void delete(String key) {
redisTemplate.delete(key);
}

/**
* 批量删除key
*
* @param keys
*/
public void delete(Collection<String> keys) {
redisTemplate.delete(keys);
}

/**
* 设置过期时间
*
* @param key
* @param timeout
* @param unit
* @return
*/
public Boolean expire(String key, long timeout, TimeUnit unit) {
return redisTemplate.expire(key, timeout, unit);
}

/**
* 返回 key 的剩余的过期时间
*
* @param key 键
* @param unit 单位
* @return
*/
public Long getExpire(String key, TimeUnit unit) {
return redisTemplate.getExpire(key, unit);
}
/**
* 获取指定 key 的值
*
* @param key
* @return
*/
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}

/**
* 设置指定 key 的值
*
* @param key
* @param value
*/
public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}

/**
* 保存 String 类型的key value值,并将 key 的过期时间设为 timeout(单位unit)
*
* @param key
* @param value
* @param timeout
* @param unit
*/
public void setEx(String key, String value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}

/**
* 增加(自增长), 负数则为自减
*
* @param key
* @return
*/
public Long incr(String key) {
return redisTemplate.opsForValue().increment(key);
}

/**
* 是否存在key
*
* @param key
* @return
*/
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}

// TODO 有其他业务需求再往下继续补充其他方法
}

DateUtils

1
2
3
4
5
6
7
8
/**
* @author -侑枫
* @date 2024/5/5 14:25:12
*/
public class DateUtil extends DateUtils {
public static final ThreadLocal<DateFormat> THREAD_LOCAL = ThreadLocal.withInitial(() ->
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
}

重新优化后的 ServiceImpl 实现:

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
/**
* @author -侑枫
* @date 2023/2/19 0:38:22
*/
@Service
@RequiredArgsConstructor
public class CkCodeServiceImpl implements ICkCodeService {
private final RedisUtils redisUtils;
private final UserService userService;
@Override
public byte[] generate(String loginName) {
/*
作者有回收站这个功能需求,所以在业务里设计的逻辑删除字段为 LogicDelTime
null 表示正常,有 date_time 表示逻辑删除,now Date() - date_time > 30 天表示真实删除
所以此处判断条件为 isNull(逻辑删除字段)
一般情况为 .eq(逻辑删除字段, 0)
*/
User user = userService.lambdaQuery()
.eq(user::getMobile, loginName)
.isNull(user::getLogicDelTime)
.one();
if (ObjectUtil.isEmpty(user)) {
return null;
}

String userId = user.getId();
String codeKey = String.format("%s_code", userId);
String countKey = String.format("%s_count", userId);
String prohibitedKey = String.format("%s_prohibited", userId);

// 获取用户已获取验证码的次数,默认为 0
int count = Optional.ofNullable(redisUtils.incr(countKey))
.map(Number::intValue)
.orElse(0);

// 获取剩余过期时间,如果过期时间不存在 -1(第一次申请),则设置默认过期时间为 1800 秒
Long ttl = Optional.ofNullable(redisUtils.getExpire(countKey, TimeUnit.SECONDS))
.filter(expire -> expire != -1)
.orElse(1800L);

// 若用户尝试获取验证码次数超过 10 次,则禁止继续获取
if (count >= 10) {
// 可以扩展禁止该用户登录时间,使用 redis 存储
if (!redisUtils.hasKey(prohibitedKey)) {
redisUtils.setEx(prohibitedKey, DateUtil.THREAD_LOCAL.get().format(new Date()), 30, TimeUnit.MINUTES);
}
throw new CloudException(Results.ERROR, "获取验证码次数已达上限,请稍后再试。");
}

// 生成验证码
Map.Entry<String, byte[]> entry;
try {
entry = CheckCodeUtils.generateCode().entrySet().iterator().next();
} catch (Exception e) {
throw new CloudException(Results.ERROR, Results.CHECK_IO_ERR);
}

// 存储验证码并更新用户获取验证码的次数和过期时间
redisUtils.setEx(codeKey, entry.getKey(), 3, TimeUnit.MINUTES);
redisUtils.expire(countKey, ttl, TimeUnit.SECONDS);

return entry.getValue();
}
}

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public Results login(User user) throws Exception {
String mobile = user.getMobile();
String password = user.getPassword();

if (StringUtils.isEmpty(mobile) || StringUtils.isEmpty(password)) {
throw new CloudException(Results.ERROR, Results.EMPTY_MOBILE_OR_PASSWORD);
}

User existUser = userService.lambdaQuery()
.eq(User::getMobile, mobile)
.isNull(User::getLogicDelTime)
.one();
if (ObjectUtil.isEmpty(exist)) {
throw new CloudException(Results.ERROR, Results.NON_REGISTER);
}

String userId = existUser.getId();
String codeKey = String.format("%s_code", userId);
String countKey = String.format("%s_count", userId);
String loginCountKey = String.format("%s_loginCount", userId);
String prohibitedKey = String.format("%s_prohibited", userId);
String loginProhibitedKey = String.format("%s_login_prohibited", userId);

String checkCode = (String) redisUtils.get(codeKey);
redisUtils.delete(codeKey);
String disableTime = (String) redisUtils.get(prohibitedKey);
String loginDisableTime = (String) redisUtils.get(loginProhibitedKey);


// 检查是否为封禁状态
if (ObjectUtil.isNotEmpty(disableTime)) {
Date disableEndTime = DateUtil.addMinutes(DateUtils.parseDate(disableTime), 30);
// Results.CHECK_CODE_DISABLE_LOGIN: 频繁获取验证码导致禁止登录,禁止登录时间截止为:
throw new CloudException(Results.ERROR,
String.format("%s: %s", Results.CHECK_CODE_DISABLE_LOGIN, disableEndTime));
}
if (ObjectUtil.isNotEmpty(loginDisableTime)) {
Date loginDisableEndTime = DateUtil.addMinutes(DateUtils.parseDate(loginDisableTime), 30);
// Results.TOO_MANY_INCORRECT_PASSWORD: 频繁获取验证码导致禁止登录,禁止登录时间截止为:
throw new CloudException(Results.ERROR,
String.format("%s: %s", Results.TOO_MANY_INCORRECT_PASSWORD, loginDisableEndTime));
}

// 检查验证码是否匹配
String code = user.getCode();
if (!code.equalsIgnoreCase(checkCode)) {
throw new CloudException(Results.ERROR, Results.CHECK_ERROR);
}

// 密码解密校验
boolean checkFlag = encryptPasswordCheck(password, existUser);
if (!checkFlag) {
int loginCount = Optional.ofNullable(redisUtils.incr(loginCountKey))
.map(Number::intValue)
.orElse(0);

long loginTtl = Optional.ofNullable(redisUtils.getExpire(loginCountKey, TimeUnit.SECONDS))
.filter(expire -> expire != -1)
.orElse(1800L);

if (loginCount >= 10) {
if (!redisUtils.hasKey(loginProhibitedKey)) {
redisUtils.setEx(loginProhibitedKey, DateUtil.THREAD_LOCAL.get().format(new Date()), loginTtl, TimeUnit.SECONDS);
}
Date disableEndTime = DateUtil.addSeconds(new Date(), (int) loginTtl);
throw new CloudException(Results.ERROR,
String.format("%s: %s", Results.TOO_MANY_INCORRECT_PASSWORD, disableEndTime));
}

throw new CloudException(Results.ERROR, Results.PASSWORD_ERR);
}

String token = JwtUtils.getJwtToken(existUser);
String refreshToken = JwtUtils.getRefreshJwtToken(existUser);

redisUtils.delete(countKey);
redisUtils.delete(loginCountKey);
redisUtils.setEx(String.format("user-%s", existUser.getId()), JSON.toJSONString(existUser), -1, TimeUnit.MINUTES);
return Results.ok().data("token", token).data("refreshToken", refreshToken).data("user", existUser);
}

在业务逻辑中,我们首先确保验证码的安全性,不管验证码是否正确,都会删除已使用的验证码。然后,如果验证码或密码匹配失败次数达到10次以上,我们会将用户标记为封禁状态,并设置封禁截止时间。此时,会抛出异常,并在异常信息中显示封禁截止时间。

如果密码验证通过,我们会生成 Token 和 Refresh Token,然后将用户信息存储到 Redis 中。同时,我们会删除登录失败计数器和验证码获取计数器,以确保下次登录过程的正常进行。

结尾

在本文中,我们深入探讨了如何利用 Redis 实现验证码功能,通过将验证码存储在 Redis 中,我们实现了一种高效、可靠且安全的验证码验证机制。通过删除已使用的验证码,我们保证了验证码的安全性,并通过记录失败次数和封禁机制,增强了系统的安全性。

最后,作者希望本文对您理解如何利用 Redis 实现验证码功能有所帮助。后续会继续更新关于滑动验证的验证码功能实现。验证码作为用户身份验证的重要环节,在保障用户安全的同时,也提高了系统的可靠性和用户体验。因此,在开发应用程序时,合理设计和使用验证码功能是至关重要的。让我们一起不断探索和实践,为用户提供更安全、更可靠的服务!

  • 标题: 使用 Redis 实现验证码业务
  • 作者: HYF
  • 创建于 : 2024-05-05 23:52:17
  • 更新于 : 2024-07-27 21:21:51
  • 链接: https://youfeng.ink/checkCode-1a3cac505f0c/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。