TOTP 动态口令

返回 Spring Boot 基础

TOTP(Time-Based One-Time Password,RFC 6238)是 Google Authenticator、Microsoft Authenticator 等 APP 背后的核心算法,常用于实现双因素认证(2FA/MFA)。


原理

TOTP 基于 HOTP(HMAC-Based OTP,RFC 4226),将时间戳替换计数器作为动态因子:

T = floor(Unix时间戳 / 30)          ← 当前 30 秒窗口编号
HMAC = HMAC-SHA1(密钥K, T)          ← 20 字节哈希
offset = HMAC[19] & 0xF
code = (HMAC[offset..offset+4] & 0x7FFFFFFF) % 1_000_000   ← 6 位数字

客户端与服务端共享同一个 Base32 密钥,各自独立计算当前时间窗口的 OTP,结果一致则验证通过。


依赖

<dependency>
    <groupId>dev.samstevens.totp</groupId>
    <artifactId>totp-spring-boot-starter</artifactId>
    <version>1.7.1</version>
</dependency>

生成密钥与二维码

import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
import dev.samstevens.totp.util.Utils;
 
@Service
public class TotpService {
 
    private final DefaultSecretGenerator secretGenerator = new DefaultSecretGenerator();
    private final ZxingPngQrGenerator qrGenerator = new ZxingPngQrGenerator();
 
    /** 生成随机密钥(Base32),每用户独立,加密后存库 */
    public String generateSecret() {
        return secretGenerator.generate();
    }
 
    /** 生成二维码 Data URL,前端直接 <img src="..."> 显示 */
    public String generateQrDataUrl(String secret, String username, String issuer) throws Exception {
        QrData data = new QrData.Builder()
            .label(username)
            .secret(secret)
            .issuer(issuer)
            .algorithm(HashingAlgorithm.SHA1)
            .digits(6)
            .period(30)
            .build();
        byte[] png = qrGenerator.generate(data);
        return "data:image/png;base64," + Utils.getDataUriForImage(png, qrGenerator.getImageMimeType());
    }
}

二维码内容本质是 otpauth:// URI:

otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&digits=6&period=30

验证 OTP

import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.time.SystemTimeProvider;
 
@Service
public class TotpService {
 
    private final DefaultCodeVerifier verifier;
 
    public TotpService() {
        this.verifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider());
        // 允许前后 1 个时间窗口的误差(共 ±30s),容忍客户端时钟偏差
        this.verifier.setAllowedTimePeriodDiscrepancy(1);
    }
 
    public boolean verify(String secret, String code) {
        return verifier.isValidCode(secret, code);
    }
}

Controller 示例

@RestController
@RequestMapping("/api/admin/totp")
public class TotpController {
 
    @Autowired private TotpService totpService;
    @Autowired private UserService userService;
 
    /** 第一步:生成密钥,返回二维码;密钥暂存 Session,待验证后再写库 */
    @PostMapping("/setup")
    public Map<String, String> setup(HttpSession session, Authentication auth) throws Exception {
        String secret = totpService.generateSecret();
        String qrUrl = totpService.generateQrDataUrl(secret, auth.getName(), "KumaBlog");
        session.setAttribute("pending_totp_secret", secret);
        return Map.of("qrDataUrl", qrUrl);
    }
 
    /** 第二步:用户扫码后输入 OTP,验证通过才写库启用 */
    @PostMapping("/enable")
    public ResponseEntity<?> enable(@RequestBody Map<String, String> body, HttpSession session) {
        String secret = (String) session.getAttribute("pending_totp_secret");
        if (secret == null || !totpService.verify(secret, body.get("code"))) {
            return ResponseEntity.badRequest().body("验证码错误");
        }
        userService.enableTotp(currentUserId(), secret);   // 加密后存库
        session.removeAttribute("pending_totp_secret");
        return ResponseEntity.ok().build();
    }
 
    /** 登录时验证 */
    @PostMapping("/verify")
    public ResponseEntity<?> verify(@RequestBody Map<String, String> body) {
        String secret = userService.getTotpSecret(currentUserId());
        if (!totpService.verify(secret, body.get("code"))) {
            return ResponseEntity.status(401).body("验证码错误或已过期");
        }
        return ResponseEntity.ok().build();
    }
}

安全要点

问题处理方式
密钥存储AES-256 加密后写库,不能明文
重放攻击每个 30s 窗口的 OTP 仅允许使用一次(Redis 记录已消费 OTP)
暴力破解连续失败 5 次后锁定或增加延迟
启用前必验密钥写库前必须先成功验证一次,防止绑定无效密钥
备用码生成 8 个一次性备用码,供用户丢失手机时使用