越来越多项目要求启用2FA,不得不去了解它!
Table of contents
Open Table of contents
概念
- 2FA 即:双因素认证 <=> Two-factor authentication <=> 2FA
- 双因素认证就是指,通过认证同时需要两个因素的证据,例如银行卡就是最常见的双因素认证,我们必须同时提供“银行卡”、“密码”两种信息才能拿钱
2FA的核心是组合2种不同类型的验证因子,验证因子主要分为以下3类:
- 你知道的东西:密码、PIN码等
- 你拥有的东西:手机、电脑安全密钥等,通过获取一次性验证码(OTP)或确认推送通知来完成验证
- 你是什么:指纹、虹膜等
2FA 方案
在实际应用中,最常用的 2FA 方案是 “密码 + 你拥有的东西”,因为生物特征(第三因素)需要特殊硬件支持,普适性稍差
方案(一)密码 + 硬件令牌
例如银行的U盾、以前网络游戏(如DNF)的密保卡、登录GitHub或系统用的YubiKey。用户插入设备或输入设备上动态码后,再输入密码
方案(二)密码 + 手机短信验证码
国内最常见的方案。登录时向绑定手机发送一条包含短时验证码的短信。注意:此方案安全性相对较低,因为短信可能被拦截、SIM卡可能被克隆,但胜在便捷
方案(三)密码 + 基于时间的一次性密码(TOTP)(当前主流且推荐的方案)
这是公认较可靠的软件层面2FA方案,国际标准为RFC6238 。用户需要先在自己的手机上安装一个身份验证器App(如 Google Authenticator、Microsoft Authenticator、Authy 等)
- 第一步(绑定):服务端生成一个密钥,以二维码形式展示给用户。
- 第二步(保存):用户用App扫描二维码,密钥安全保存到手机(也就说这个密钥在手机和服务端上都存有了)。注意,密钥必须跟手机绑定。一旦用户更换手机,就必须生成全新的密钥
- 第三步(验证):登录时,App使用密钥和当前时间戳,通过特定算法(如HMAC-SHA1)生成一个30秒或60秒(默认30秒)内有效的6-8位动态验证码。用户在有效期内,把这个动态验证码提交给服务器。服务器也使用密钥和当前时间戳,生成一个动态验证码,跟用户提交的验证码比对。只要两者不一致,就拒绝登录
TOTP的算法
TOTP 的全称是”基于时间的一次性密码”(Time-based One-time Password),仔细看上面的步骤,我会有一个问题:App和服务端,如何保证30秒期间都得到同一个验证码?答案就是下面的这个公式:
TC = floor((unixtime(now) − unixtime(T0)) / TS)
TC—> 时间计数器unixtime(now)—> 当前 Unix 时间戳unixtime(T0)—> 约定的起始时间点的时间戳,默认是0,也就是1970年1月1日TS—> 哈希有效期的时间长度,默认是30秒
因此,上面的公式就变成下面的形式
TC = floor(unixtime(now) / 30)
所以,只要在 30 秒以内,TC 的值都是一样的。前提是服务端和手机端的时间必须同步。接下来,就可以算出验证码了。
TOTP = HASH(SecretKey, TC)
上面代码中,HASH就是约定的哈希函数,默认是 SHA-1
另外,TOTP 有“硬件生成器”和“软件生成器”之分,都是采用上面的算法
(左:硬件生成器,右:软件生成器)

TOTP的实现(Csharp)
(1)NuGet 引入 SimpleBase 包
(2)简单实现
using System;
using System.Security.Cryptography;
using SimpleBase;
public class SimpleTotp
{
public static string Generate(string base32Secret)
{
// 1. 解码 Base32 密钥
byte[] secret = Base32.Rfc4648.Decode(base32Secret);
// 2. 计算时间计数器 (30秒一个窗口)
long counter = DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 30;
byte[] counterBytes = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
// 3. HMAC-SHA1
byte[] hash;
using (var hmac = new HMACSHA1(secret))
{
hash = hmac.ComputeHash(counterBytes);
}
// 4. 动态截取
int offset = hash[^1] & 0x0F;
int code = ((hash[offset] & 0x7F) << 24) |
((hash[offset + 1] & 0xFF) << 16) |
((hash[offset + 2] & 0xFF) << 8) |
(hash[offset + 3] & 0xFF);
code %= 1000000;
return code.ToString("D6");
}
}
参考
(完)
如果这篇文章刚好帮到了你,欢迎请我喝杯咖啡!