0%

设计一种无状态的验证码

背景

通常验证码都是通过session来实现,在服务端生成一个随机字符串作为验证码,将该字符串存到session中,然后将验证码图片渲染到前端,前端提交之后通过session中存放的正确验证码进行对比从而验证输入的正确性。

上面是一个典型的验证码实现的流程,但是这种方案存在非常多的弊端,例如:

  1. 分布式应用:大家知道session是有状态的,当服务器存在多个时,需要去处理session丢失的问题。
  2. 跨域问题:现在前后端分离大行其道,cookie跨域问题会导致session id无法正确传递,需要去处理cookie跨域的问题。
  3. 开销问题:维护session需要消耗一定服务器的资源。

无状态验证码

为了解决上面的问题,我想了一个解决方案,核心思想是将真实的验证码字符串存储在前端,当然是经过加密的字符串,流程图如下:

  1. 首先前端通过接口获取一个token
  2. 服务端生成随机字符串并通过AES加密,AES KEY放在服务器保证加密解密是安全的
  3. 客户端通过token访问一个验证码图片
  4. 服务器通过AES解密拿到之前生成的随机字符串,然后将字符串渲染成图片返回

至此前端已经得到了一个token和一个验证码图片,后续的流程图如下:

  1. 前端发起登录请求,将token和用户输入的验证码一起发送到后端。
  2. 服务器通过AES解密拿到之前生成的随机字符串,再和用户输入的验证码做对比校验

这样就实现了一个无状态的验证码。

安全性

上面的验证码存在重放攻击的风险,即记录一次正确的token输入的验证码,这样就可以一直使用,以此绕过验证码校验。对此可以在token中加入过期时间属性,这样token中其实包含了加密后的正确验证码过期时间,在经过服务器时,首先通过时间检验,这样就可以大大的避免重放攻击的风险。

实现

这里后端主要是用springboot+hutool来实现,hutool用于验证码图片的渲染。

  • 后端
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
@ApiOperation(value = "获取验证码token", httpMethod = "GET")
@GetMapping("captcha")
public Result<String> getCaptcha() {
// 随机生成4位字符串
String code = RandomUtil.randomString(4);
// 封装字符串和过期时间
CaptchaDTO dto = new CaptchaDTO();
dto.setCode(code);
// 过期时间为一分钟
dto.setExp(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(1));
// CAPTCHA_AES_KEY为AES加密中用到的key,存储在服务器中
// 将dto对象转化成json字符串,再通过aes加密
// token=aes(key,JSON.toString(dto))
String token = SecureUtil.aes(Base64.getDecoder().decode(CAPTCHA_AES_KEY)).encryptBase64(JSON.toJSONString(dto));
return new Result<>(token);
}

@ApiOperation(value = "渲染验证码", httpMethod = "GET")
@GetMapping("captcha/{token}")
public void showCaptcha(@PathVariable String token, HttpServletResponse response) throws Exception {
//解码token
CaptchaDTO dto = decodeCaptcha(token);
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(100, 40, 4, 50);
//渲染验证码
lineCaptcha.createImage(dto.getCode());
response.setContentType("image/png");
lineCaptcha.write(response.getOutputStream());
}

@ApiOperation(value = "登录", httpMethod = "POST")
@PostMapping("login")
public Result<UserDTO> login(@RequestBody LoginVO loginVO) throws Exception {
CaptchaDTO dto;
try {
//解码token
dto = decodeCaptcha(loginVO.getToken());
} catch (Exception e) {
throw new BizException("验证码数据异常");
}
// 校验时间和验证码输入
if (System.currentTimeMillis() > dto.getExp()
|| StrUtil.isBlank(loginVO.getCode())
|| !dto.getCode().equalsIgnoreCase(loginVO.getCode())) {
throw new BizException("验证码校验不通过");
}
// 处理登录逻辑
UserDTO user = userService.login(loginVO);
return new Result<>(user);
}

private CaptchaDTO decodeCaptcha(String token) throws UnsupportedEncodingException {
// 解码token,注意要做一次url decode,因为前端通过url传递时需要做url encode
String jsonStr = SecureUtil.aes(Base64.getDecoder().decode(CAPTCHA_AES_KEY)).decryptStrFromBase64(URLDecoder.decode(token, "UTF-8"));
return JSON.parseObject(jsonStr, CaptchaDTO.class);
}
  • 前端
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
export default {
name: "Login",
data: () => {
return {
loginForm: { username: "", password: "", token: "", code: "" },
captchaUrl: ""
};
},
mounted() {
this.getCaptcha();
},
methods: {
//加载验证码
async getCaptcha() {
//获取验证码token
this.loginForm.token = encodeURIComponent(await getCaptcha());
//这里其实对token做了两次encodeURIComponent,因为img标签的get请求浏览器默认会做一次decode,不做两次encode会请求失败
this.captchaUrl = `${api}/manager/users/captcha/${encodeURIComponent(
this.loginForm.token
)}`;
},
async login() {
const result = await login(this.loginForm);
//登录后处理...
}
}
};

后记

虽然在 token 中加入了一分钟的过期时间,但是在这一分钟内其实够干很多事了,比如注册的业务使用这种验证码方式,一分钟内可以模拟大量的请求来进行注册,所以无状态验证码方案并不适合所有的业务场景,还是需要根据业务情况来进行实施。


我是MonkeyWie,欢迎扫码👇👇关注!不定期在公众号中分享JAVAGolang前端dockerk8s等干货知识。

如果觉得本文对您有帮助,可以请我喝一杯咖啡☕