纲要
- 多因子认证概述
- 双因子认证的常见形式:短信、邮件、软令牌、生物识别、位置等
- 一次性密码的两个核心特性:一次性、时效性
- TOTP 算法原理
TOTP基于时间的一次性密码,时间步长与密码长度可配置- 密钥与用户绑定,保证同一时间不同用户生成的验证码不同
- 技术栈与依赖
- Spring Boot、Spring Security、OAuth2 授权服务器
aerogear-otp-java提供 TOTP 算法支持- Redis (
Redisson) 用于缓存验证码尝试状态,嵌入式 Redis 简化测试
- 实现 TOTP 工具类
- 密钥生成(
KeyGenerator,HmacSHA1,512 位密钥) - 密钥的 Base64 序列化/反序列化
- 根据密钥与当前时间生成 6 位动态口令
- 验证口令:重新生成并与输入比对,利用时间窗口保证容错
- 密钥生成(
- Spring Security 集成多因子认证
- 自定义
MfaAuthenticationToken与MfaAuthenticationProvider - 自定义过滤器
MfaAuthenticationFilter拦截二次认证请求 - 登录流程:用户名密码验证成功 → 颁发临时令牌 → 要求输入 TOTP 码 → 二次认证通过后签发正式 JWT
- 自定义
- 项目结构展示
- 完整可运行代码示例
pom.xml依赖配置- TOTP 工具类
TOTPUtils - Spring Security 配置
- 认证流程核心类
- 测试控制器
- 总结与相关度
多因子认证概述
在仅依赖用户名和密码的系统中,弱密码、重复密码等问题常常导致账户被盗。多因子认证(MFA,Multi-Factor Authentication)通过组合两种或以上不同维度的验证因素来大幅提升安全性。常见的第二因素包括:
- 短信验证码
- 邮件验证码
- 基于时间的一次性密码(TOTP),如 Google Authenticator、Microsoft Authenticator
- 生物特征(指纹、面部识别)
- 地理位置或设备指纹
其中 TOTP 是一种无需依赖短信/邮件网关、零成本的软件令牌方案,非常适合企业应用或对成本敏感的系统。它生成的一串 6 位数字每 30 秒(或自定义步长)变化一次,具备两个关键特性:
- 一次性:验证成功后该口令立即失效,不可重用;
- 时效性:口令只在有限的时间窗口内有效,过期自动作废,且重试次数受控。
下面我们将从算法原理出发,在 Spring Security + OAuth2 的环境中实现一套完整的 TOTP 多因子认证方案。
TOTP 算法与工具类实现
TOTP(Time-Based One-Time Password)是 HOTP 的扩展,将递增计数器替换为当前时间戳除以时间步长得到的整数。算法标准为 RFC 6238。核心参数包括:
- 共享密钥:每个用户一个独立密钥,长度建议 160 位以上,通常经过 Base32 编码展示给用户。
- 时间步长:口令不变的时间窗口,常见为 30 秒,本示例使用 300 秒(5 分钟)便于演示。
- 口令长度:通常为 6 位或 8 位数字。
在 Java 中,我们使用 aerogear-otp-java 库来实现 TOTP,其底层依赖于 javax.crypto 进行 HMAC 计算。工具类将封装:
- 密钥生成(
KeyGenerator,HmacSHA1 算法,密钥长度 512 位) - 密钥与 Base64 字符串的相互转换(方便持久化到数据库)
- 根据密钥和时间生成 6 位口令
- 验证用户输入的口令是否有效
添加依赖
在 pom.xml 中引入相关库:
xml
<dependency>
<groupId>org.jboss.aerogear</groupId>
<artifactId>aerogear-otp-java</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.12.5</version>
</dependency>
<dependency>
<groupId>it.ozimov</groupId>
<artifactId>embedded-redis</artifactId>
<version>0.7.2</version>
<scope>test</scope>
</dependency>
(其它 Spring Boot、Spring Security、OAuth2 依赖此处省略,可基于 Spring Initializr 生成。)
TOTP 工具类代码
java
package com.example.mfa.util;
import org.jboss.aerogear.security.otp.Totp;
import org.jboss.aerogear.security.otp.api.Base32;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
public class TOTPUtils {
private static final String ALGORITHM = "HmacSHA1";
private static final int KEY_SIZE = 512; // 512 bits
private static final int TIME_STEP = 300; // 5分钟
private static final int PASSWORD_LENGTH = 6; // 6位数字
private static KeyGenerator keyGenerator;
private static Totp totp;
static {
try {
keyGenerator = KeyGenerator.getInstance(ALGORITHM);
keyGenerator.init(KEY_SIZE);
totp = new Totp(new SecretKeySpec(new byte[64], ALGORITHM), TIME_STEP, PASSWORD_LENGTH);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("TOTP 初始化失败", e);
}
}
// 生成随机密钥
public static SecretKey generateKey() {
return keyGenerator.generateKey();
}
// 将密钥编码为 Base64 字符串,便于存储
public static String encodeKey(SecretKey key) {
byte[] encoded = key.getEncoded();
return Base64.getEncoder().encodeToString(encoded);
}
// 从 Base64 字符串解码密钥
public static SecretKey decodeKey(String base64Key) {
byte[] decoded = Base64.getDecoder().decode(base64Key);
return new SecretKeySpec(decoded, ALGORITHM);
}
// 生成一个随机密钥并返回其 Base64 表示
public static String generateEncodedKey() {
return encodeKey(generateKey());
}
// 根据密钥与当前时间生成 TOTP 口令
public static String createTOTP(SecretKey key) {
Totp totpInstance = new Totp(key, TIME_STEP, PASSWORD_LENGTH);
String otp = totpInstance.now();
// 若不足6位,前面补零
return String.format("%0" + PASSWORD_LENGTH + "d", Integer.parseInt(otp));
}
// 基于 Base64 密钥生成口令
public static Optional<String> createTOTP(String base64Key) {
try {
SecretKey key = decodeKey(base64Key);
return Optional.of(createTOTP(key));
} catch (Exception e) {
return Optional.empty();
}
}
// 验证用户输入的 code 是否与当前时间窗口生成的匹配
public static boolean verify(SecretKey key, String code) {
String current = createTOTP(key);
return current.equals(code);
}
// 基于 Base64 密钥验证
public static boolean verify(String base64Key, String code) {
try {
SecretKey key = decodeKey(base64Key);
return verify(key, code);
} catch (Exception e) {
return false;
}
}
}
代码解读:静态初始化块中完成了 KeyGenerator 的实例化,密钥长度设置为 512 位,足够安全。TIME_STEP 设为 300 秒,即同一个用户在同一 5 分钟窗口内生成的验证码不变,这使演示和调试更为方便,生产环境建议缩短至 30 秒。生成的口令若不足 6 位,通过 String.format 自动补零。
集成 Spring Security 实现多因子流程
整体认证流程
TokenService Redis TOTPUtils UserDetailsService AuthManager AuthFilter Client TokenService Redis TOTPUtils UserDetailsService AuthManager AuthFilter Client #mermaid-svg-ejTVIioaWJcGCTF8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ejTVIioaWJcGCTF8 .error-icon{fill:#552222;}#mermaid-svg-ejTVIioaWJcGCTF8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ejTVIioaWJcGCTF8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ejTVIioaWJcGCTF8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ejTVIioaWJcGCTF8 .marker.cross{stroke:#333333;}#mermaid-svg-ejTVIioaWJcGCTF8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ejTVIioaWJcGCTF8 p{margin:0;}#mermaid-svg-ejTVIioaWJcGCTF8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ejTVIioaWJcGCTF8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ejTVIioaWJcGCTF8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ejTVIioaWJcGCTF8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ejTVIioaWJcGCTF8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ejTVIioaWJcGCTF8 .sequenceNumber{fill:white;}#mermaid-svg-ejTVIioaWJcGCTF8 #sequencenumber{fill:#333;}#mermaid-svg-ejTVIioaWJcGCTF8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ejTVIioaWJcGCTF8 .messageText{fill:#333;stroke:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ejTVIioaWJcGCTF8 .labelText,#mermaid-svg-ejTVIioaWJcGCTF8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .loopText,#mermaid-svg-ejTVIioaWJcGCTF8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ejTVIioaWJcGCTF8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ejTVIioaWJcGCTF8 .noteText,#mermaid-svg-ejTVIioaWJcGCTF8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ejTVIioaWJcGCTF8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ejTVIioaWJcGCTF8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ejTVIioaWJcGCTF8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ejTVIioaWJcGCTF8 .actorPopupMenu{position:absolute;}#mermaid-svg-ejTVIioaWJcGCTF8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ejTVIioaWJcGCTF8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ejTVIioaWJcGCTF8 .actor-man circle,#mermaid-svg-ejTVIioaWJcGCTF8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ejTVIioaWJcGCTF8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt验证通过验证失败 alt用户名密码正确且用户已启用MFA首次认证失败或无需MFA POST /login (username+password)认证加载用户UserDetails返回临时令牌 (status=MFA_REQUIRED)302 /mfa?token=tempTokenGET /mfa (展示输入框)POST /mfa (token+totpCode)verify(key, code)true/false签发正式JWTJWT登录成功,携带JWT错误,可重试认证失败/直接签发JWT
流程说明:
- 用户提交用户名和密码,Spring Security 进行基础认证。
- 若该用户已开启多因子,认证成功但不直接签发最终令牌,而是生成一个临时令牌(仅用于衔接二次认证),并在响应中指示需要 MFA。
- 前端接收到 MFA 要求后,跳转至验证码输入页面,用户填入 TOTP 应用上显示的 6 位数字。
- 二次认证过滤器拦截
/mfa请求,从临时令牌中解析出用户身份,取出该用户的 TOTP 密钥,调用TOTPUtils.verify进行校验。 - 校验成功后,颁发正式的访问令牌(如 JWT),并清除临时令牌;失败则返回错误,同时利用 Redis 记录重试次数,防止暴力破解。
核心代码
用户实体扩展
在用户表中增加 totp_secret 字段,存储 Base64 编码的密钥。同时增加 mfa_enabled 标识。
java
@Entity
public class User {
// ... 其他字段
private String totpSecret;
private boolean mfaEnabled;
// getter/setter
}
自定义 MFA 认证令牌与提供者
java
package com.example.mfa.security;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class MfaAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal; // 可能是临时令牌中的用户ID
private String totpCode;
public MfaAuthenticationToken(Object principal, String totpCode) {
super(null);
this.principal = principal;
this.totpCode = totpCode;
setAuthenticated(false);
}
public MfaAuthenticationToken(Object principal, String totpCode,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.totpCode = totpCode;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return totpCode;
}
@Override
public Object getPrincipal() {
return principal;
}
public String getTotpCode() {
return totpCode;
}
}
对应的 MfaAuthenticationProvider 实现核心校验逻辑:
java
package com.example.mfa.security;
import com.example.mfa.entity.User;
import com.example.mfa.repository.UserRepository;
import com.example.mfa.util.TOTPUtils;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.Collections;
public class MfaAuthenticationProvider implements AuthenticationProvider {
private final UserRepository userRepository;
public MfaAuthenticationProvider(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
MfaAuthenticationToken mfaToken = (MfaAuthenticationToken) authentication;
String userId = (String) mfaToken.getPrincipal();
String code = mfaToken.getTotpCode();
User user = userRepository.findById(userId)
.orElseThrow(() -> new BadCredentialsException("用户不存在"));
if (!user.isMfaEnabled() || user.getTotpSecret() == null) {
throw new BadCredentialsException("该用户未开启多因子认证");
}
if (!TOTPUtils.verify(user.getTotpSecret(), code)) {
throw new BadCredentialsException("TOTP 验证码错误");
}
return new MfaAuthenticationToken(userId, code,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
}
@Override
public boolean supports(Class<?> authentication) {
return MfaAuthenticationToken.class.isAssignableFrom(authentication);
}
}
过滤器链与 Spring Security 配置
java
package com.example.mfa.config;
import com.example.mfa.security.MfaAuthenticationFilter;
import com.example.mfa.security.MfaAuthenticationProvider;
import com.example.mfa.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserRepository userRepository;
public SecurityConfig(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManager authManager) throws Exception {
MfaAuthenticationFilter mfaFilter = new MfaAuthenticationFilter("/mfa");
mfaFilter.setAuthenticationManager(authManager);
http
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login", "/mfa").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(mfaFilter, UsernamePasswordAuthenticationFilter.class)
.authenticationProvider(mfaAuthenticationProvider());
return http.build();
}
@Bean
public MfaAuthenticationProvider mfaAuthenticationProvider() {
return new MfaAuthenticationProvider(userRepository);
}
@Bean
public AuthenticationManager authManager(HttpSecurity http) throws Exception {
return http.getSharedObject(AuthenticationManagerBuilder.class)
.build();
}
}
MfaAuthenticationFilter 类似 UsernamePasswordAuthenticationFilter,拦截 /mfa POST 请求,从请求中提取临时令牌和 TOTP 码,构建 MfaAuthenticationToken 并交给 AuthenticationManager 处理。
临时令牌生成与 Redis 缓存
首次登录成功后,生成一个临时令牌并存入 Redis,设置过期时间(如 5 分钟,与 TOTP 步长匹配),同时将用户 ID 与之关联。
java
// 临时令牌服务简化示例
public String createTempToken(String userId) {
String token = UUID.randomUUID().toString();
RBucket<String> bucket = redissonClient.getBucket("mfa:temp:" + token);
bucket.set(userId, 5, TimeUnit.MINUTES);
return token;
}
public String validateTempToken(String token) {
RBucket<String> bucket = redissonClient.getBucket("mfa:temp:" + token);
String userId = bucket.get();
if (userId != null) {
bucket.delete(); // 一次性使用
}
return userId;
}
前端交互参考
用户登录时,后端可能返回 { "status": "MFA_REQUIRED", "tempToken": "xxx" },前端据此跳转到 /mfa?token=xxx,展示输入框。提交时携带 tempToken 和 totpCode。
项目结构总览
dir
src/main/java/com/example/mfa/
├── config
│ └── SecurityConfig.java
├── entity
│ └── User.java
├── repository
│ └── UserRepository.java
├── security
│ ├── MfaAuthenticationFilter.java
│ ├── MfaAuthenticationProvider.java
│ └── MfaAuthenticationToken.java
├── service
│ └── TempTokenService.java
├── util
│ └── TOTPUtils.java
└── MfaApplication.java
运行与测试
- 启动嵌入式 Redis(测试环境自动加载)或外部 Redis。
- 在数据库中为用户开启 MFA,并调用
TOTPUtils.generateEncodedKey()生成密钥存入totp_secret。 - 用户可在 Google Authenticator 中手动输入该密钥(先通过 Base32 转换显示),或通过二维码导入。
- 登录流程如前所述,二次认证通过后获得 JWT 令牌。
总结
本文从多因子认证的核心概念出发,深入剖析了 TOTP 算法原理,并基于 Spring Security + OAuth2 给出了完整的实现方案。
通过自定义认证令牌、提供者与过滤器,我们将 TOTP 无缝嵌入到常规用户名密码认证之后,利用 Redis 管理临时令牌和重试计数,从而在不大幅改动原有安全框架的前提下,为企业应用增加了低成本、高安全性的第二因素认证。