Java框架快速入门: Spring Security+OAuth2之多因子认证缓存服务

纲要

  • 多因子认证(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负责:

  1. 缓存待验证的用户(cacheUser),返回MFA ID。
  2. 根据MFA ID取出用户(getUser)。
  3. 校验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,形成可运行的轻量级多因子认证缓存层。

相关推荐
Elastic 中国社区官方博客18 分钟前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn18830 分钟前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn1 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su1 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
我命由我123453 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
苜蓿muxu3 小时前
Java 字符串拼接:+、StringBuilder、StringBuffer的区别
java
摇滚侠3 小时前
《Java并发编程之美》第 4 章 Java 并发包中原子操作类原理剖析 阅读笔记 7
java·笔记·python
旋律4144 小时前
同义方法区别(增加类与删除类)
java·后端
学到老活到老4 小时前
方法执行完了,对象为什么还占着内存?
java·jvm