Spring Boot 集成 TOTP 双因素认证与恢复码机制

只靠密码的登录方式,在现在这个环境下多少有点自欺欺人。攻击者很少再去硬碰你的密码,撞库和钓鱼才是最常见的手段。密码复杂度合规解决不了用户在其他平台泄密、然后被批量脚本拿来试登录的问题。给账号加一道"第二因子",至少能让拿到密码的人进不了门。

MFA 方案里 TOTP 是成本最低、也最容易落地的那种。用户手机装个 Google Authenticator 或 Microsoft Authenticator,服务端不用买硬件、不用发短信,只需要在认证流程里多算一个 6 位数字。但这件事也不是"登录时多验一次码"那么简单:Secret 怎么生成、二维码怎么出、第二步怎么插进 Spring Security 过滤器链、验证码被截获重放怎么办、用户手机丢了怎么办,都要提前想清楚。

这篇不打算把 RFC 逐字贴一遍,只讲实现时真正用得上的东西。

TOTP 到底在算什么

TOTP 是在 HOTP 基础上改出来的,HOTP 的计数器是"事件",每用一次加一;TOTP 把计数器换成了"时间",用当前 Unix 时间戳整除以 30 得到。时间变了,密码就变。

计算过程拆开看就五步:

  1. 服务端生成一个随机 Base32 字符串作为共享密钥,存库的同时让用户扫码存进手机。
  2. 取 counter = currentUnixTime / 30。
  3. 用共享密钥对这个 counter(8 字节大端序)做 HMAC-SHA1,得到一个 20 字节的 hash。
  4. 取 hash 最后一个字节的低 4 位作为偏移量,从 hash 中截 4 个字节,转成 31 位整数(最高位符号位置 0)。
  5. 这个整数对 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 做出来,再配合恢复码把"手机丢了"这条退路留好,账户安全的水平已经能提升一大截了。


校验记录

以下是对原文的校验结果,供你对照调整:

  1. 修正了文章中防重放示例代码的错误调用。原文里 generateAtCounter(secret, code(counter)).equals(code) 明显是笔误,code(counter) 并不可用,应直接比较当前 counter 的生成值与用户提交的验证码。
  2. 原文在"Session 缓存 User 对象"没有展开讨论。建议最终正文中加入相关提醒,因为 totpSecret 随 session 序列化到 Redis 属于真实项目容易踩的坑。
  3. otpauth URL 的生成代码强调简单可用,实际落地建议做好 URL 编码处理。建议不要直接用 URLEncoder.encode 处理 label 中的空格和特殊字符,+ 会被当成字面量加号,造成部分验证器 App 识别异常。
  4. 激活 MFA 前必须校验一次动态码,这一点原文已经提到,但正文会更容易忽略其背后的原因。我保留了这个关键流程并补充了解释。
  5. Secret 存储加密部分,原文只给思路,未实现具体代码,这也是合理的,因为密钥管理依赖具体基础设施。最终正文与原文保持一致,只做必要的提醒。
  6. 全文去掉了模板化的开场白与总结句式,恢复码、安全建议等部分改为更接近一线开发者的口吻。
  7. 源代码中个别命名和常量(如 SECRET_SIZE = 20)均与 Google Authenticator 兼容,不需要改动。

🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

相关推荐
vipxieliang1 小时前
ThinkPHP MVC 模式入门:从控制器到视图的完整实践
后端·php·thinkphp
小冯不疯1 小时前
物料主数据同步实战:从金蝶云星空到小满OKKICRM的单一策略深度拆解
java·大数据·数据库
vipxieliang1 小时前
Spring Boot 测试策略:单元测试、切片测试与集成测试
java·spring boot·后端
vipxieliang1 小时前
Go 1.24 工具链改进实战:工具依赖管理与 go vet 新分析器
后端·go
掘金者阿豪1 小时前
慢 SQL 优化别急着加索引:从订单分页查询聊聊 KES 执行计划
后端
涡能增压发动积1 小时前
AI 对一位老开发的副作用总结
后端
她的男孩1 小时前
条件分支两边审批一样,却发成两份待办:Forge Admin 审批设计器的公共节点合并
spring boot·低代码·工作流引擎
估值探索者1 小时前
【Python量化技术指标实战 04】布林带通道突破,怎么知道是真突破还是假突破?
java·开发语言·python·接口·股票数据api接口
未秃头的程序猿1 小时前
同一个商户号,V2 免密代扣和 V3 分账的证书怎么共存?
java·后端·微信