2FA 与 Google Authenticator 对接:从原理到 Java 实现

你在日常生活中大概率已经接触过 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 位数字。

典型流程是:

  1. 用户第一次绑定设备时,服务端和设备同步一个密钥。
  2. 用户点击硬件令牌或 App 中的生成按钮。
  3. 设备使用密钥和当前计数器生成验证码。
  4. 用户把验证码提交给服务端。
  5. 服务端用同一个密钥和自己的计数器计算验证码。
  6. 如果当前计数器不匹配,服务端会在一个向前窗口内尝试后续几个计数器值。
  7. 匹配成功后,服务端把计数器同步到匹配的位置。

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 协议。

你需要做的事情主要是:

  1. 生成共享密钥。
  2. 拼出标准的 otpauth:// URI。
  3. 前端把 URI 渲染成二维码。
  4. 用户用 Google Authenticator 扫码。
  5. 服务端校验用户输入的 6 位验证码。
  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://... 这段字符串渲染成图片,前端使用 qrcodeqrcode.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();

/** 生成 Base32 编码的共享密钥,存入数据库与用户绑定。 */
public static String generateSecret() {
byte[] bytes = new byte[20];
RANDOM.nextBytes(bytes);
// Google Authenticator 不识别 Base32 的填充符 '=',这里去掉
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 {

/** 拼出标准 otpauth URI,前端拿到后自行渲染成二维码。 */
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';

// 渲染到 <canvas id="qr" />
QRCode.toCanvas(document.getElementById('qr'), data.otpAuthUri, { width: 240 });

// 或者生成 data URL 放到 <img src={url} />
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;

/** 根据密钥和 Unix 时间戳(秒)生成 6 位 OTP。 */
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; // 允许前后各 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 {
// 1. 注册:为用户生成密钥并保存
String secret = SecretGenerator.generateSecret();
System.out.println("Secret(存数据库): " + secret);

// 2. 拼出 otpauth URI 返回给前端,由前端渲染二维码
String uri = OtpAuthUriBuilder.build("MyApp", "wuheng@example.com", secret);
System.out.println("返回给前端的 URI: " + uri);

// 3. 用户扫码后,输入 App 上显示的 6 位验证码
String userInput = "123456"; // 实际从前端接收
TotpVerifier.VerifyResult result = TotpVerifier.verify(secret, userInput, null);
System.out.println("校验结果:" + result.success());
System.out.println("匹配到的 counter:" + result.matchedCounter());
}
}

真实业务中的绑定流程

真正落到业务里,要把“绑定”和“登录”两个流程分清楚。

开启 2FA 时,可以这样做:

  1. 用户在账号安全页点击开启 2FA。
  2. 服务端生成 secret,但不要直接把 2FA 状态改成已开启,而是先标记为“待确认”。
  3. 后端返回 otpAuthUri,前端渲染二维码。
  4. 用户用 Google Authenticator 扫码,然后输入 App 上显示的 6 位验证码。
  5. 服务端校验验证码。
  6. 如果校验通过,再把用户的 2FA 状态改成已开启,并保存本次匹配到的 counter
  7. 如果用户没有完成校验,就不要启用 2FA,避免用户因为扫码失败把自己锁在账号外面。

登录时,则是另一个流程:

  1. 用户先输入账号和密码。
  2. 服务端确认密码正确后,检查这个账号是否已经开启 2FA。
  3. 如果开启了 2FA,就要求用户继续输入身份验证器 App 上的 6 位验证码。
  4. 服务端校验验证码,并检查这个 counter 是否已经使用过。
  5. 校验通过后,更新最近一次成功使用的 counter
  6. 最后才真正完成登录。

落地时还要注意的几点

  • 密钥存储secret 是绑定的核心,数据库里一定要加密存储,例如用 KMS 包一层。
  • 防重放:验证通过后,把匹配到的 counter 记下来,同一个 counter 不允许再次通过。
  • 绑定确认:不要生成密钥后直接启用 2FA,要先让用户输入一次正确验证码。
  • 恢复码:给用户生成一组一次性 recovery code。当手机丢了,用户可以用恢复码登录并重置 2FA。
  • 时间同步:服务端一定要开 NTP,否则服务端时间漂移后,可能会拒绝大量正常用户。

小结

看到这里,你会发现对接 Google Authenticator 背后并没有什么实时通信。

甚至用户换成其他支持 TOTP 的 2FA App,也一样可以使用。

它真正依赖的是一个提前共享好的密钥,以及双方都认可的时间。

只要密钥不泄露、时间足够准确、同一个时间窗口的验证码不会被重复使用,这套机制就能用很低的成本给账户多加一道防线。


2FA 与 Google Authenticator 对接:从原理到 Java 实现
https://www.likeben.games/2026/04/19/2FA-与-Google-Authenticator-对接:从原理到-Java-实现/
作者
Ben
发布于
2026年4月19日
许可协议