今天分享一个网站开发时候遇到的一个问题和解决方案。
阅读本文你将会了解:
- 无状态服务器实现验证码登录的方法
- 客户端信息加密的一些技巧
- nodejs的DES加密解密方法
一、验证码登录流程设计
假如让你设计并实现一个邮箱账号的验证码登录功能,几乎每个同学都能娓娓道来:
- 第一步,用户填写邮箱,并点击“获取验证码”,浏览器发送请求,调用获取验证码接口。
- 然后,服务端根据邮箱,生成验证码,发送验证码给这个邮箱,并将验证码和邮箱和有效期放到redis/内存中。
- 用户在邮箱中查到验证码后→填写到登录界面→点击登录→前端请求登录接口,携带邮箱和验证码参数。
- 服务器收到请求后,到redis中取到这个邮箱对应的信息→校验验证码是否正确,并验证是否过期,如果验证码正确且没有过期,则正常登录。

二、没有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、不能使用内存的验证码登录场景,我们也可以考虑把验证码信息都加密存放在浏览器。
那么应该如何加密信息?整个请求流程如何呢?
三、解决方案
灵题库设计的方案如下:
- 用户填写邮箱,请求获取验证码接口,携带邮箱参数。
- 服务器根据邮箱生成验证码,并将邮箱、验证码和有效期用连接符拼接成一个字符串,然后DES加密成一个字符token。将code发给邮箱,将token返回给前端
- 前端拿到token后,等待用户输入code,然后将邮箱、code和token通过登录接口发给后端
- 后端根据token解密出字符串,解出来之后对比code,并验证有效期,通过的话,认为登录成功
四、代码
根据上面的流程,我们需要实现的最核心的两个方法,一个是根据邮箱和验证码生成加密后的token,另一个是根据token解出邮箱、code和token。除此之外,还需要实现生成随机验证码、DES加密解密的工具方法。
nodejs发送邮件的方法后面会单独介绍。
import crypto from 'crypto';class CodeManager {// 秘钥,任意8位字符串key = 's8cks92c';// 加密_encrypt(message) {const cipher = crypto.createCipheriv('des-ecb', Buffer.from(this.key), Buffer.alloc(0));cipher.setAutoPadding(true)let ciph = cipher.update(message, 'utf8', 'base64');ciph += cipher.final('base64');return ciph;}// 解密_decrypt(message) {const keyHex = Buffer.from(this.key);const cipher = crypto.createDecipheriv('des-ecb', keyHex, Buffer.alloc(0));let c = cipher.update(message, 'base64', 'utf8');c += cipher.final('utf8');return c;}// 生成随机四位数,作为验证码_random4() {const getRandom = () => Math.floor(Math.random() * 10);return [getRandom(), getRandom(), getRandom(), getRandom()].join('');}// 生成验证码和tokengenerate(email) {const time = Date.now();const code = this._random4();const token = this._encrypt(`${email}&${code}&${time}`);return {code, token};}// 校验验证码verify(email, code, token) {let text = '';try {text = this._decrypt(token);}catch(e) {e;}if (text) {const [emailFromToken, codeFromToken, timeFromToken] = text.split('&');return emailFromToken === email && codeFromToken === code// 10分钟有效期&& Date.now() - timeFromToken < 10 * 6 * 1000;}return false;}}
五、结语
这个方案有个问题:安全性不高。DES加密是可以被破解的,如果破解了的话,攻击者可以登录任何已注册的用户。
为了提高安全性,可以使用安全级别更高的AES加密,或者定期更新DES的秘钥。
