你在日常生活中大概率已经接触过 2FA。
比如有些软件登录时,除了输入密码,还会要求你再输入一个手机验证码。再比如 Steam 登录时,手机 App 会显示一个 6 位动态验证码。Google Authenticator 也是类似的思路:它会每隔一段时间刷新一个验证码,用来证明“正在登录的人确实拥有这个设备”。
这篇文章会从原理讲起,再用 Java 实现一个可以和 Google Authenticator 对接的 TOTP 验证流程。
什么是 2FA 2FA 是 Two-Factor Authentication 的缩写,也就是双因素认证。
简单来说,它是在密码之外再增加一层校验。密码证明“你知道什么”,手机验证码或身份验证器 App 则证明“你拥有什么”。
有了双因素认证,自然也有多因素认证,也就是 MFA,Multi-factor Authentication。认证因素越多,通常越安全,但用户体验也会变差。所以大多数互联网账号登录场景里,2FA 已经足够常见,也足够实用。
本文主要关注 2FA 中的这一类方式:身份验证器 App 生成的动态令牌 ,也就是 Google Authenticator、Microsoft Authenticator、1Password 等工具里常见的 6 位验证码。
动态验证码背后的核心问题 短信验证码和身份验证器 App 看起来都像是“一次性验证码”,但它们背后的实现方式很不一样。
短信验证码 :通常由服务端生成,再通过短信发送给用户。
令牌验证码 :服务端和用户手机各自用同一个密钥,通过相同算法算出验证码。
也就是说,Google Authenticator 这类 App 并不是每次都从服务器接收验证码。它真正解决的问题是:
服务端和用户手机如何在不实时通信的情况下,算出同一个一次性验证码?
这就要讲到两个常见算法:HOTP 和 TOTP。
HOTP:基于计数器的一次性密码 HOTP 的全称是 HMAC-Based One-Time Password ,也就是基于哈希消息认证码的一次性密码。
可以把它理解成:双方都有一本从第 1 页开始的验证码本。
HOTP 依赖两个核心值:
服务端和用户设备都保存同一个密钥,并按照同一套算法,把“密钥 + 当前计数器”计算成一个哈希值,再通过动态截断转换成一个 6 到 8 位数字。
典型流程是:
用户第一次绑定设备时,服务端和设备同步一个密钥。
用户点击硬件令牌或 App 中的生成按钮。
设备使用密钥和当前计数器生成验证码。
用户把验证码提交给服务端。
服务端用同一个密钥和自己的计数器计算验证码。
如果当前计数器不匹配,服务端会在一个向前窗口内尝试后续几个计数器值。
匹配成功后,服务端把计数器同步到匹配的位置。
HOTP 的优点是适合硬件令牌等离线设备,生成验证码时不需要联网。缺点也很明显:它依赖计数器同步。如果用户连续多按几次,服务端和设备之间的计数器就可能偏移。
另外,HOTP 的验证码通常不会因为时间过去而自动失效,而是使用后才失效。这在某些场景下方便,但也带来了一定风险。
TOTP:基于时间的一次性密码 TOTP 的全称是 Time-Based One-Time Password ,也就是基于时间的一次性密码。
它和 HOTP 最大的区别在于:TOTP 不再使用“点击次数”作为计数器,而是使用“时间”作为计数器。
TOTP 里常见的三个值是:
Current Unix Time :当前 Unix 时间戳
T0 :起始时间,通常为 0
Time Step :时间步长,通常为 30 秒
计算方式可以理解为:
1 T = floor ((Current Unix Time - T0 ) / Time Step)
这个 T 会替代 HOTP 中的计数器。也就是说,TOTP 并不是每一秒都生成一个完全不同的验证码,而是每 30 秒共用一个时间计数器。
这也是为什么 Google Authenticator 里的验证码通常会每 30 秒刷新一次。
TOTP 的优点是:
验证码会过期,攻击者即使拿到某一次验证码,也只能在很短时间内使用。
用户不需要点击按钮,也不用担心多按几次导致计数器不同步。
缺点是:
它依赖设备时间和服务端时间足够准确。
如果手机或服务端时间偏移太大,就可能导致验证码校验失败。
所以在互联网账号登录场景中,TOTP 比 HOTP 更常见。HOTP 则更适合一些按键式硬件令牌或特定离线设备。
对接 Google Authenticator 的整体思路 Google Authenticator 本质上是一个标准的 TOTP 客户端。常见参数是:
algorithm = SHA1
digits = 6
period = 30
对服务端来说,对接 Google Authenticator 不需要调用 Google 的接口。它遵循的是标准 TOTP 协议。
你需要做的事情主要是:
生成共享密钥。
拼出标准的 otpauth:// URI。
前端把 URI 渲染成二维码。
用户用 Google Authenticator 扫码。
服务端校验用户输入的 6 位验证码。
记录绑定状态和最近一次成功使用的 counter,防止重放。
下面用 Java 实现一遍。
准备依赖 后端只需要一个库:commons-codec。
它可以提供 Base32 编解码和 HMAC 相关工具。
1 2 3 4 5 <dependency > <groupId > commons-codec</groupId > <artifactId > commons-codec</artifactId > <version > 1.17.0</version > </dependency >
二维码不需要后端生成。二维码本质上只是把 otpauth://... 这段字符串渲染成图片,前端使用 qrcode、qrcode.react 等库处理会更合适。
Step 1:生成共享密钥 共享密钥本质上是一段随机字节。
Google Authenticator 需要使用 Base32 编码后的密钥。注意这里是 Base32,不是 Base64。
推荐使用 20 字节,也就是 160 bit,和 HMAC-SHA1 的输出长度对齐。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import org.apache.commons.codec.binary.Base32;import java.security.SecureRandom;public class SecretGenerator { private static final SecureRandom RANDOM = new SecureRandom (); public static String generateSecret () { byte [] bytes = new byte [20 ]; RANDOM.nextBytes(bytes); return new Base32 ().encodeToString(bytes).replace("=" , "" ); } }
这个 secret 是整套 2FA 的核心,一定要加密存储。
Step 2:生成 otpauth URI Google Authenticator、Microsoft Authenticator、1Password 等工具都能识别 otpauth:// URI。
标准格式大致是:
1 otpauth://totp/ {issuer} : {account} ?secret= {secret} &issuer= {issuer} &algorithm=SHA1&digits=6&period=30
Java 示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import java.net.URLEncoder;import java.nio.charset.StandardCharsets;public class OtpAuthUriBuilder { public static String build (String issuer, String account, String secret) { String label = URLEncoder.encode(issuer + ":" + account, StandardCharsets.UTF_8); String encodedIssuer = URLEncoder.encode(issuer, StandardCharsets.UTF_8); return String.format( "otpauth://totp/%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30" , label, secret, encodedIssuer ); } }
接口可以返回类似这样的数据:
1 2 3 4 { "otpAuthUri" : "otpauth://totp/MyApp:wuheng@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&algorithm=SHA1&digits=6&period=30" , "secret" : "JBSWY3DPEHPK3PXP" }
这里要注意:secret 只应该在绑定流程中短暂展示,用于扫码或手动输入。绑定完成后,不要再返回给前端。
日志里也不要打印 secret 或完整的 otpAuthUri,因为 otpAuthUri 本身就包含密钥。一旦泄露,等同于泄露 2FA 密钥。
前端拿到 otpAuthUri 后,可以用二维码库渲染:
1 2 3 4 5 6 7 import QRCode from 'qrcode' ;QRCode .toCanvas (document .getElementById ('qr' ), data.otpAuthUri , { width : 240 });const url = await QRCode .toDataURL (data.otpAuthUri );
用户扫码后,App 侧会保存密钥。但账号是否真正开启 2FA,还需要服务端再校验一次用户输入的验证码。
Step 3:生成 TOTP TOTP 的核心计算过程是:
1 HMAC-SHA1 (K, C_T) → 动态截断 → 取模
Java 实现如下:
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 import org.apache.commons.codec.binary.Base32;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.nio.ByteBuffer;public class TotpGenerator { private static final int TIME_STEP_SECONDS = 30 ; private static final int DIGITS = 6 ; public static String generate (String base32Secret, long unixTimeSeconds) { long counter = unixTimeSeconds / TIME_STEP_SECONDS; return generateByCounter(base32Secret, counter); } public static String generateByCounter (String base32Secret, long counter) { try { byte [] key = new Base32 ().decode(base32Secret); byte [] counterBytes = ByteBuffer.allocate(8 ).putLong(counter).array(); Mac mac = Mac.getInstance("HmacSHA1" ); mac.init(new SecretKeySpec (key, "HmacSHA1" )); byte [] hash = mac.doFinal(counterBytes); int offset = hash[hash.length - 1 ] & 0x0f ; int binary = ((hash[offset] & 0x7f ) << 24 ) | ((hash[offset + 1 ] & 0xff ) << 16 ) | ((hash[offset + 2 ] & 0xff ) << 8 ) | (hash[offset + 3 ] & 0xff ); int otp = binary % (int ) Math.pow(10 , DIGITS); return String.format("%0" + DIGITS + "d" , otp); } catch (Exception e) { throw new IllegalStateException ("生成 TOTP 失败" , e); } } }
Step 4:服务端校验 校验时不要只计算当前时间步。现实中,用户手机时间可能和服务端时间有轻微偏差,所以通常允许前后各 1 个时间步的漂移。
也就是允许:
当前 30 秒窗口
前一个 30 秒窗口
后一个 30 秒窗口
同时,为了防止同一个验证码被重复提交,校验结果最好不要只返回 boolean。我们需要知道这次到底匹配到了哪个 counter。
可以设计一个 VerifyResult:
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 public class TotpVerifier { private static final int WINDOW = 1 ; private static final int TIME_STEP_SECONDS = 30 ; public static VerifyResult verify (String base32Secret, String code, Long lastUsedCounter) { if (code == null || !code.matches("\\d{6}" )) { return VerifyResult.fail(); } long currentCounter = System.currentTimeMillis() / 1000 / TIME_STEP_SECONDS; for (int i = -WINDOW; i <= WINDOW; i++) { long counter = currentCounter + i; String expected = TotpGenerator.generateByCounter(base32Secret, counter); if (constantTimeEquals(expected, code)) { if (lastUsedCounter != null && counter <= lastUsedCounter) { return VerifyResult.replayed(counter); } return VerifyResult.success(counter); } } return VerifyResult.fail(); } private static boolean constantTimeEquals (String a, String b) { if (a.length() != b.length()) return false ; int result = 0 ; for (int i = 0 ; i < a.length(); i++) { result |= a.charAt(i) ^ b.charAt(i); } return result == 0 ; } public record VerifyResult (boolean success, Long matchedCounter, boolean replayed) { public static VerifyResult success (long counter) { return new VerifyResult (true , counter, false ); } public static VerifyResult replayed (long counter) { return new VerifyResult (false , counter, true ); } public static VerifyResult fail () { return new VerifyResult (false , null , false ); } } }
校验成功后,服务端应该把 matchedCounter 保存到数据库。下一次如果有人拿同一个时间窗口里的验证码来请求,即使验证码本身正确,也会因为 counter 已经用过而被拒绝。
这里还要注意并发问题。
真实业务中,保存 matchedCounter 时最好使用数据库条件更新或事务。比如只允许在 matchedCounter > lastUsedCounter 时更新成功,避免两个请求同时提交同一个验证码,结果都读到旧的 lastUsedCounter 并同时通过校验。
Step 5:完整使用示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class Demo { public static void main (String[] args) throws Exception { String secret = SecretGenerator.generateSecret(); System.out.println("Secret(存数据库): " + secret); String uri = OtpAuthUriBuilder.build("MyApp" , "wuheng@example.com" , secret); System.out.println("返回给前端的 URI: " + uri); String userInput = "123456" ; TotpVerifier.VerifyResult result = TotpVerifier.verify(secret, userInput, null ); System.out.println("校验结果:" + result.success()); System.out.println("匹配到的 counter:" + result.matchedCounter()); } }
真实业务中的绑定流程 真正落到业务里,要把“绑定”和“登录”两个流程分清楚。
开启 2FA 时,可以这样做:
用户在账号安全页点击开启 2FA。
服务端生成 secret,但不要直接把 2FA 状态改成已开启,而是先标记为“待确认”。
后端返回 otpAuthUri,前端渲染二维码。
用户用 Google Authenticator 扫码,然后输入 App 上显示的 6 位验证码。
服务端校验验证码。
如果校验通过,再把用户的 2FA 状态改成已开启,并保存本次匹配到的 counter。
如果用户没有完成校验,就不要启用 2FA,避免用户因为扫码失败把自己锁在账号外面。
登录时,则是另一个流程:
用户先输入账号和密码。
服务端确认密码正确后,检查这个账号是否已经开启 2FA。
如果开启了 2FA,就要求用户继续输入身份验证器 App 上的 6 位验证码。
服务端校验验证码,并检查这个 counter 是否已经使用过。
校验通过后,更新最近一次成功使用的 counter。
最后才真正完成登录。
落地时还要注意的几点
密钥存储 :secret 是绑定的核心,数据库里一定要加密存储,例如用 KMS 包一层。
防重放 :验证通过后,把匹配到的 counter 记下来,同一个 counter 不允许再次通过。
绑定确认 :不要生成密钥后直接启用 2FA,要先让用户输入一次正确验证码。
恢复码 :给用户生成一组一次性 recovery code。当手机丢了,用户可以用恢复码登录并重置 2FA。
时间同步 :服务端一定要开 NTP,否则服务端时间漂移后,可能会拒绝大量正常用户。
小结 看到这里,你会发现对接 Google Authenticator 背后并没有什么实时通信。
甚至用户换成其他支持 TOTP 的 2FA App,也一样可以使用。
它真正依赖的是一个提前共享好的密钥,以及双方都认可的时间。
只要密钥不泄露、时间足够准确、同一个时间窗口的验证码不会被重复使用,这套机制就能用很低的成本给账户多加一道防线。