Featured image of post Nodejs中使用2FA验证(two-factor-authorization)

Nodejs中使用2FA验证(two-factor-authorization)

Nodejs中使用2FA验证(two-factor-authorization)

基于时间的一次性密码(totp),Time-Based One-time Password.

2次因数验证,可以使用google 验证器或其他2次验证器。

GitHub - speakeasyjs/speakeasy: NOT MAINTAINED Two-factor authentication for Node.js. One-time passcode generator (HOTP/TOTP) with support for Google Authenticator.

install

1
npm install --save speakeasy

要实现2因数认证,需要实现3步

  1. 生成一个秘钥(secret_key)
  2. 生成二维码给用户扫描
  3. 印证用户的code
  4. 保存秘钥到用户的数据库中

生成一个秘钥

1
2
3
var secret = speakeasy.generateSecret();
// Returns an object with secret.ascii, secret.hex, and secret.base32.
// Also returns secret.otpauth_url, which we'll use later.

secret对象

1
2
3
4
5
6
{
  ascii: 'ZV)B&ga*MCZhwF:@',
  hex: '5a5629422667612a4d435a6877463a40',
  base32: 'LJLCSQRGM5QSUTKDLJUHORR2IA',
  otpauth_url: 'otpauth://totp/SecretKey?secret=LJLCSQRGM5QSUTKDLJUHORR2IA'
}

默认生成32位秘钥,secret是一个对象,包含了属性otpauth_url,ascii,hex,base32,其中ascii,hex,base32是秘钥的3个编码。

生成secret后,用户可以在google 验证器中输入这个编码(必须是base32格式),就可以生成code了。

实际应用中,第一次产生秘钥,用户要扫描,生成code返回服务器验证客户端已经保存了秘钥,这样服务端就可以确认这个用户已经保存了这个秘钥,再进行数据库的保存。(第一次如果不验证,直接保存到用户数据库的话,会出现用户可能没有扫描这个秘钥。但是已经开起了验证,造成无法登录。)

显示秘钥二维码图片

Alt text

第一步生成 secret.otpauth_url,用qrcode模块生成二位码,谷歌验证器可以直接扫描otpauth://totp/SecretKey?secret=LJLCSQRGM5QSUTKDLJUHORR2IA 协议的二维码图片

二维码格式:

1
otpauth://TYPE/LABEL?PARAMETERS

TYPE:可以top或totp;

LABEL  用来指定用户身份,例如用户名、邮箱或者手机号,前面还可以加上服务提供者,需要做 URI 编码。它是给人看的,不影响最终校验码的生成。

1
const secret = speakeasy.generateSecret({ length: 16, name: 'WOW_' + username });

这样可以指定:账号名为WOW_usenane 将显示在随机码上面。

PARAMETERS 用来指定参数,它的格式与 URL 的 Query 部分一样,也是由多对 key 和 value 组成,也需要做 URL 编码。可指定的参数有这些:

  • secret:必须,密钥 K,需要编码为 base32 格式;
  • issuer:可选(强烈推荐),指定服务提供者。这个字段会在 Google Authenticator 客户端中单独显示,在添加了多个服务者提供的 2FA 后特别有用

生成二维码:

1
npm install --save qrcode
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var QRCode = require('qrcode');

// Get the data URL of the authenticator URL
QRCode.toDataURL(secret.otpauth_url, function(err, data_url) {
  console.log(data_url);

  // Display this data URL to the user in an <img> tag
  // Example:
  write('<img src="' + data_url + '">');
});

网页上生成二维码用户就可以用谷歌验证器扫描,生成code,服务端也会基于时间生成code,如果服务端和客户端时间相差不超过30秒,那么这两个code就会相同,通过验证。

验证code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Let's say the user says that the token they have is 132890
var userToken = '132890';

// Let's say we stored the user's temporary secret in a user object like above:
// (This is specific to your implementation)
var base32secret = user.two_factor_temp_secret;
// 这个零时的秘钥可以暂时存在用户的temp_secret字段中
// 这里可以读取用户的零时秘钥进行验证如果验证通过删除零时字段存为永久字段比如secret_key
var verified = speakeasy.totp.verify({ secret: base32secret,
                                       encoding: 'base32',
                                       token: userToken });

如果验证通过verified 值为true,否者为false。

通过验证后说明用户已经扫描过了,并且顺利产生了code并通过。此时就可以把秘钥保存到用户的数据库表中了,一个用户有一个唯一秘钥,基于base32格式。

一个demo:

 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
56
57
58
'use strict';
const speakeasy = require('speakeasy');
const { Controller } = require('egg');
const QRCode = require('qrcode');

class HomeController extends Controller {
  async index() {
    // Generate a secret key.
    const secret = speakeasy.generateSecret({ length: 16 });
		//生成长度是16位的秘钥
    console.log(secret);
    const user = {
      username: 'zhangsan',
      password: '123456',
      secret_key: secret.base32,
      status: 1,

    };
    const result2 = await this.ctx.model.User.create(user);
    const generateQR = async text => {
      try {
        return await QRCode.toDataURL(text);
      } catch (err) {
        console.error(err);
      }
    };
    const result = await generateQR(secret.otpauth_url);
    // Returns token for the secret at the current time
    // Compare this to user input
    await this.ctx.render('index', { url: result });

  }
  async auth() {
    const csrf = this.ctx.csrf;
    await this.ctx.render('auth', { csrf });
  }
  async doAuth() {
    const data = this.ctx.request.body;
    const user = await this.ctx.model.User.findOne();
			// 验证code
    const verified = speakeasy.totp.verify({
      secret: user.secret_key,
      encoding: 'base32',
      token: data.code,
	    });
	// 服务端生成code和google验证器生成的值应该一样
    const token = speakeasy.totp({
      secret: user.secret_key,
      encoding: 'base32',
    });
    console.log(token);
    console.log(data.code);
    console.log(verified);

  }
}

module.exports = HomeController;

demo

在魔兽世界account表中的totp_secret中存储的是secret的ASCII编码就可以实现魔兽的游戏中二次验证。

安全风险讨论

首先,无论是 HOTP 和 TOTP,在服务端验证时需要用到密钥,这就需要在服务端存储密钥,如果服务端代码或者数据库发生泄露,密钥可能会被人拿到。其次,生成二维码这一步也需要用到密钥,如果用户电脑存在恶意软件,也存在密钥泄露的风险。另外,手机 2FA 客户端也需要存储密钥,如果安全防护没做好,也有密钥被其他 APP 盗取的风险(所以从正规途径下载知名公司开发的 2FA 客户端,并且手机不要越狱或 ROOT 很重要)。后两个问题,使用独立的 OTP 硬件设备应该能解决。

首先,无论是 HOTP 和 TOTP,在服务端验证时需要用到密钥,这就需要在服务端存储密钥,如果服务端代码或者数据库发生泄露,密钥可能会被人拿到。其次,生成二维码这一步也需要用到密钥,如果用户电脑存在恶意软件,也存在密钥泄露的风险。另外,手机 2FA 客户端也需要存储密钥,如果安全防护没做好,也有密钥被其他 APP 盗取的风险(所以从正规途径下载知名公司开发的 2FA 客户端,并且手机不要越狱或 ROOT 很重要)。后两个问题,使用独立的 OTP 硬件设备应该能解决。

但是,无论是何种 OTP,一定会存在需要禁用 OTP,或者更换密钥的场景(例如 HOTP 中计数器达到最大值,手机丢失或 OTP 硬件设备丢失),如果这里考虑不周,一样会影响账户安全。所以,WEB 安全也是一个系统工程,各方面都需要考虑周全。