TOTP 动态口令
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 个一次性备用码,供用户丢失手机时使用 |