现在越来越多的应用增加了2FA功能,通常用于网银支付、游戏道具交易、账号密码修改等敏感操作时验证用户身份。

这并不是一项新的技术,long long ago,开通网银会给一个U盾(或着其他的叫法),令牌上会一直显示6位数字,并且会每过一段时间刷新,这便是早期的OTP(我高中刚接触U盾的时候以为这里面有无线模块,会实时通信,后来才知道,这个东西是基于时间戳按照密钥进行计算的)。

现在的OTP大多是用的虚拟令牌,即通过软件来显示虚拟令牌,不再需要单独的硬件。

OTP原理

HOTP(HMAC-Based One-Time Password Algorithm):在实现上主要使用了HMAC(Hash-based Message Authentication Code)算法,通过将一个计数器和用户的密钥作为输入,生成单次密码,并在服务端保存当前计数器的值。当用户尝试登录时,服务端也会计算并验证单次密码是否匹配。由于计数器需要不断增加,因此对于可能存在的计数器不同步问题,HOTP也提供了允许服务器指定一个偏差范围的机制。
$$
\text{HOTP}(K, C) = \text{Truncate}(\text{HMAC}(K, C))
$$

  • K:用户密钥
  • C:计数器
  • Truncate:截取算法

JavaScript实现如下

const CryptoJS = require("crypto-js");

function HOTP(key, counter, digits = 6) {
  const hmac = CryptoJS.algo.HMAC.create(CryptoJS.algo.SHA1, key);
  hmac.update(CryptoJS.enc.Hex.parse(counter.toString(16).padStart(16, "0")));
  const code = hmac.finalize().toString(CryptoJS.enc.Hex);
  const offset = parseInt(code.substring(code.length - 1), 16);
  const binary = (parseInt(code.substr(offset * 2, 8), 16) & 0x7fffffff).toString(10);
  return binary.substr(binary.length - digits).padStart(digits, "0");
}

TOTP(Time-Based One-Time Password Algorithm):在实现上相对简单,通过将当前时间戳除以一个预设的时间步长,将商作为计数器,与用户密钥一起输入到哈希函数中,生成单次密码。由于这里使用了时间戳,因此客户端和服务端需要保持时钟同步。
$$
\text{TOTP}(K, T) = \text{Truncate}(\text{HMAC}(K, \lfloor{\frac{T}{X}}\rfloor))
$$

  • K:用户密钥
  • T:当前时间戳
  • X:时间步长
  • Truncate:截取算法

JavaScript 实现如下

const jsrsasign = require("jsrsasign");

function TOTP(key, timeStep = 30, digits = 6) {
  const counter = Math.floor((new Date().getTime() / 1000) / timeStep);
  const hashAlg = "sha1";
  const hmac = new jsrsasign.KJUR.crypto.Mac({alg: `HMAC-${hashAlg}`, pass: key});
  hmac.updateString(jsrsasign.b64utohex(jsrsasign.intToBytes(counter)));
  const code = hmac.doFinalHex();
  const offset = parseInt(code.substring(code.length - 1), 16);
  const binary = (parseInt(code.substr(offset * 2, 8), 16) & 0x7fffffff).toString(10);
  return binary.substr(binary.length - digits).padStart(digits, "0");
}

实现一个2FA服务

一个2FA提供的服务应该有以下内容:初始化令牌、绑定令牌、验证令牌、解绑令牌。

其交互过程如下图所示

image-20230605214048728

  1. 用户访问2FA相关页面,请求服务器接口;
  2. 服务端生成临时密钥,将密钥缓存起来,并将生成的符合otpauth协议的链接发送至前端生成二维码(或者生成二维码发送到前端);
  3. 用令牌工具扫码,在页面输入令牌给出的OTP,传输到服务器进行验证;
  4. 验证通过后将密钥进行持久化存储;
  5. 后续需要2FA的时候,将令牌工具给出的动态口令传到服务器进行验证。

生成的连接格式如下:otpauth://totp/f-live:feng?secret=123456&period=30&digits=6&algorithm=SHA1&issuer=f-live

为了安全性,密钥在持久化时尽量加密存储。

npm上有一个otplib的库,用于实现otp,我们就来基于这个库来实现2FA功能,还是基于我个人最喜欢的MidwayJS作为服务端框架进行实现。

首先将otp相关的功能单独作为一个service进行编码

@Provide()
export class OtpService {
  @Config('otpConfig')
  otpConfig;

  @Inject()
  redis: RedisService;

  /**
   * 生成OTP绑定地址
   * @param account 账号
   */
  async generateAddress(account: string) {
    const secret = authenticator.generateSecret();
    await this.redis.set('otp_' + account, secret, 'EX', 300);
    return totp.keyuri(account, this.otpConfig.application, secret);
  }

  /**
   * 验证OTP
   * @param code 一次性密码
   * @param secret 密钥
   */
  async verify(code: string, secret: string) {
    return authenticator.verify({ token: code, secret });
  }
}

非常简单的两个方法,一个生成otpauth地址,另一个用于验证code合法性。

注意:生成otpauth时要使用totp.keyuri或者hotp.keyuri,通过第三方令牌绑定之后需要使用authenticator.verify来进行验证,不能直接使用totp或者hotp的验证方法来验证。

然后就是业务相关的代码了,对本小节开头我们提出的几个功能进行实现。

@Controller('/user')
export class UserController {
  @Inject()
  ctx: Context;

  @Inject()
  userService: UserService;

    // 生成个人OTP绑定地址
  @Get('/security/2fa/generate')
  async generateOtpauth() {
    const username = this.ctx.state.user.username;
    return await this.otpService.generateAddress(username);
  }

  // 验证绑定2FA
  @Post('/security/2fa')
  async bind2FA(@Body('code') code: string) {
    const username = this.ctx.state.user.username;
    const secret = await this.redis.get('otp_' + username);
    if (code && secret) {
      const isValid = await this.otpService.verify(code, secret);
      if (isValid) {
        // 验证成功持久化密钥
        const user = await this.userService.getUserByUsernameWithPassword(
          username
        );
        user.otpSecret = secret;
        await this.userService.updateUser(user);
        return true;
      }
    }
    throw new CustomError(OtpError.CODE1, OtpError.MESSAGE1);
  }

  // 验证2FA
  @Get('/security/2fa')
  async verifyOtp(@Query('code') code: string) {
    const uid = this.ctx.state.user.uid;
    const user = await this.userService.getUserByUidWithSecret(uid);
    if (!user.otpSecret) {
      return await this.otpService.verify(code, user.otpSecret);
    }
    throw new CustomError(OtpError.CODE3, OtpError.MESSAGE3);
  }

  // 解绑2FA
  @Del('/security/2fa')
  async unBind2FA() {
    const uid = this.ctx.state.user.uid;
    const user = await this.userService.getUserByUidWithSecret(uid);
    if (user.otpSecret) {
      user.otpSecret = null;
      await this.userService.updateUser(user);
    } else {
      throw new CustomError(OtpError.CODE2, OtpError.MESSAGE2);
    }
  }
}

来验证一下我们的接口,通过postman等API测试工具来进行生成otpauth地址的请求,将生成的otpauth地址通过草料二维码等工具生成二维码

image-20230608225107444

使用Google Authenticator或者一些微信小程序等工具进行扫码绑定

image-20230608225210119

之后再使用虚拟令牌计算的code进行令牌绑定,code验证等功能的测试即可。

至此我们已经在原有业务上接入了2FA功能,前端内容比较简单就不啰嗦了,就是几个接口请求,最“麻烦”的一点就是将otpauth生成二维码,使用qrcode进行渲染即可。

出了将2FA融入业务,也可以单独作为一个应用提供服务,供其他业务方快速接入。

第三方2FA应用快速接入

除了进行自研,我们也可以选择采购第三方的验证服务快速接入,比如 Authing

Authing 的文档有详细的接入中指引,就不再复读机了。


前端小白