纲要
- 多因子认证(MFA)与TOTP核心流程
- 为何需要缓存用户临时状态
- 技术选型:
Redis+Redisson客户端 - 核心实现:
UserCacheService- 生成随机MFA标识并存储用户信息
- 设置基于TOTP时间步长的过期时间
- 按MFA ID获取缓存用户
- 校验TOTP码并清理缓存
- 嵌入式
Redis测试环境 - 代码结构与可运行示例
- 启动与验证
背景与问题
在实现基于时间的一次性密码(TOTP)的多因子认证时,用户初次登录后需要输入六位验证码。服务端必须在两次请求之间记住"正在等待MFA验证"的用户信息,且该信息只是临时有效(通常几分钟)。
直接使用数据库会增加无效IO且可能遗留脏数据,引入带有时效的缓存是最佳选择。
本文基于Spring Boot + Spring Security环境,使用Redis作为缓存中间件,Redisson作为访问客户端,封装一个轻量级的UserCacheService,完整展示多因子认证中的缓存逻辑。
依赖与环境
核心依赖在pom.xml中如下所示:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.5</version>
</dependency>
<!-- 嵌入式Redis,仅用于测试 -->
<dependency>
<groupId>it.ozimov</groupId>
<artifactId>embedded-redis</artifactId>
<version>0.7.3</version>
<scope>test</scope>
</dependency>
Redisson负责与Redis交互,嵌入式Redis可以让你在本地测试时无需单独启动Redis服务。
常量定义
为缓存区域命名并统一管理,在常量接口中定义一个字符串常量:
java
package com.example.security.constant;
public interface Constants {
String USER_MFA_CACHE = "userMfaCache";
}
这个名称将作为Redis中Map结构的标识。
工具类:随机MFA ID生成
当用户进入MFA验证阶段时,服务端生成一个唯一随机串作为Key,与用户绑定存入Redis。工具类如下:
java
package com.example.security.util;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
@Component
public class RandomUtil {
private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
private final SecureRandom random = new SecureRandom();
public String generateRandomString(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(CHARS.charAt(random.nextInt(CHARS.length())));
}
return sb.toString();
}
}
这里生成12位字母数字混合串,可根据安全需求调整长度。
核心服务:UserCacheService
该Service负责:
- 缓存待验证的用户(
cacheUser),返回MFA ID。 - 根据MFA ID取出用户(
getUser)。 - 校验TOTP码,并清除缓存(
verifyTotp),返回验证成功的用户。
完整实现如下:
java
package com.example.security.service;
import com.example.security.constant.Constants;
import com.example.security.model.User;
import com.example.security.util.RandomUtil;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RMapCache;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class UserCacheService {
private final RedissonClient redissonClient;
private final RandomUtil randomUtil;
/**
* 缓存用户,返回MFA ID
* @param user 待验证用户
* @return 生成的MFA ID
*/
public String cacheUser(User user) {
RMapCache<String, User> cache = redissonClient.getMapCache(Constants.USER_MFA_CACHE);
String mfaId = randomUtil.generateRandomString(12);
// 如果缓存中不存在该ID,则放入
if (!cache.containsKey(mfaId)) {
int ttlInSeconds = TotpUtil.DEFAULT_TIME_STEP_SECONDS; // 例如300秒
cache.put(mfaId, user, ttlInSeconds, TimeUnit.SECONDS);
}
return mfaId;
}
/**
* 根据MFA ID获取缓存的用户
* @param mfaId 前端带回的MFA ID
* @return Optional包装的用户
*/
public Optional<User> getUser(String mfaId) {
RMapCache<String, User> cache = redissonClient.getMapCache(Constants.USER_MFA_CACHE);
if (cache.containsKey(mfaId)) {
return Optional.ofNullable(cache.get(mfaId));
}
return Optional.empty();
}
/**
* 验证TOTP码,成功则删除缓存并返回用户
* @param mfaId MFA ID
* @param code 用户提交的验证码
* @return 验证成功返回用户,否则返回Optional.empty()
*/
public Optional<User> verifyTotp(String mfaId, String code) {
RMapCache<String, User> cache = redissonClient.getMapCache(Constants.USER_MFA_CACHE);
// 先检查缓存是否存在
if (!cache.containsKey(mfaId)) {
return Optional.empty();
}
User cachedUser = cache.get(mfaId);
if (cachedUser == null) {
return Optional.empty();
}
try {
byte[] secretKey = Base64.getDecoder().decode(cachedUser.getMfaSecret());
if (TotpUtil.verifyTotp(secretKey, code)) {
// 验证成功,移除缓存
cache.remove(mfaId);
return Optional.of(cachedUser);
}
} catch (Exception e) {
// 密钥无效等异常,视为验证失败
}
return Optional.empty();
}
}
要点说明:
RMapCache提供了带TTL的Map结构,put(key, value, ttl, timeUnit)会在TTL到期后自动清除。- TTL取自由
TotpUtil定义的DEFAULT_TIME_STEP_SECONDS(通常300秒即5分钟),与TOTP窗口保持一致。 verifyTotp中解码用户的MFA密钥(Base64存储),调用TotpUtil.verifyTotp验证动态码,成功后移除缓存,保证一次性使用。
TOTP工具参考
为完整性,给出TotpUtil中的关键常量与方法签名:
java
package com.example.security.util;
public class TotpUtil {
public static final int DEFAULT_TIME_STEP_SECONDS = 300; // 5分钟
public static boolean verifyTotp(byte[] secret, String code) {
// 使用TOTP算法校验,此处省略具体实现,可使用Google Auth库
}
}
用户实体示例
java
package com.example.security.model;
import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String password;
private boolean usingMfa;
private String mfaSecret; // Base64编码的TOTP密钥
}
集成到认证流程
下面是一个简化的Controller展示如何调用:
java
package com.example.security.controller;
import com.example.security.model.User;
import com.example.security.service.UserCacheService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserCacheService userCacheService;
/**
* 第一因子认证通过后,生成MFA ID返回给前端
*/
@PostMapping("/mfa/init")
public ResponseEntity<?> initMfa(@RequestBody User authenticatedUser) {
String mfaId = userCacheService.cacheUser(authenticatedUser);
return ResponseEntity.ok(Map.of("mfaId", mfaId));
}
/**
* 前端提交MFA ID和验证码完成验证
*/
@PostMapping("/mfa/verify")
public ResponseEntity<?> verifyMfa(@RequestParam String mfaId,
@RequestParam String code) {
Optional<User> verifiedUser = userCacheService.verifyTotp(mfaId, code);
if (verifiedUser.isPresent()) {
// 签发JWT或创建SecurityContext
return ResponseEntity.ok(Map.of("token", "generated_jwt_token"));
}
return ResponseEntity.status(401).body("MFA verification failed");
}
}
启动与验证
1. 启动Redis
在开发环境可使用Docker快速启动:
bash
docker run -d --name redis -p 6379:6379 redis:7-alpine
2. 配置application.yml
yaml
spring:
redis:
host: localhost
port: 6379
# Redisson 自动配置,无需额外配置
3. 验证接口
向/auth/mfa/init发送第一因子认证通过的用户JSON,得到mfaId,随后调用/auth/mfa/verify?mfaId=xxx&code=123456完成MFA验证。
项目代码结构
dir
src/main/java/com/example/security/
├── constant/
│ └── Constants.java
├── controller/
│ └── AuthController.java
├── model/
│ └── User.java
├── service/
│ └── UserCacheService.java
└── util/
├── RandomUtil.java
└── TotpUtil.java
总结
本文介绍了在 Spring Security + OAuth2 环境下实现 TOTP 多因子认证时,通过 Redis 缓存临时用户状态的完整方案。
核心思路是:用户通过第一因子认证后,服务端生成随机 MFA ID,将用户对象以该 ID 为键存入带 TTL 的 Redis Map 缓存(TTL 与 TOTP 时间步长一致)。验证时前端回传 MFA ID 与动态码,服务端取出用户、校验 TOTP 并立即删除缓存,确保一次有效。
采用 Redisson 的 RMapCache 简化过期控制,结合嵌入式 Redis 完成测试,最终将缓存服务融入认证 Controller,形成可运行的轻量级多因子认证缓存层。