Java框架快速入门: Spring Security+OAuth2之多因子认证与TOTP实战

纲要

  • 多因子认证概述
    • 双因子认证的常见形式:短信、邮件、软令牌、生物识别、位置等
    • 一次性密码的两个核心特性:一次性、时效性
  • TOTP 算法原理
    • TOTP 基于时间的一次性密码,时间步长与密码长度可配置
    • 密钥与用户绑定,保证同一时间不同用户生成的验证码不同
  • 技术栈与依赖
    • Spring Boot、Spring Security、OAuth2 授权服务器
    • aerogear-otp-java 提供 TOTP 算法支持
    • Redis (Redisson) 用于缓存验证码尝试状态,嵌入式 Redis 简化测试
  • 实现 TOTP 工具类
    • 密钥生成(KeyGenerator,HmacSHA1,512 位密钥)
    • 密钥的 Base64 序列化/反序列化
    • 根据密钥与当前时间生成 6 位动态口令
    • 验证口令:重新生成并与输入比对,利用时间窗口保证容错
  • Spring Security 集成多因子认证
    • 自定义 MfaAuthenticationTokenMfaAuthenticationProvider
    • 自定义过滤器 MfaAuthenticationFilter 拦截二次认证请求
    • 登录流程:用户名密码验证成功 → 颁发临时令牌 → 要求输入 TOTP 码 → 二次认证通过后签发正式 JWT
  • 项目结构展示
  • 完整可运行代码示例
    • pom.xml 依赖配置
    • TOTP 工具类 TOTPUtils
    • Spring Security 配置
    • 认证流程核心类
    • 测试控制器
  • 总结与相关度

多因子认证概述

在仅依赖用户名和密码的系统中,弱密码、重复密码等问题常常导致账户被盗。多因子认证(MFA,Multi-Factor Authentication)通过组合两种或以上不同维度的验证因素来大幅提升安全性。常见的第二因素包括:

  • 短信验证码
  • 邮件验证码
  • 基于时间的一次性密码(TOTP),如 Google Authenticator、Microsoft Authenticator
  • 生物特征(指纹、面部识别)
  • 地理位置或设备指纹

其中 TOTP 是一种无需依赖短信/邮件网关、零成本的软件令牌方案,非常适合企业应用或对成本敏感的系统。它生成的一串 6 位数字每 30 秒(或自定义步长)变化一次,具备两个关键特性:

  1. 一次性:验证成功后该口令立即失效,不可重用;
  2. 时效性:口令只在有限的时间窗口内有效,过期自动作废,且重试次数受控。

下面我们将从算法原理出发,在 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

流程说明:

  1. 用户提交用户名和密码,Spring Security 进行基础认证。
  2. 若该用户已开启多因子,认证成功但不直接签发最终令牌,而是生成一个临时令牌(仅用于衔接二次认证),并在响应中指示需要 MFA。
  3. 前端接收到 MFA 要求后,跳转至验证码输入页面,用户填入 TOTP 应用上显示的 6 位数字。
  4. 二次认证过滤器拦截 /mfa 请求,从临时令牌中解析出用户身份,取出该用户的 TOTP 密钥,调用 TOTPUtils.verify 进行校验。
  5. 校验成功后,颁发正式的访问令牌(如 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,展示输入框。提交时携带 tempTokentotpCode

项目结构总览

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

运行与测试

  1. 启动嵌入式 Redis(测试环境自动加载)或外部 Redis。
  2. 在数据库中为用户开启 MFA,并调用 TOTPUtils.generateEncodedKey() 生成密钥存入 totp_secret
  3. 用户可在 Google Authenticator 中手动输入该密钥(先通过 Base32 转换显示),或通过二维码导入。
  4. 登录流程如前所述,二次认证通过后获得 JWT 令牌。

总结

本文从多因子认证的核心概念出发,深入剖析了 TOTP 算法原理,并基于 Spring Security + OAuth2 给出了完整的实现方案。

通过自定义认证令牌、提供者与过滤器,我们将 TOTP 无缝嵌入到常规用户名密码认证之后,利用 Redis 管理临时令牌和重试计数,从而在不大幅改动原有安全框架的前提下,为企业应用增加了低成本、高安全性的第二因素认证。

相关推荐
滕州市燕猫虎计算机科技工作室个体工商户1 小时前
Java面试题汇总
java·开发语言
2301_32241428042 小时前
活力孕康复APP 47737- 原创(免费领源码+部署教程+开发环境)
java·vue.js·spring boot·mysql·微信小程序·idea·微信开发者工具
君顾12 小时前
外卖CPS软件开发实战:从系统架构到部署全流程指南
java·开发语言·外卖
vx_Biye_Design3 小时前
springboot游泳馆系统93765-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·spring·课程设计
名字还没想好☜3 小时前
Java NIO ByteBuffer 实战:flip/clear/compact 三个绕晕人的方法与 position/limit 心智模型
java·开发语言·后端·spring·nio
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之短信服务多供应商动态切换(阿里云与LeanCloud)
java·spring·阿里云
Doubbbbbbble云4 小时前
区间合并问题的常见算法模式与优化思路4
java·数据结构·算法
小溪学编程4 小时前
AQS 原理详解:从 CLH 队列到 ReentrantLock 的实现
java·开发语言
蜗牛互联网4 小时前
语音AI开始边听边说,改变的不只是响应速度
java·人工智能·后端·语音识别