安全无忧!在 Spring Boot 3.3 中轻松实现 TOTP 双因素认证
随着互联网的快速发展,网络安全问题日益严峻。传统的用户名和密码认证方式已经无法满足现代应用对安全性的要求,因此双因素认证(2FA)成为了提升安全性的有效手段。双因素认证不仅要求用户输入密码,还需通过第二种方式进行身份验证,例如手机生成的动态验证码。
时间同步一次性密码(TOTP)是一种基于时间的双因素认证方式,它通过算法生成短期有效的验证码。用户在登录时,需要输入从手机应用(如 Google Authenticator)获取的 TOTP 代码。由于 TOTP 代码每 30 秒更新一次,即使攻击者获取了用户的密码,没有有效的 TOTP 代码,也无法登录账户。
本文将详细介绍如何在 Spring Boot 3.3 中实现基于 TOTP 的双因素认证,涵盖从依赖配置、服务实现到前端展示的完整过程。
什么是 TOTP?
TOTP(Time-based One-Time Password)是一种用于双因素认证的算法,它基于当前时间和用户的共享秘密(密钥)生成一次性密码。TOTP 主要遵循以下步骤:
密钥生成:在用户账户创建时生成一个共享密钥,并与用户的身份绑定。该密钥通常以 Base32 编码格式存储。
时间戳使用:TOTP 使用当前时间戳,将时间分成固定的时间段(例如,30 秒)。每个时间段生成一个唯一的 TOTP 密码。
动态密码生成:通过将共享密钥和当前时间戳作为输入,使用 HMAC-SHA1 或类似算法生成一次性密码。
验证过程:在用户登录时,服务器端也使用相同的共享密钥和当前时间戳生成 TOTP 密码,并与用户输入的密码进行比对。
这种机制保证了每次登录时生成的密码都是唯一且短暂的,极大地提升了账户的安全性。
运行效果:
若想获取项目完整代码以及其他文章的项目源码,且在代码编写时遇到问题需要咨询交流,欢迎加入下方的知识星球。
项目依赖配置
首先,在 pom.xml
中添加所需的依赖:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.icoderoad</groupId>
<artifactId>totp-authentication</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>totp-authentication</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<dependency>
<groupId>dev.samstevens.totp</groupId>
<artifactId>totp</artifactId>
<version>1.7.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置文件
接下来,我们在 application.yml
中配置所需的属性:
server:
port: 8080
totp:
time-step: 30
length: 6
生成和配置密钥
生成密钥服务类
package com.icoderoad.totp.service;
import org.springframework.stereotype.Service;
import dev.samstevens.totp.secret.DefaultSecretGenerator;
import dev.samstevens.totp.secret.SecretGenerator;
@Service
public class SecretService {
private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
public String generateSecret() {
// 生成安全的随机 base32 编码字符串
return secretGenerator.generate();
}
}
属性配置类
package com.icoderoad.totp.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@ConfigurationProperties(prefix = "totp")
public class TotpProperties {
private int timeStep = 30; // 默认值为 30 秒
private int length = 6; // 默认值为 6 位
}
配置 TOTP 生成器
package com.icoderoad.totp.service;
import com.icoderoad.totp.config.TotpProperties;
import dev.samstevens.totp.time.TimeProvider;
import dev.samstevens.totp.time.SystemTimeProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TotpConfiguration {
private final TotpProperties totpProperties;
public TotpConfiguration(TotpProperties totpProperties) {
this.totpProperties = totpProperties;
}
@Bean
public TimeProvider timeProvider() {
return new SystemTimeProvider(); // 使用系统时间提供者
}
@Bean
public int getTotpLength() {
return totpProperties.getLength();
}
public int getTimeStepInSeconds() {
return totpProperties.getTimeStep();
}
}
TOTP 生成和验证
TOTP 生成服务
package com.icoderoad.totp.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.icoderoad.totp.config.TotpProperties;
import dev.samstevens.totp.code.CodeGenerator;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.exceptions.CodeGenerationException;
import dev.samstevens.totp.time.SystemTimeProvider;
import dev.samstevens.totp.time.TimeProvider;
@Service
public class TotpGeneratorService {
@Autowired
private TotpProperties totpProperties;
private final CodeGenerator codeGenerator;
private final TimeProvider timeProvider;
@Autowired
public TotpGeneratorService(TimeProvider timeProvider) {
this.timeProvider = timeProvider != null ? timeProvider : new SystemTimeProvider();
this.codeGenerator = new DefaultCodeGenerator(); // 使用默认构造函数
}
public String generateTotp(String secret) {
long counter = getCounter();
try {
return codeGenerator.generate(secret, counter);
} catch (CodeGenerationException e) {
return "";
}
}
private long getCounter() {
long timeStep = totpProperties.getTimeStep();
return timeProvider.getTime() / timeStep;
}
}
TOTP 验证服务
package com.icoderoad.totp.service;
import dev.samstevens.totp.code.CodeVerifier;
import dev.samstevens.totp.code.DefaultCodeVerifier;
import dev.samstevens.totp.code.DefaultCodeGenerator;
import dev.samstevens.totp.exceptions.CodeGenerationException;
import dev.samstevens.totp.time.TimeProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.icoderoad.totp.config.TotpProperties;
@Service
public class TotpVerificationService {
private final DefaultCodeVerifier codeVerifier;
private final TimeProvider timeProvider;
private final TotpProperties totpProperties;
@Autowired
public TotpVerificationService(TimeProvider timeProvider, TotpProperties totpProperties) {
this.totpProperties = totpProperties;
this.timeProvider = timeProvider;
this.codeVerifier = new DefaultCodeVerifier(new DefaultCodeGenerator(), timeProvider);
this.codeVerifier.setTimePeriod(this.totpProperties.getTimeStep()); // 从配置文件中读取或设置
this.codeVerifier.setAllowedTimePeriodDiscrepancy( this.totpProperties.getLength() ); // 可配置的时间误差
}
public boolean verifyTotp(String secret, String code) {
return codeVerifier.isValidCode(secret, code);
}
}
用户注册与 TOTP 集成
UserService 类
package com.icoderoad.totp.service;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class UserService {
// 使用 HashMap 模拟用户存储(可以替换为数据库实现)
private final Map<String, String> userSecrets = new HashMap<>();
/**
* 保存用户的 TOTP 秘密
*
* @param username 用户名
* @param secret 用户的 TOTP 秘密
*/
public void saveUserSecret(String username, String secret) {
userSecrets.put(username, secret);
}
/**
* 根据用户名获取 TOTP 秘密
*
* @param username 用户名
* @return TOTP 秘密
*/
public String findSecretByUsername(String username) {
return userSecrets.get(username);
}
// 可以添加更多与用户相关的方法,如验证用户、获取用户信息等
}
QRCodeGenerator类
package com.icoderoad.totp.generator;
import org.springframework.stereotype.Component;
import dev.samstevens.totp.exceptions.QrGenerationException;
import dev.samstevens.totp.qr.QrData;
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
@Component
public class QRCodeGenerator {
private final ZxingPngQrGenerator qrGenerator;
public QRCodeGenerator() {
this.qrGenerator = new ZxingPngQrGenerator();
}
public byte[] generate(String secret, String username, String issuer, int digits, int period) throws QrGenerationException {
// 创建 QR 数据
QrData qrData = new QrData.Builder()
.label(username)
.secret(secret)
.issuer(issuer)
.digits(digits)
.period(period)
.build();
// 生成 QR 代码
return qrGenerator.generate(qrData);
}
public String generateQrCodeUrl(String secret, String username, String issuer, int digits, int period) throws QrGenerationException {
byte[] qrCodeBytes = generate(secret, username, issuer, digits, period);
// 将生成的 QR 代码转换为 Base64 URL,便于在 HTML 中显示
return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(qrCodeBytes);
}
}
RegistrationResponse类
package com.icoderoad.totp.controller;
public class RegistrationResponse {
private final String secret;
private final String qrCodeUrl;
public RegistrationResponse(String secret, String qrCodeUrl) {
this.secret = secret;
this.qrCodeUrl = qrCodeUrl;
}
public String getSecret() {
return secret;
}
public String getQrCodeUrl() {
return qrCodeUrl;
}
}
注册控制器
package com.icoderoad.totp.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.icoderoad.totp.dto.UserDto;
import com.icoderoad.totp.generator.QRCodeGenerator;
import com.icoderoad.totp.service.SecretService;
import com.icoderoad.totp.service.UserService;
import dev.samstevens.totp.exceptions.QrGenerationException;
@RestController
public class RegistrationController {
private final SecretService secretService;
private final UserService userService;
private final QRCodeGenerator qrCodeGenerator;
@Autowired
public RegistrationController(SecretService secretService, UserService userService, QRCodeGenerator qrCodeGenerator) {
this.secretService = secretService;
this.userService = userService;
this.qrCodeGenerator = qrCodeGenerator;
}
@PostMapping("/register")
public ResponseEntity<RegistrationResponse> registerUser(@RequestBody UserDto user) {
String secret = secretService.generateSecret();
// 将密钥安全存储在用户账户下
userService.saveUserSecret(user.getUsername(), secret);
// 生成二维码供用户扫描
String qrCodeUrl = generateQrCodeUrl(secret, user.getUsername());
return ResponseEntity.ok(new RegistrationResponse(secret, qrCodeUrl));
}
private String generateQrCodeUrl(String secret, String username) {
try {
return qrCodeGenerator.generateQrCodeUrl(secret, username, "YourIssuer", 6, 30);
} catch (QrGenerationException e) {
// 处理 QR 代码生成异常
throw new RuntimeException("Failed to generate QR code.", e);
}
}
}
前端页面实现
登录页面 (index.html)
首先,在 src/main/resources/templates/
目录下创建一个名为 index.html
的文件。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TOTP 注册</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
</head>
<body>
<div class="container">
<h1 class="mt-5">注册 TOTP</h1>
<form id="registrationForm">
<div class="form-group">
<label for="username">用户名</label>
<input type="text" class="form-control" id="username" placeholder="输入用户名" required>
</div>
<button type="submit" class="btn btn-primary">注册</button>
</form>
<div id="result" class="mt-4" style="display: none;">
<h2>注册成功!</h2>
<p>您的密钥: <span id="secret"></span></p>
<h3>扫描二维码:</h3>
<img id="qrCode" alt="二维码" />
</div>
</div>
<script>
document.getElementById('registrationForm').addEventListener('submit', function (event) {
event.preventDefault(); // 阻止表单提交
const username = document.getElementById('username').value;
fetch('/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username }),
})
.then(response => {
if (!response.ok) {
throw new Error('网络错误');
}
return response.json();
})
.then(data => {
// 更新页面内容
document.getElementById('secret').textContent = data.secret;
document.getElementById('qrCode').src = data.qrCodeUrl;
document.getElementById('result').style.display = 'block'; // 显示结果
})
.catch(error => {
console.error('发生错误:', error);
alert('注册失败,请重试!');
});
});
</script>
</body>
</html>
总结
本 TOTP 注册系统通过结合现代前端技术与稳健的后端架构,成功实现了高效、安全的用户注册流程。系统的设计充分考虑了安全性与用户体验,确保用户在注册过程中能够快速获取所需信息,而不影响安全标准。总体而言,该系统不仅提升了用户账户的安全性,也通过友好的操作流程增强了用户的信任感,为未来的扩展和优化打下了坚实基础。