今天分享一个网站开发时候遇到的一个问题和解决方案。

阅读本文你将会了解:

  • 无状态服务器实现验证码登录的方法
  • 客户端信息加密的一些技巧
  • nodejs的DES加密解密方法

一、验证码登录流程设计

假如让你设计并实现一个邮箱账号的验证码登录功能,几乎每个同学都能娓娓道来:

  1. 第一步,用户填写邮箱,并点击“获取验证码”,浏览器发送请求,调用获取验证码接口。
  2. 然后,服务端根据邮箱,生成验证码,发送验证码给这个邮箱,并将验证码和邮箱和有效期放到redis/内存中。
  3. 用户在邮箱中查到验证码后→填写到登录界面→点击登录→前端请求登录接口,携带邮箱和验证码参数。
  4. 服务器收到请求后,到redis中取到这个邮箱对应的信息→校验验证码是否正确,并验证是否过期,如果验证码正确且没有过期,则正常登录。

没有redis,不能用内存,如何实现一个验证码登录功能? - 图1

二、没有redis、不能用内存的场景

如果没有redis、不能用内存,怎么办?

这种情况并不常见,但灵题库网站确实遇到了,没有redis是因为价格,阿里云redis最低配一年几千,超出一个免费网站的承受范围。那内存为什么不能用呢?因为灵题库的api部署在阿里云FC,FC是无状态的,不像ECS,用户每次请求都打到同一台云主机。用户每次请求灵题库api时候,可能对应FC的不同实例。这导致信息保存在内存中并不可靠。

那么问题如何解决呢?

其实有一个类似的场景,就是我们常见的cookie和session。用户登录成功后会保存一个用户信息,用户跟踪用户登录状态,通常用户信息存放在服务器端,即session,服务器会给前端设置一个cookie,值为sessionid,服务器可以根据sessionid查找到session从而知道用户的信息。

如果session信息存在内存中,在分布式系统中就会有问题,一个用户多次访问可能会请求到不同机器,登录访问的机器保存了session,其他机器没有session。这可以通过session的同步(服务器间同步session信息,保证每个服务器都有所有用户的session信息)或者session粘滞(负载均衡层对请求做hash,让一个用户一直访问同一个服务器)来解决,但又会带来新的问题。这些方案具体思路可以参考这个博客:

https://www.cnblogs.com/sharpxiajun/p/4237704.html

比较完美的方案是将session保存在缓存服务器(redis/memcache)中。

还有一种巧妙的方法,就是把用户信息都加密后保存在cookie中,服务器接收到客户端的请求,都可以通过对cookie解密来获取用户信息和登录状态。

这种办法的核心思路是把信息加密后都放在客户端。这样避免了服务端处理的复杂度和压力。

因此对于没有redis、不能使用内存的验证码登录场景,我们也可以考虑把验证码信息都加密存放在浏览器。

那么应该如何加密信息?整个请求流程如何呢?

三、解决方案

灵题库设计的方案如下:

  1. 用户填写邮箱,请求获取验证码接口,携带邮箱参数。
  2. 服务器根据邮箱生成验证码,并将邮箱、验证码和有效期用连接符拼接成一个字符串,然后DES加密成一个字符token。将code发给邮箱,将token返回给前端
  3. 前端拿到token后,等待用户输入code,然后将邮箱、code和token通过登录接口发给后端
  4. 后端根据token解密出字符串,解出来之后对比code,并验证有效期,通过的话,认为登录成功

四、代码

根据上面的流程,我们需要实现的最核心的两个方法,一个是根据邮箱和验证码生成加密后的token,另一个是根据token解出邮箱、code和token。除此之外,还需要实现生成随机验证码、DES加密解密的工具方法。

nodejs发送邮件的方法后面会单独介绍。

  1. import crypto from 'crypto';
  2. class CodeManager {
  3. // 秘钥,任意8位字符串
  4. key = 's8cks92c';
  5. // 加密
  6. _encrypt(message) {
  7. const cipher = crypto.createCipheriv('des-ecb', Buffer.from(this.key), Buffer.alloc(0));
  8. cipher.setAutoPadding(true)
  9. let ciph = cipher.update(message, 'utf8', 'base64');
  10. ciph += cipher.final('base64');
  11. return ciph;
  12. }
  13. // 解密
  14. _decrypt(message) {
  15. const keyHex = Buffer.from(this.key);
  16. const cipher = crypto.createDecipheriv('des-ecb', keyHex, Buffer.alloc(0));
  17. let c = cipher.update(message, 'base64', 'utf8');
  18. c += cipher.final('utf8');
  19. return c;
  20. }
  21. // 生成随机四位数,作为验证码
  22. _random4() {
  23. const getRandom = () => Math.floor(Math.random() * 10);
  24. return [getRandom(), getRandom(), getRandom(), getRandom()].join('');
  25. }
  26. // 生成验证码和token
  27. generate(email) {
  28. const time = Date.now();
  29. const code = this._random4();
  30. const token = this._encrypt(`${email}&${code}&${time}`);
  31. return {code, token};
  32. }
  33. // 校验验证码
  34. verify(email, code, token) {
  35. let text = '';
  36. try {
  37. text = this._decrypt(token);
  38. }
  39. catch(e) {
  40. e;
  41. }
  42. if (text) {
  43. const [emailFromToken, codeFromToken, timeFromToken] = text.split('&');
  44. return emailFromToken === email && codeFromToken === code
  45. // 10分钟有效期
  46. && Date.now() - timeFromToken < 10 * 6 * 1000;
  47. }
  48. return false;
  49. }
  50. }

五、结语

这个方案有个问题:安全性不高。DES加密是可以被破解的,如果破解了的话,攻击者可以登录任何已注册的用户。

为了提高安全性,可以使用安全级别更高的AES加密,或者定期更新DES的秘钥。