现在越来越多的应用增加了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提供的服务应该有以下内容:初始化令牌、绑定令牌、验证令牌、解绑令牌。
其交互过程如下图所示
- 用户访问2FA相关页面,请求服务器接口;
- 服务端生成临时密钥,将密钥缓存起来,并将生成的符合otpauth协议的链接发送至前端生成二维码(或者生成二维码发送到前端);
- 用令牌工具扫码,在页面输入令牌给出的OTP,传输到服务器进行验证;
- 验证通过后将密钥进行持久化存储;
- 后续需要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地址通过草料二维码等工具生成二维码
使用Google Authenticator或者一些微信小程序等工具进行扫码绑定
之后再使用虚拟令牌计算的code进行令牌绑定,code验证等功能的测试即可。
至此我们已经在原有业务上接入了2FA功能,前端内容比较简单就不啰嗦了,就是几个接口请求,最“麻烦”的一点就是将otpauth生成二维码,使用qrcode进行渲染即可。
出了将2FA融入业务,也可以单独作为一个应用提供服务,供其他业务方快速接入。
第三方2FA应用快速接入
除了进行自研,我们也可以选择采购第三方的验证服务快速接入,比如 Authing。
Authing 的文档有详细的接入中指引,就不再复读机了。