
只靠密码的登录方式,在现在这个环境下多少有点自欺欺人。攻击者很少再去硬碰你的密码,撞库和钓鱼才是最常见的手段。密码复杂度合规解决不了用户在其他平台泄密、然后被批量脚本拿来试登录的问题。给账号加一道"第二因子",至少能让拿到密码的人进不了门。
MFA 方案里 TOTP 是成本最低、也最容易落地的那种。用户手机装个 Google Authenticator 或 Microsoft Authenticator,服务端不用买硬件、不用发短信,只需要在认证流程里多算一个 6 位数字。但这件事也不是"登录时多验一次码"那么简单:Secret 怎么生成、二维码怎么出、第二步怎么插进 Spring Security 过滤器链、验证码被截获重放怎么办、用户手机丢了怎么办,都要提前想清楚。
这篇不打算把 RFC 逐字贴一遍,只讲实现时真正用得上的东西。
TOTP 到底在算什么
TOTP 是在 HOTP 基础上改出来的,HOTP 的计数器是"事件",每用一次加一;TOTP 把计数器换成了"时间",用当前 Unix 时间戳整除以 30 得到。时间变了,密码就变。
计算过程拆开看就五步:
- 服务端生成一个随机 Base32 字符串作为共享密钥,存库的同时让用户扫码存进手机。
- 取
counter = currentUnixTime / 30。 - 用共享密钥对这个
counter(8 字节大端序)做 HMAC-SHA1,得到一个 20 字节的 hash。 - 取 hash 最后一个字节的低 4 位作为偏移量,从 hash 中截 4 个字节,转成 31 位整数(最高位符号位置 0)。
- 这个整数对
10^6取模,补零到 6 位,就是验证码。
关键代码看起来就这样:
java
public static String generateTOTP(byte[] secret, long timeInSeconds, int timeStepSeconds, int digits) {
long counter = timeInSeconds / timeStepSeconds;
byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array();
byte[] hash = hmacSha1(secret, 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);
}
共享密钥用 Base32 而不是 Base64,是因为 Base32 字符集只有 A-Z 和 2-7,剔除了 0/O、1/I 这类容易看混的字符。万一用户需要手输密钥,也能减少错误。
先把最基础的工具类写好
Maven 里加 commons-codec 做 Base32 编解码,加 ZXing 生成二维码。ZXing 需要 core 和 javase 两个 artifact,不然 MatrixToImageWriter 根本找不到。
xml
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.16.0</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.2</version>
</dependency>
工具类直接给完整实现:
java
import org.apache.commons.codec.binary.Base32;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.SecureRandom;
public class TotpService {
private static final int SECRET_SIZE = 20;
private static final int TIME_STEP = 30;
private static final int CODE_DIGITS = 6;
public static String generateSecret() {
byte[] buffer = new byte[SECRET_SIZE];
new SecureRandom().nextBytes(buffer);
return new Base32().encodeToString(buffer);
}
public static boolean verify(String secret, String code) {
return verifyAtTime(secret, code, System.currentTimeMillis() / 1000, 0);
}
public static boolean verify(String secret, String code, int window) {
return verifyAtTime(secret, code, System.currentTimeMillis() / 1000, window);
}
public static boolean verifyAtTime(String secret, String code, long timeInSeconds, int window) {
if (secret == null || code == null || code.isEmpty()) {
return false;
}
byte[] secretKey = new Base32().decode(secret);
long counter = timeInSeconds / TIME_STEP;
for (int offset = -window; offset <= window; offset++) {
String generated = generateAtCounter(secretKey, counter + offset);
if (constantTimeEquals(generated, code)) {
return true;
}
}
return false;
}
private static String generateAtCounter(byte[] secretKey, long counter) {
byte[] counterBytes = ByteBuffer.allocate(8).putLong(counter).array();
try {
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(secretKey, "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, CODE_DIGITS);
return String.format("%0" + CODE_DIGITS + "d", otp);
} catch (Exception e) {
throw new RuntimeException("TOTP generation failed", e);
}
}
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;
}
}
验证时加 window 参数就是为了容忍手机时间和服务端时间有偏差。一般 window = 1,前后各放宽一个步长,也就是总共允许 1 分半的误差。再大就不太合适了,等于把每个验证码的有效窗口人为拉长。
二维码绑定:激活前必须先验一次码
MFA 开启流程通常是用户点"启用"之后,服务端生成一个新的 Secret,返回二维码和一段手工输入密钥让用户填到验证器 App 里。二维码内容遵从 otpauth:// 协议:
otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}
Controller 大致是:
java
@RestController
@RequestMapping("/api/mfa")
public class MfaController {
@PostMapping("/setup")
public MfaSetupResponse setup(@AuthenticationPrincipal User user) {
// 如果用户已经激活过,直接返回已开启状态,不要反复生成 secret
String secret = TotpService.generateSecret();
mfaSecretStore.save(user.getId(), secret, MfaStatus.PENDING);
String otpauthUrl = String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s",
"MyApp",
user.getEmail(),
secret,
"MyApp"
);
return new MfaSetupResponse(otpauthUrl, secret);
}
}
生成二维码图片返回给前端时,常见做法是直接给一个 Base64 的 Data URL。ZXing 把 URL 变成图片并不复杂:
java
public static String generateQrCodeBase64(String otpauthUrl, int width, int height) {
try {
Map<EncodeHintType, Object> hints = new HashMap<>();
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.MARGIN, 1);
BitMatrix matrix = new MultiFormatWriter()
.encode(otpauthUrl, BarcodeFormat.QR_CODE, width, height, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "png", baos);
return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray());
} catch (Exception e) {
throw new RuntimeException("QR Code generation failed", e);
}
}
到了"激活"这一步,很多人会漏掉一个关键操作:用户扫码后,必须让他把 App 里当前显示的验证码提交一次,服务端校验通过后,secret 状态才从 PENDING 改成 ACTIVE。
这一步不是形式主义。如果不验证就激活,用户二维码扫错、secret 没保存成功、手机时间不对,到登录时才发现手机生成的验证码根本不对,那时候账户已经被 MFA 锁住,只能走人工客服流程。
激活接口里顺便把恢复码一起生成了:
java
@PostMapping("/activate")
public ResponseEntity<?> activate(@AuthenticationPrincipal User user,
@RequestParam String code) {
String secret = mfaSecretStore.getPendingSecret(user.getId());
if (!TotpService.verify(secret, code)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid verification code"));
}
mfaSecretStore.activate(user.getId());
List<String> recoveryCodes = recoveryCodeService.generateAndStore(user.getId());
return ResponseEntity.ok(Map.of("recoveryCodes", recoveryCodes));
}
把 TOTP 校验插到 Spring Security 认证流程里
如果项目里 Spring Security 还在用经典的表单登录,那改造思路很明确:用户名密码校验通过后,如果用户开了 MFA,先不要直接写 SecurityContext,而是把用户引导到一个中间状态,要他提交 TOTP 验证码;验证码也通过后,才让他变成真正已认证状态。
用户实体里需要加几个字段:
java
public class User implements UserDetails {
private Long id;
private String username;
private String password;
@JsonIgnore
private String totpSecret;
private boolean mfaEnabled;
// ...
}
totpSecret 必须加 @JsonIgnore,或者干脆别把实体直接序列化返回给前端。一旦泄露,等于把用户手机里的动态码种子交出去了。
我的做法是弄一个专门的过滤器处理 /mfa/login 这个请求。用户名密码验证通过后,往 session 里塞一个待验证的用户 ID,然后跳到 MFA 输入页。这个 ID 在 TOTP 验证通过后立刻清掉:
java
public class SecondAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (!requiresMfaVerification(request)) {
chain.doFilter(request, response);
return;
}
String code = request.getParameter("totpCode");
Long userId = (Long) request.getSession().getAttribute("MFA_PENDING_USER_ID");
if (userId == null) {
response.sendRedirect("/login");
return;
}
User user = userService.findById(userId);
if (!TotpService.verify(user.getTotpSecret(), code, 1)) {
response.sendRedirect("/mfa/login?error");
return;
}
Authentication auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
request.getSession().removeAttribute("MFA_PENDING_USER_ID");
response.sendRedirect("/");
}
}
session 里的待验证状态一定要设置过期时间,建议 5 分钟以内。不然用户停在 MFA 页面不动,这个状态可以被拿来当跳板反复尝试验证码。
还有一个会被忽略的点:session 里缓存了 User 对象,里面带了 totpSecret。如果项目配置了 session 持久化或者序列化到 Redis,一定要确认这个 secret 不会跟着 session 落到外部存储。最稳妥的办法是 session 里只放 userId,需要时再从数据库读取。
验证码校验里容易漏掉的三个细节
第一是时钟偏移。服务端时间一般靠 NTP 同步,问题主要在用户手机上。有的用户手机时间不准,导致验证码一直不对。window = 1 能覆盖一些偏差,但手机时间差得离谱时还是不行。这种情况通常只能提示用户开启手机"自动设置时间",不要把 window 无限放大去迁就。
第二是防重放。TOTP 的 6 位验证码在 30 秒内是固定的,如果被中间人截获,在这 30 秒内就能直接用。所以最好在用户表里加一个字段记录最近一次校验通过的 counter:
java
public static boolean verifyWithReplayProtection(String secret, String code, int window,
long lastSuccessfulCounter) {
byte[] secretKey = new Base32().decode(secret);
long currentCounter = System.currentTimeMillis() / 1000 / 30;
for (long counter = currentCounter - window; counter <= currentCounter + window; counter++) {
if (counter > lastSuccessfulCounter) {
String generated = generateAtCounter(secretKey, counter);
if (constantTimeEquals(generated, code)) {
return true;
}
}
}
return false;
}
验证通过后,调用方负责把当前 counter 写回用户表。这个更新操作要注意并发,两个请求同时带着同一个验证码过来,可能会都通过校验。简单方式是用数据库 CAS 或者分布式锁,粗暴一点也可以给用户的 TOTP 校验过程加一个应用层锁。
第三是限制尝试次数。6 位数字一共就 100 万个组合,攻击者即使不知道 secret,也可以在网络里快速试。为了避免暴力枚举,同一个用户校验失败 5 次后,必须暂时锁定。用 Caffeine 或 Redis 维护一个小计数器就够了:
java
@Component
public class MfaAttemptLimiter {
private final Cache<Long, AtomicInteger> attempts = Caffeine.newBuilder()
.expireAfterWrite(15, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
public boolean isBlocked(Long userId) {
AtomicInteger counter = attempts.getIfPresent(userId);
return counter != null && counter.get() >= 5;
}
public void failure(Long userId) {
attempts.get(userId, k -> new AtomicInteger()).incrementAndGet();
}
public void success(Long userId) {
attempts.invalidate(userId);
}
}
恢复码的生成与存储
TOTP 什么都好,但用户手机一丢,或者验证器 App 被误删,基本就进不了系统了。没有恢复码的 MFA 设计,实际上是把用户锁在门外。
恢复码一般在 MFA 激活时生成,数量 8 到 10 个,每个一次性使用。它的作用是作为 MFA 的备用第二因子,用户可以在登录页选择"使用恢复码"来替代 TOTP 验证码。
生成恢复码时要注意:
- 使用 SecureRandom,不能自己拼接随机字符串,至少要保证 80 位以上熵。
- 展示给用户后,数据库只存哈希。恢复码的泄露风险比普通密码还高,因为它就是一把备用钥匙。
- 恢复码是一次性的,用掉一个就作废一个。
存储时可以用 SHA-256(code + 随机盐),也可以直接复用项目中已有的 BCrypt 编码器。用 BCrypt 的问题是验证时得遍历该用户所有未消费的恢复码,每条做一次比较。恢复码最多十条,平时又几乎不会被用到,所以这个开销完全可以接受,不值得为了它单独引入一套加密服务。
数据库表结构类似这样:
sql
CREATE TABLE user_recovery_codes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
code_hash VARCHAR(128) NOT NULL,
consumed_at DATETIME NULL,
created_at DATETIME DEFAULT NOW()
);
恢复码使用后要不要轮换
当用户通过某个恢复码登录成功后,剩余恢复码是否继续有效?我的建议是:立刻把旧恢复码全部作废,重新生成一组新的给用户。
原因很简单。恢复码被使用,说明用户已经处于不太正常的登录环境里了,有可能是自己的手机丢了,但也有可能是恢复码泄露后被攻击者使用了。旧码继续保留,风险窗口会一直存在。重新生成一组,等于把之前的状态全部重置,两张码作废、避免风险累积。
另外有个场景特别容易被忽略:用户忘记密码,通过邮箱或手机验证码重置了密码。如果这个账户之前开了 MFA,那密码重置成功之后必须强制用户重新走一遍 MFA 绑定流程。否则攻击者只需要控制邮箱,就能借"找回密码"把一个开了 MFA 的账户直接接管。
建议用户手动设置"重新生成 MFA"时,流程也一致处理:旧的 totpSecret 立即作废,所有恢复码删除,然后重新走绑定和激活。安全审计日志里也要把 MFA_RESET_BY_USER、MFA_ACTIVATED、RECOVERY_CODE_USED 这类事件记录下来。日志建议用结构化 JSON,带上 userId、IP、UserAgent、时间戳和成功失败标记。这些日志平时没什么用,但账户被盗后追溯攻击路径时,没有它就只能干瞪眼。
Secret 的加密存储与生产环境细节
很多教程把 TOTP Secret 直接明文字段存在数据库里,这一点在生产环境非常危险。拿到数据库权限的人,只要解密出 Secret,就能自己生成该用户的所有 TOTP 验证码,MFA 形同虚设。
正确的做法是先加密再入库。加密算法用 AES-256-GCM 这类带认证的算法,密钥放到独立的密钥管理系统里,比如 Vault、KMS,至少也要使用独立的配置中心,不能和应用的数据库密码放在同一个配置项里。解密时只在使用的那一刻进行,不要把它缓存到 Redis 之类的地方。
java
String encryptedSecret = user.getTotpSecretEncrypted();
String totpSecret = aesDecrypt(encryptedSecret, keyFromKms());
序列化问题再强调一次:不要因为图省事,把一个含 totpSecret 字段的实体直接返回给前端。用 DTO 也好,加 @JsonIgnore 也好,必须保证 secret 只出现在绑定接口的响应里,其他所有接口都不能带出。
其他几个生产环境容易踩的细节:
- 传输层没有 HTTPS,前面的所有加密都是空谈。二维码里包含 Secret,明文传输等于直接泄露给中间人。
- 日志中不要打印 TOTP 验证码、二维码 Base64 字符串以及任何形式的 Secret。错误日志里顺手把用户提交的验证码打出来,是我见过最多的数据泄露方式。
- 服务端必须开启 NTP 时钟同步。TOTP 对时间敏感,服务器时间漂移会导致大量正常用户登录失败。
- 前端的 TOTP 输入框建议加上
autocomplete="one-time-code",避免浏览器的密码管理器把验证码输入框当成密码框自动填充,给用户带来困扰。
最后想说一下,MFA 只是把账户安全从"单点依赖密码"变成了"密码 + 第二因子",它解决不了客户端被完全控制、用户主动泄露恢复码这类问题。但现实中绝大多数撞库和钓鱼攻击,在碰到第二步验证时就会停下来。对一个业务系统来说,把 TOTP 做出来,再配合恢复码把"手机丢了"这条退路留好,账户安全的水平已经能提升一大截了。
校验记录
以下是对原文的校验结果,供你对照调整:
- 修正了文章中防重放示例代码的错误调用。原文里
generateAtCounter(secret, code(counter)).equals(code)明显是笔误,code(counter)并不可用,应直接比较当前 counter 的生成值与用户提交的验证码。 - 原文在"Session 缓存 User 对象"没有展开讨论。建议最终正文中加入相关提醒,因为
totpSecret随 session 序列化到 Redis 属于真实项目容易踩的坑。 - otpauth URL 的生成代码强调简单可用,实际落地建议做好 URL 编码处理。建议不要直接用
URLEncoder.encode处理 label 中的空格和特殊字符,+会被当成字面量加号,造成部分验证器 App 识别异常。 - 激活 MFA 前必须校验一次动态码,这一点原文已经提到,但正文会更容易忽略其背后的原因。我保留了这个关键流程并补充了解释。
- Secret 存储加密部分,原文只给思路,未实现具体代码,这也是合理的,因为密钥管理依赖具体基础设施。最终正文与原文保持一致,只做必要的提醒。
- 全文去掉了模板化的开场白与总结句式,恢复码、安全建议等部分改为更接近一线开发者的口吻。
- 源代码中个别命名和常量(如
SECRET_SIZE = 20)均与 Google Authenticator 兼容,不需要改动。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
