Java框架快速入门: Spring Security+OAuth2之多因子认证逻辑与实体类改造

纲要

  • 多因子认证(MFA)核心概念

    • usingMFA:布尔字段,标识用户是否启用两步验证
    • MFAKey:每个用户独立的 TOTP 密钥
    • X-MFA-Delicate:自定义响应头,携带 MFA 标识及 requestId
    • 用户缓存:第一步认证成功后暂存用户信息,生成 requestId,用于第二步验证关联
  • 核心流程

    1. 用户名密码登录(第一步)
    2. 查询用户并校验密码
    3. 检查 usingMFA 字段
      • false:直接签发 JWT,登录完成
      • true:缓存用户信息,生成 requestId,返回 401 及自定义头 X-MFA-Delicate: MFA, requestId=xxx
    4. 前端接收后跳转至验证码输入页面,提交 requestId 与 TOTP 验证码
    5. 后端从缓存获取用户,取出 MFAKey 验证 TOTP
    6. 验证通过后签发 JWT,登录完成
  • 涉及的实体与代码改造

    • User 实体新增字段
    • UserService 增加密码匹配与认证逻辑
    • LoginController 改造登录接口,区分 MFA 与非 MFA 响应
    • UserCacheService 设计缓存接口(后续实现)

多因子认证流程概览

多因子认证(MFA)是在传统用户名密码基础上增加一层验证的安全机制。在已具备 TOTP 生成、验证以及邮件/短信发送能力后,需要将其组装成完整的两步认证流程。整体流程如下图所示:
服务端 客户端 服务端 客户端 #mermaid-svg-Fh1u8MzeBAdCgFXR{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-Fh1u8MzeBAdCgFXR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Fh1u8MzeBAdCgFXR .error-icon{fill:#552222;}#mermaid-svg-Fh1u8MzeBAdCgFXR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Fh1u8MzeBAdCgFXR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Fh1u8MzeBAdCgFXR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Fh1u8MzeBAdCgFXR .marker.cross{stroke:#333333;}#mermaid-svg-Fh1u8MzeBAdCgFXR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Fh1u8MzeBAdCgFXR p{margin:0;}#mermaid-svg-Fh1u8MzeBAdCgFXR .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Fh1u8MzeBAdCgFXR text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Fh1u8MzeBAdCgFXR .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Fh1u8MzeBAdCgFXR .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Fh1u8MzeBAdCgFXR #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Fh1u8MzeBAdCgFXR .sequenceNumber{fill:white;}#mermaid-svg-Fh1u8MzeBAdCgFXR #sequencenumber{fill:#333;}#mermaid-svg-Fh1u8MzeBAdCgFXR #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Fh1u8MzeBAdCgFXR .messageText{fill:#333;stroke:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Fh1u8MzeBAdCgFXR .labelText,#mermaid-svg-Fh1u8MzeBAdCgFXR .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .loopText,#mermaid-svg-Fh1u8MzeBAdCgFXR .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .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-Fh1u8MzeBAdCgFXR .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Fh1u8MzeBAdCgFXR .noteText,#mermaid-svg-Fh1u8MzeBAdCgFXR .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Fh1u8MzeBAdCgFXR .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Fh1u8MzeBAdCgFXR .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Fh1u8MzeBAdCgFXR .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Fh1u8MzeBAdCgFXR .actorPopupMenu{position:absolute;}#mermaid-svg-Fh1u8MzeBAdCgFXR .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-Fh1u8MzeBAdCgFXR .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Fh1u8MzeBAdCgFXR .actor-man circle,#mermaid-svg-Fh1u8MzeBAdCgFXR line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Fh1u8MzeBAdCgFXR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt验证成功验证失败 altusingMFA = falseusingMFA = true POST /login (username, password)校验用户名密码200 OK (JWT Token)缓存用户信息,生成 requestId401 UnauthorizedX-MFA-Delicate: MFA, requestId=xxx跳转验证码页面POST /mfa/verify (requestId, code)从缓存获取用户,取出 MFAKey验证 TOTP200 OK (JWT Token)401 验证码错误

实体类改造

为了支持按用户启用 MFA,User 实体需要增加两个字段:usingMfa(是否启用两步验证)和 mfaKey(TOTP 密钥)。同时注意以下几点:

  • 使用 @Column 精确映射数据库字段,usingMfa 默认值为 false
  • mfaKey 使用 @JsonIgnore 避免在 JSON 序列化时泄露
  • 使用 Lombok @Builder 时需为 usingMfa 设置默认值

示例

java 复制代码
package com.example.demo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private boolean enabled;

    @Column(name = "using_mfa", nullable = false)
    @Builder.Default
    private boolean usingMfa = false;

    @Column(name = "mfa_key")
    @JsonIgnore
    private String mfaKey;
}

数据库对应的表结构(示例 SQL):

sql 复制代码
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    password VARCHAR(200) NOT NULL,
    enabled BOOLEAN NOT NULL DEFAULT TRUE,
    using_mfa BOOLEAN NOT NULL DEFAULT FALSE,
    mfa_key VARCHAR(100)
);

用户注册时生成 MFA Key

在用户注册流程中,若需为用户预先生成 TOTP 密钥,可通过注入 TOTP 工具类完成。以下示例展示如何在 UserService 的注册方法中自动生成并存储密钥:

java 复制代码
@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final TOTP totp; // 假设已实现的TOTP工具类

    public UserService(UserRepository userRepository,
                       PasswordEncoder passwordEncoder,
                       TOTP totp) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
        this.totp = totp;
    }

    public User register(String username, String rawPassword) {
        String mfaKey = totp.generateKey(); // 生成密钥并转为String存储
        User user = User.builder()
                .username(username)
                .password(passwordEncoder.encode(rawPassword))
                .enabled(true)
                .usingMfa(false) // 默认不启用,可由用户后续自行开启
                .mfaKey(mfaKey)
                .build();
        return userRepository.save(user);
    }
}

登录逻辑改造

原有登录接口直接返回 JWT,引入 MFA 后需根据用户 usingMfa 字段决定响应方式。以下是改造后的控制器核心逻辑。

首先,为 UserService 添加一个按用户名和原始密码进行认证的方法,返回 Optional<User>

java 复制代码
@Service
public class UserService {
    // 其他注入...

    public Optional<User> authenticate(String username, String rawPassword) {
        return userRepository.findByUsername(username)
                .filter(user -> passwordEncoder.matches(rawPassword, user.getPassword()))
                .filter(user -> user.isEnabled()); // 可加入更多账户状态检查
    }
}

UserRepository 需提供 findByUsername 方法:

java 复制代码
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

然后,在登录控制器中,根据 usingMfa 分流处理:

java 复制代码
@RestController
public class AuthController {

    private final UserService userService;
    private final UserCacheService userCacheService;

    public AuthController(UserService userService, UserCacheService userCacheService) {
        this.userService = userService;
        this.userCacheService = userCacheService;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        Optional<User> userOpt = userService.authenticate(request.getUsername(), request.getPassword());

        if (userOpt.isEmpty()) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("用户名或密码错误");
        }

        User user = userOpt.get();

        if (!user.isUsingMfa()) {
            // 未启用MFA,直接返回JWT
            String token = generateToken(user); // 自行实现JWT生成
            return ResponseEntity.ok(new AuthResponse(token));
        }

        // 启用MFA:缓存用户信息并返回特殊响应
        String requestId = userCacheService.cacheUser(user);
        HttpHeaders headers = new HttpHeaders();
        headers.add("X-MFA-Delicate", "MFA, requestId=" + requestId);

        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .headers(headers)
                .body("需要两步验证");
    }

    private String generateToken(User user) {
        // 实际使用JWT工具生成,此处省略
        return "jwt-token-for-" + user.getUsername();
    }
}

请求体与响应体可定义为简单的 DTO:

java 复制代码
// LoginRequest.java
@Data
public class LoginRequest {
    private String username;
    private String password;
}

// AuthResponse.java
@Data
@AllArgsConstructor
public class AuthResponse {
    private String token;
}

第二步验证接口

前端收到 401X-MFA-Delicate 头后,引导用户输入 TOTP 验证码,然后调用第二步验证接口。该接口从缓存中取出用户,使用其 mfaKey 进行 TOTP 校验,成功后返回 JWT。

java 复制代码
@PostMapping("/mfa/verify")
public ResponseEntity<?> verifyMfa(@RequestBody MfaVerifyRequest request) {
    Optional<User> userOpt = userCacheService.getUser(request.getRequestId());
    if (userOpt.isEmpty()) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("会话已过期,请重新登录");
    }

    User user = userOpt.get();
    // 假设TOTP验证工具类已注入
    boolean isValid = totp.verify(user.getMfaKey(), request.getCode());
    if (!isValid) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("验证码错误");
    }

    // 验证成功,清除缓存,返回JWT
    userCacheService.removeUser(request.getRequestId());
    String token = generateToken(user);
    return ResponseEntity.ok(new AuthResponse(token));
}

// MfaVerifyRequest.java
@Data
public class MfaVerifyRequest {
    private String requestId;
    private String code;
}

用户缓存服务设计

UserCacheService 负责暂存第一步认证成功的用户信息,并生成唯一 requestId。这里使用内存 ConcurrentHashMap 实现一个简单版本,实际生产环境应使用 Redis 并设置过期时间。

java 复制代码
@Service
public class UserCacheService {

    private final Map<String, User> cache = new ConcurrentHashMap<>();

    public String cacheUser(User user) {
        String requestId = UUID.randomUUID().toString();
        cache.put(requestId, user);
        return requestId;
    }

    public Optional<User> getUser(String requestId) {
        return Optional.ofNullable(cache.get(requestId));
    }

    public void removeUser(String requestId) {
        cache.remove(requestId);
    }
}

项目结构概览

改造涉及的文件与层级关系:

dir 复制代码
src/main/java/com/example/demo/
├── entity
│   └── User.java
├── repository
│   └── UserRepository.java
├── service
│   ├── UserService.java
│   └── UserCacheService.java
├── controller
│   └── AuthController.java
├── dto
│   ├── LoginRequest.java
│   ├── AuthResponse.java
│   └── MfaVerifyRequest.java
└── DemoApplication.java

安全考量与扩展

  • 密码存储 :始终使用 PasswordEncoder(如 BCrypt)进行哈希处理
  • 缓存安全requestId 应具有时效性,建议使用 Redis 并设置 3‑5 分钟过期
  • 自定义响应头X-MFA-Delicate 虽非标准头,但可被前端识别并用于流程控制,避免将认证状态放在普通响应体中而引发歧义
  • 灵活启用 MFAusingMfa 字段赋予业务极大的灵活性,可按角色、风险等级动态调整,例如异地登录时强制要求两步验证

以上内容完整展示了基于 Spring Security 与 OAuth2 生态下,多因子认证逻辑的实体改造、认证分流及缓存配合方案。

后续可进一步集成 Spring Security 的过滤器链,将 MFA 校验作为额外认证提供者,实现更平滑的鉴权集成。

相关推荐
ashiho1 小时前
【Spring】RequestParam/PathVariable/RequestBody详解
java·后端·spring·servlet·springmvc
传奇开心果编程1 小时前
【Rust入门知识点学与练】第26课:Unsafe Rust
开发语言·学习·rust
bug总结1 小时前
uniapp总结
开发语言·前端·javascript
zyeyeye1 小时前
C++入门:从HelloWorld到命名空间揭秘
c语言·开发语言·c++·算法
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从 addHistory 到 RS_UndoCycle:LibreCAD 里的两套 History 与撤销机制
开发语言·c++·qt·学习·系统架构
Escalating_xu1 小时前
【mmap 进程间通信】从共享内存到跨进程唤醒:用互斥锁、条件变量控制多个进程
java·linux·开发语言·jvm
走到天涯海角1 小时前
pinia和Vuex的区别以及使用pinia
开发语言·前端·javascript
泡海椒1 小时前
脚本包导入实战:JQuick-Java自定义包引入与别名配置教程
java·开发语言·python
隐退山林1 小时前
JavaEE进阶:SpringBoot统一功能处理
java·spring boot·后端