无状态的验证码校验

在现有的产品线中,有各类不同的场景都需要对图形验证码、短信验证码、语音验证码等的校验。现系统中使用的常规处理方式:生成验证码记录至session中,客户提交的时候,从session中获取相应的验证码匹配。整体的流程如下:

  • 生成验证码(图片或手机),写入至session中

  • 生成图片(或发送短信)

  • 等待接收客户端提交的验证请求,从session中读取,校验是否符合

在整个校验流程中,需要对session做写、读的操作,而本来session的设计是只有在客户登录之后,才会做session的保存,而产品中有部分流程的短信验证是不需要用户登录的,因此session的数据量级别有30%以上的增长。

既然不希望通过session来保存,那么我们就让客户端来保存,客户端调用生成验证码的时候,把验证码数据返回给客户端,客户提交的时候校验就好了。嗯,看着感觉就是这么简单?那么验证码数据怎么返回给客户端呢?加密,将验证码以加密后的形式返回给客户端。下面来看看代码:

const appKey = 'MY-APP-KEY';
/**
 * 生成hash
 * @param {string} data
 * @param {string} key 
 */
function hash(data, key) {
  return crypto
    .createHmac('sha1', key)
    .update(data).digest('base64')
    .replace(/\/|\+|=/g, function(x) {
      return ({ "/": "_", "+": "-", "=": "" })[x]
    });
}
/**
 * 获取验证码 
 * @param {Number} len 长度,默认为6
 */
function getCode(len = 6) {
  const v = _.random(0, Math.pow(10, len));
  const code = _.padStart(`${v}`, len, '0');
  return {
    code: code,
    hash: hash(code, appKey),
  };
}

/**
 * 校验是否合法 
 * @param {string} code 
 * @param {string} hashValue 
 */
function verify(code, hashValue) {
  return hash(code, appKey) === hashValue;
}

在调用getCode获取数据{"code":"424707","hash":"8lqFXYs0b1hsrlN6wqPaTVKwUz0"}后,根据code生成图片(或发送短信)成功后,将hash值返回给客户端。客户提交校验验证码时,将客户输入的code与接口返回的hash的提交,后端程序verify校验。

客户无法知道使用的算法,就算被猜到了,那也无法知道appKey,所以破解的难度就很大了。看着感觉很安全了,是吧?忽然发现,那就错了。因为这种形式,每个code都会有唯一对应的hash,那么客户只要用点时间,慢慢去扫,6位数的验证码,总能把所有的hash都一一找出来,之后就可以根据hash得到真实的code了。

那么生成hash的形式就需要调整了,我们的目标是:

  • 相同的code生成的hash可以变化(避免被列举所有的hash)

  • hash有时效性,可以避免暴力破解

使用keygrip能支持多个key的hash校验,可以在设置的时间间隔内生成不同的key,并保证前面生成的key在固定期限内有效。那么如果保证多个实例之间,使用的是同样的key?最开始的想法是利用redis来保存这些key,有个定时任务去更新,但是这样反而更复杂了。因此最终使用的方案是根据当前系统时间,生成一个时间列表。

根据系统时间,每60秒一个间隔,往前往后取时间戳,生成key列表,如当前系统时间为1526396880368,对60*1000取整为25439948,生成9个时间戳:['25439948', '25439949', '25439947', '25439950', '25439946', '25439951','25439945', '25439952', '25439944'],这样的处理后,则保证了在当前系统时间前后时间段的的校验码都有效(就算不同的机器时间同步有所误差也不影响),并优先使用系统当前时间校验,调整后的代码如下:

const _ = require('lodash');
const crypto = require('crypto');
const Keygrip = require('keygrip');

class CodeIdentifier {
  constructor(opts) {
    this.options = _.extend({
      interval: 60 * 1000,
      max: 5,
    }, opts);
    this.keys = [];
    this.freshKeys();
    this.keygrip = new Keygrip(this.keys);
  }
  /**
   * 刷新keys
   */
  freshKeys() {
    const {
      options,
      keys,
    } = this;
    const {
      interval,
      max,
    } = options;
    const now = Date.now();
    // 根据当前时间做为中间值(就算所有的机器时间有所偏差,key列表也可以保证校验符合)
    const start = Math.floor(now / interval);
    if (_.first(keys) === `${start}`) {
      return;
    }
    keys.length = 0;
    for (let i = 0; i < max; i++) {
      keys.push(`${start + i}`);
      if (i !== 0) {
        keys.push(`${start - i}`);
      }
    }
  }
  /**
   * 获取验证码
   * @param {number} len 验证码长度
   */
  getCode(len = 6) {
    const {
      keygrip,
    } = this;
    const v = _.random(0, Math.pow(10, len));
    const code = _.padStart(`${v}`, len, '0');
    this.freshKeys();
    const hash = keygrip.sign(code);
    return {
      hash,
      code,
    };
  }
  /**
   * 校验验证码是否合法
   * @param {string} code 
   * @param {string} hash 
   */
  verify(code, hash) {
    const {
      keygrip,
    } = this;
    this.freshKeys(); 
    return keygrip.verify(code, hash);
  }
}

至此,验证码保证了每个时间间隔内相同的code生成的hash不一致,而且在获取验证码的接口也有频率限制。想要历遍获取所有的hash已是不太可能,安全性上已大大提高。 如果希望生成hash的时候,增加与客户端相关的一些信息,让校验过程更安全一些,例如使用客户的唯一track cookie或者客户IP等参数,优化getCodeverify函数:

  /**
   * 获取验证码
   * @param {number} len 验证码长度
   * @param {string} id 
   */
  getCode(len = 6, id = '') {
    const {
      keygrip,
    } = this;
    const v = _.random(0, Math.pow(10, len));
    const code = _.padStart(`${v}`, len, '0');
    this.freshKeys();
    const hash = keygrip.sign(`${code}${id}`);
    return {
      hash,
      code,
    };
  }
  /**
   * 校验验证码是否合法
   * @param {string} code 
   * @param {string} hash 
   * @param {string} id
   */
  verify(code, hash, id = '') {
    const {
      keygrip,
    } = this;
    this.freshKeys(); 
    return keygrip.verify(`${code}${id}`, hash);
  }

getCodeverify都支持客户端标识的id参数,因此保证了不同客户端在同一时间间隔内,同一个code生成的hash也不一致,通过这翻调整,在安全与性能上都已达到预期,简化了验证码的校验逻辑。

Last updated