网上搜了一下,关于 nodejs 如何接入 google authenticator 的文章都太过笼统,并且有不少都已经过时了,所以这里把自己最近完成的接入工作总结一下,从原理介绍到所用依赖及如何实现。
如果你也有类似的需求的话,那你来对地方了。不用担心麻烦,核心逻辑其实只有 4 行代码。
废话不多说,首先来了解下相关背景:
一次性密码以及 google 身份验证器(authenticator)
关于一次性密码相信大家都不陌生,最常见的就是手机短信验证码了。用于在执行重要操作或者用户身份可疑时进行认证。由于其短时效性,所以可以提供较高的可信度。
再往前追溯,我们身边的另一个一次性密码就是办银行卡时会配发的银行令牌了,不知道你有没有用,反正我手里的这些东西都是放在抽屉里吃灰的。
而 google 身份验证器则是谷歌推出的一个手机 app,可以通过扫二维码的方式绑定令牌,绑定之后就会每隔一分钟生成一个验证码,就相当于在手机里装了一个电子令牌,而且从原理来讲,谷歌身份验证器和上面的银行令牌其实是差不多的。
一次性密码原理简述
一次性密码(One-Time Password,OTP),又叫做动态口令、动态验证码。是一种客户端和服务端通过分享种子密钥来进行认证的无需联网的技术。是对于常规的静态认证(用户名密码等)的补充。
一次性密码的实现最常见的有两种:
-
HOTP(HMAC-Based One-Time Password Algorithm):这种实现基于 HMAC 算法的,,这种算法是双方先分享一个相同的密钥,然后设置一个同步的计数器。由于二者计数器是同步的,所以每次生成的一次性令牌也是相同的。这种方法的应用并不常见。实现规范为 RFC-4226。
-
TOTP(Time-Based One-Time Password Algorithm):这种实现是基于时间的,所以就不再需要同步计数器,只需要双方的当前时间相同即可,所以这种方式更为常用。实现规范为 RFC-6238。
google 身份验证器则是使用的第二种方式。所以一个常见的误解就是,如果想要使用谷歌身份验证器,就需要调用谷歌的接口进行认证。事实上,服务端和身份认证 app 的密钥都是独立离线计算的,不需要调用任何第三方接口。
接入流程
第一步:初始化令牌
后端生成临时的种子密钥,结合用户名生成二维码发给前端,前端展示二维码,要求用户扫码并输入谷歌令牌里显示的一次性码
第二步:绑定令牌
前端把一次性码发给后端,后端通过 token 获取到用户信息,找到临时种子密钥,验证令牌是否正确。若正确,就把种子密钥持久化到存储里。绑定完成。
其他需要验证令牌的时候
例如异地登录或者修改密码的时候。具体操作和第二步一样:前端发送一次性码给后端,后端找到持久化的初始密钥,进行验证。
所需接口
- 获取令牌绑定信息:没有绑定就返回二维码 base64 编码(后端会在这一步生成临时密钥)
- 绑定令牌:接受前端返回的一次性码,并验证是否正确(后端会在这一步把临时密钥持久化)
- 解绑令牌:把持久化的密钥删除即可。
功能实现
OK,现在流程弄清楚了,接下来就是正式的编码环节了。
我们需要用到 otplib 和 qrcode 两个包。其中 otplib 是一个 js 版本的 TOTP 实现(类似的还有 speakeasy ,一些早期教程里会用到,但是这个包已经不维护了),而 qrcode 就负责把种子密钥处理成二维码。
这里也有一个误区:otplib 不是对谷歌服务器接口的封装。他只是 TOTP 规范(RFC-6238)的一个实现。而谷歌身份验证器也遵守了相同的规范,所以对于相同的种子密钥,otplib 和谷歌身份验证器必定能生成相同的验证码,从而完成验证。
OK,接下来安装依赖:
npm install otplib qrcode
npm install --save-dev @types/qrcode
下面是具体的功能代码,我就只贴核心逻辑了,其他的相关业务代码还是要看你的项目:
首先是第一个接口中的 获取令牌绑定信息:
import { authenticator } from 'otplib'
import QRCode from 'qrcode'
/**
* 初始化 OTP 令牌
*
* @param userName 唯一的用户名
* @param appName 项目名称
* @returns secret 需要临时缓存的种子密钥
* @returns qrcodeUrl 展示给用户的二维码 base64
*/
const createSeedSecret = async (userName, appName) => {
const secret = authenticator.generateSecret()
const googleKeyuri = authenticator.keyuri(userName, appName, secret)
const qrcodeUrl = await QRCode.toDataURL(googleKeyuri)
return { secret, qrcodeUrl }
}
核心代码就三行,authenticator.generateSecret
生成一个随机的种子密钥字符串,需要和用户名绑定暂存到诸如 redis 之类的地方,记得添加过期时间。
而 keyuri 则是 google 身份验证器的一个约定,用于生成一个 url,用户扫描后就能直接打开手机上的 google 身份验证器 app,注意,这里用到了用户名和应用名,扫描后会在 app 上以 “应用名(用户名)” 的形式显示出来。
keyuri 具体规范可以 看这里。而 otplib 中提供了这个规范的实现封装,也就是上面用到的 authenticator.keyuri
方法(同样是离线操作),其他的关于 google 身份验证器和 TOTP 的一些小区别可以 看这里。
第三行就比较简单了,QRCode.toDataURL
把一个字符串处理成二维码的 base64,直接传递给前端 <img src={qrcodeUrl} />
就能用。
然后是 验证一次性令牌是否正确:
import { authenticator } from 'otplib'
/**
* 判断令牌是否正确
*
* @param code 用户输入的一次性令牌
* @param secret 用户对应的种子密钥
* @returns {boolean} 一次性令牌是否正确
*/
const isCodeCorrect = (code, secret) => {
return authenticator.check(code, secret)
}
更简单了,调用 authenticator.check
,传入前端返回的用户一次性令牌和种子密钥,他就会返回验证码是否正确。绑定令牌和后续的验证都是这个 api,区别就是绑定时 secret 是从临时存储里获取的,而验证时是从用户的持久化存储里获取的。
由于 TOTP 是离线计算的,所以这整个过程都不需要接入什么第三方接口。
至于解绑令牌,也是输入一个一次性码,验证正确后就把持久化的种子密钥删除即可。
注意事项
-
在用户没有完成绑定前,生成的种子密钥要 设置过期时间,例如页面请求二维码时生成密钥,生成时设置三分钟左右的过期时间,如果用户没有绑定或者刷新页面的话,就重新生成一个。因为攻击者一旦获取了这个种子密钥就可以伪造出正确的一次性令牌,非常的危险。
-
用户绑定之后,持久化的种子密钥尽量加密存放。原因同上。
-
解绑令牌接口 非常重要!由于可以关闭用户的二步验证,这个接口会被攻击者反复研究。因此在解绑时一定要用户提供尽可能多的身份信息,比如一次性验证码、账号密码、邮箱验证码等等。
写在最后
其实整个流程看下来还是挺简单的,两个设备使用相同的种子密钥,按照相同的规则运算就可以得到相同的一次性密码。所以说这个种子密钥很重要。
如果你想看一下实际 demo 的话,可以看一下这个项目:brkgcl/node-google-authenticator,一个只包含谷歌验证器功能的 express 服务器 demo。
参考
- brkgcl/node-google-authenticator: use google authenticator (github.com)
- otplib - npm (npmjs.com)
- 【加解密】动态令牌-(OTP,HOTP,TOTP)-基本原理 - Mr.YF - 博客园 (cnblogs.com)
- speakeasyjs/speakeasy: NOT MAINTAINED Two-factor authentication for Node.js. One-time passcode generator (HOTP/TOTP) with support for Google Authenticator. (github.com)
- How do I integrate google authenticator in my app(nodeJS)? - Stack Overflow
- 谷歌验证器的原理及JS实现 - 黄聪 - 博客园 (cnblogs.com)