Skip to Content
全部文章Node服务端双因素验证(2FA)之:TOTP

双因素验证(2FA)之:TOTP

应用安全面临的挑战

互联网发展迅速的时代,市面上有各种各样的平台网站为我们提供服务,但随之而来的安全问题也越来越多,比如账号被盗、密码泄露、信息泄露等。

而且还存在用户喜欢多平台使用同一个密码的情况,通常当其中一个平台泄漏了密码,那么其他平台也会存在被攻击的风险。

前几年这种事情屡见不鲜,包括csdn这种技术平台都出现过密码明文存储被黑客盗取的情况。

2011年12月,CSDN的安全系统遭到黑客攻击,600万用户的登录名、密码及邮箱遭到泄漏。随后,CSDN密码外泄门持续发酵,天涯、世纪佳缘等网站相继被曝用户数据遭泄密

安全因素

为了解决用户密码容易泄露、盗取等问题,这几年出现了很多安全认证机制,多因验证越来越多,比如敏感操作要求输入手机验证码,要求输入令牌验证码,要求用登录过的手机扫码等。

常见的身份验证因素

用户在登录或执行某些敏感操作时,提供两种不同类型的身份验证因素来证明其身份,通常包括以下三种因素中的两种:

  • 知识因素:如密码、PIN 码等只有用户自己知道的信息。
  • 拥有因素:像手机、智能卡等用户拥有的物理设备。
  • 生物特征因素:基于用户自身的生物特征,如指纹、面部识别、虹膜扫描等。

双因素验证(2FA)

双因素认证(2FA:Two-Factor Authentication),也称为二次验证或双因子认证。 通过结合多种因素进行身份验证,2FA 可以显著提高系统的安全性,降低账号被盗用或被破解的风险。

比如你可能遇到过如下场景:

  • 新设备登录时:要求输入账号密码,然后要求输入手机验证码。
  • 支付操作时:已登录的情况下还要求输入支付密码,如果金额过大,还要求输入手机验证码。
  • 某些内部系统登录:输入账号+pin之外,还要求输入动态token。

这些都是一些常见的双因素验证场景,极大的加大了安全保障力度,即便其中一个安全因素泄露,只要另一个安全因素还在,那么攻击者就无法登录你的账号。

TOTP

今天主要是想探讨一下TOTP(Time - based One - Time Password,基于时间的一次性密码)的原理,以及如何实现TOTP,TOTP是一种基于时间的一次性密码算法,用户手上的token生成甚至不需要联网(一些无联网的动态口令设备), 一样可以和服务端进行校验,非常方便。

先说下简易的TOTP的验证流程:

服务端生成一个密钥

通常密钥是80位(base32编码之后字符数16个)以上,这个位指的是二进制位,长度越长越安全,一些大公司的密钥位数都在128位以上,这个密钥最后会采用32进制编码, 生成是针对每个用户单独随机生成。

客户端保存密钥

客户端需要保存服务端生成的密钥到本地,这个密钥是用来生成验证码的,所以一定要保存好,不能泄露。

客户端和服务端使用相同的算法生成验证码

当客户端通过算法生成验证码,提交到服务端,服务端使用相同的算法生成验证码,如果一致则验证通过,否则验证失败。

💡
Tip

需要特别说明,因为算法计算验证码时,时间步长一般是30秒、60秒,意思就是客户端和服务端的时间通过步长(30秒或60秒)计算出布值是一致的。 这需要两边的时间同步(允许小的误差),所以TOTP也叫基于时间的一次性密码。

TOTP验证码生成算法

生成一个验证码的算法并不复杂。

  1. 计算时间步值:根据步长计算出时间步值。
  2. 生成 HMAC 值:使用共享密钥和时间步值,通过 HMAC算法生成一个哈希值。
  3. 动态截断:从生成的 HMAC 哈希值中提取 4 个字节(32 位)的子字符串。
  4. 生成一次性密码:将提取的 32 位值进行取模运算,得到一个 6 位或 8 位的一次性密码(OTP)。

手搓一个TOTP验证功能

功能说明

首先服务端生成一个OptAuth链接,通过二维码返回给客户端,客户端需要通过支持TOTP的软件扫码添加验证码, 当然也可以不通过二维码返回这些数据,直接返回字符串密钥、步长(默认30秒)等信息,客户端手动填写,这些取决于客户端APP使用什么方式。

一个完整二维码包含的信息如下:

OptAuth链接

otpauth://totp/MyApp:user123?secret=ABCDEFGHIJKLMNOP&issuer=MyApp&period=30&digits=6&algorithm=SHA1

参数说明:

otpauth://totp/ 一般以 otpauth:// 开头,这是 OTP认证的统一资源标识符(URI)协议,表明该二维码用于一次性密码相关的认证。 totp 则明确了使用的是基于时间的一次性密码算法。

MyApp 服务名称:标识提供服务的主体,比如 Google、Microsoft 等应用,或者自定义的服务名称,如公司内部系统名

user123 用户账号:代表使用该 TOTP 认证的具体用户账号,常见的格式是使用 : 分隔服务名称和用户账号

secret 共享密钥,用于生成一次性密码,由服务端生成,客户端保存,不能泄露。

以下为可选项:

issuer 通过 issuer= 参数明确提供服务的组织或应用,有助于接收端更好地识别和处理

period 时间步长,使用 period= 参数指定,默认值是 30 秒,即每隔 30 秒生成一个新的一次性密码

digits 密码位数,使用 digits= 参数指定,默认值是 6 位,即生成 6 位的一次性密码

algorithm hash算法

客户端拿到这些信息就能和服务端一样生成同一个验证码了

服务端代码

我们来手搓一个TOTP验证码生成算法,我现在也多年不碰C#和java了,当然只有用node来写了。

const Koa = require('koa'); const app = new Koa(); const qr = require('qr-image'); const crypto = require('crypto'); // 计算时间步长 function getTimeStep(interval = 30) { const currentTime = Math.floor(Date.now() / 1000); return Math.floor(currentTime / interval); } // 将整数转换为 8 字节的大端字节序数组 function intToBytes(num) { const bytes = []; for (let i = 7; i >= 0; i--) { bytes[i] = num & 0xff; num = num >> 8; } return bytes; } // base32转二进制 function base32Decode(input) { const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = ''; let output = []; for (let i = 0; i < input.length; i++) { const char = input[i].toUpperCase(); if (char === '=') { break; } const index = base32Alphabet.indexOf(char); if (index === -1) { throw new Error('Invalid Base32 character'); } bits += index.toString(2).padStart(5, '0'); } for (let i = 0; i + 8 <= bits.length; i += 8) { const byte = parseInt(bits.substr(i, 8), 2); output.push(byte); } return Buffer.from(output); } // 生成 TOTP 验证码 function generateTOTP(secret, digits = 6, interval = 30) { // 计算时间步长 const timeStep = getTimeStep(interval); // 将时间步长转换为 8 字节的大端字节序数组 const timeBytes = intToBytes(timeStep); // 将密钥转换为缓冲区 const secretBuffer = base32Decode(secret); // 创建 HMAC-SHA1 哈希 const hmac = crypto.createHmac('sha1', secretBuffer); // 更新哈希内容 hmac.update(Buffer.from(timeBytes)); // 计算哈希值 const hmacResult = hmac.digest(); // 提取动态二进制代码(DBC) const offset = hmacResult[19] & 0xf; const binaryCode = (hmacResult[offset] & 0x7f) << 24 | (hmacResult[offset + 1] & 0xff) << 16 | (hmacResult[offset + 2] & 0xff) << 8 | (hmacResult[offset + 3] & 0xff); // 生成 6 位验证码 const otp = binaryCode % Math.pow(10, digits); return String(otp).padStart(digits, '0'); } app.use(async (ctx) => { // 接口 1: 返回 TOTP 链接的二维码 if (ctx.path === '/qr') { const secret = 'JBSWY3DPEHPK3PXP'; // 密钥,需使用 Base32 编码 const issuer = 'YourApp'; const account = 'user@example.com'; const otpauthUrl = `otpauth://totp/${issuer}:${account}?secret=${secret}&issuer=${issuer}`; const qrSvg = qr.imageSync(otpauthUrl, { type: 'svg' }); ctx.type = 'image/svg+xml'; ctx.body = qrSvg; } // 接口 2: 验证输入验证码检查是否正确 else if (ctx.path === '/verify') { const secret = 'JBSWY3DPEHPK3PXP'; // 密钥,需使用 Base32 编码 const { code } = ctx.query; const totp = generateTOTP(secret); if (code === totp) { ctx.body = { success: true, message: '验证码验证成功' }; } else { ctx.body = { success: false, message: '验证码验证失败' }; } } }); const port = 3000; app.listen(port, () => { console.log(`Server is running on port ${port}`); });

这段代码提供了两个接口,一个是生成一个二维码给客户端添加OPT,一个是验证客户端输入的验证码是否正确。

💡
Tip

需要注意的是密钥这里示例写死,实际需要动态随机生成,针对不同的用户生成不同的密钥,当用户添加后,最好在页面上让用户输入一次验证码来校验, 通过之后服务端再保存这个密钥和用户的关系,否则可能出现用户添加了,实际验证码(取决于用户使用的客户端)和服务端不一致,下次就无法登录了。

测试验证码

手机客户端支持TOTP的有很多,个人比较推荐使用大厂的,比如Google Authenticator、Authy、Microsoft Authenticator等,而我使用的是BitWarden, 因为我用了私有化部署服务端,密码都保存在我自己的服务器上,BitWarden用起来蛮方便的。

下载二维码

打开localhost:3000/qr

可以看到一个包含了optauth链接内容的二维码

Hello
客户端二维码扫描添加
Hello

保存后可以看到

Hello
验证一下这个验证码

调用接口:http://localhost:3000/verify?code=444039

可以看到成功提示

Hello

当验证码倒计时结束会自动刷新,这个时候再提交444039就会提示错误:

Hello

最后

TOTP验证的应用很广泛,尤其是一些安全要求高的场景,内部应用要想实现这个也很容易,客户端服务端都依赖时间来计算出同一个验证码。 比起短信验证码来说,不依赖网络、不用花money,也不用麻烦的短信备案,是一种稳定可靠、低成本的双因验证方式,三方OPT应用也很多,技术简单成熟。

最后编辑于

hi