纲要
-
多因子认证(MFA)核心概念
usingMFA:布尔字段,标识用户是否启用两步验证MFAKey:每个用户独立的 TOTP 密钥X-MFA-Delicate:自定义响应头,携带MFA标识及requestId- 用户缓存:第一步认证成功后暂存用户信息,生成
requestId,用于第二步验证关联
-
核心流程
- 用户名密码登录(第一步)
- 查询用户并校验密码
- 检查
usingMFA字段false:直接签发JWT,登录完成true:缓存用户信息,生成requestId,返回401及自定义头X-MFA-Delicate: MFA, requestId=xxx
- 前端接收后跳转至验证码输入页面,提交
requestId与 TOTP 验证码 - 后端从缓存获取用户,取出
MFAKey验证 TOTP - 验证通过后签发
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;
}
第二步验证接口
前端收到 401 和 X-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虽非标准头,但可被前端识别并用于流程控制,避免将认证状态放在普通响应体中而引发歧义 - 灵活启用 MFA :
usingMfa字段赋予业务极大的灵活性,可按角色、风险等级动态调整,例如异地登录时强制要求两步验证
以上内容完整展示了基于 Spring Security 与 OAuth2 生态下,多因子认证逻辑的实体改造、认证分流及缓存配合方案。
后续可进一步集成 Spring Security 的过滤器链,将 MFA 校验作为额外认证提供者,实现更平滑的鉴权集成。