最新Spring Security实战教程(十七)企业级安全方案设计 - 多因素认证(MFA)实现
回顾链接:
最新Spring Security实战教程(一)初识Spring Security安全框架
最新Spring Security实战教程(二)表单登录定制到处理逻辑的深度改造
最新Spring Security实战教程(三)Spring Security 的底层原理解析
最新Spring Security实战教程(四)基于内存的用户认证
最新Spring Security实战教程(五)基于数据库的动态用户认证传统RBAC角色模型实战开发
最新Spring Security实战教程(六)最新Spring Security实战教程(六)基于数据库的ABAC属性权限模型实战开发
最新Spring Security实战教程(七)方法级安全控制@PreAuthorize注解的灵活运用
最新Spring Security实战教程(八)Remember-Me实现原理 - 持久化令牌与安全存储方案
最新Spring Security实战教程(九)前后端分离认证实战 - JWT+SpringSecurity无缝整合
最新Spring Security实战教程(十)权限表达式进阶 - 在SpEL在安全控制中的高阶魔法
最新Spring Security实战教程(十一)CSRF攻防实战 - 从原理到防护的最佳实践
最新Spring Security实战教程(十二)CORS安全配置 - 跨域请求的安全边界设定
最新Spring Security实战教程(十三)会话管理机制 - 并发控制与会话固定攻击防护
最新Spring Security实战教程(十四)OAuth2.0精讲 - 四种授权模式与资源服务器搭建
最新Spring Security实战教程(十五)快速集成 GitHub 与 Gitee 的社交登录
最新Spring Security实战教程(十六)微服务间安全通信 - JWT令牌传递与校验机制
在微服务与分布式架构日益普及的今天,传统的 单一凭证(用户名+密码) 已经难以满足企业对于身份验证的高安全性需求。多因素认证(Multi‐Factor Authentication,简称 MFA) 通过用户知道的东西
(如密码)+ 用户拥有的东西
(如动态验证码)或 用户自身的一部分
(如指纹)三种因素的组合,大幅提升了系统防护能力。
比如我们常的 GitHub
、腾讯云
等就开启了MFA
,GitHub
开启 MFA后可以使用 使用Authenticator
应用扫描,而腾讯云则需要短信验证码来进行校验。
本章节博主将带着大家深入解析MFA
,并基于 Spring Security 6
,结合 MySQL 与 MyBatis-Plus,带你从理论到实战,快速构建一套企业级的 MFA 认证方案。
多因素认证(MFA)通过多种不同类别的凭证 来共同完成身份验证,显著提升安全性:
当密码被破解或泄露后,如果没有第二因素(如手机动态验证码),攻击者依然无法登录。
认证方式
安全性
用户体验
实施成本
SMS验证码
★★☆
★★★
★★☆
邮件验证
★★☆
★★☆
★★☆
TOTP
★★★
★★★☆
★★★
生物识别
★★★☆
★★★★
★★★★
本方案选择TOTP:平衡安全性与实施成本,兼容Google Authenticator
等标准应用
以 TOTP(Time‐based One‐Time Password)
为例:
Secret Key
),并在用户首次登录或在安全设置中心将其展示给用户(通常通过二维码形式扫描到 Google Authenticator
、Authy
等应用中)Google Authenticator
)基于 Secret Key
与当前时间戳,通过 HMAC‐SHA1
算法计算出 6 位动态验证码整个流程中,只有用户掌握 Secret Key(存在手机应用中),且需实时生成动态验证码,即使攻击者获得了用户名+密码,没有手机和 Secret Key,也无法通过第二因素验证。
本章节以单体 Spring Boot
应用演示 MFA
流程,生产环境可拆分成独立的认证服务(Auth Service
)与业务服务(Resource Service
),二者均依赖集中管理的用户与 MFA 数据库。关键流程:
后台管理员或用户注册时,系统为用户生成一对 RSA 密钥(可选)或仅生成 TOTP Secret,保存用户表中。
将生成的 Secret 以二维码或明文形式呈现给用户,用户通过 Google Authenticator
等扫描或手动录入。
用户提交用户名+密码,Service 层校验密码(结合 BCrypt)。
校验成功后,将用户标记为“已通过第一步认证”,并生成一个短期令牌(可存放到 session 或 JWT)表示“待 MFA”状态,重定向到 MFA 验证页。
用户在 MFA 验证页中输入 6 位动态验证码,提交后,后台从数据库中取出该用户的 Secret,通过 TOTP 算法生成当前时刻的合法验证码,进行比对。
若校验通过,则完成整个登录流程,Spring Security
将真正的 Authentication
对象置入 SecurityContext
中,登录成功,跳转到首页;否则,提示错误并重试。
根据前面的章节我们已经整合好了 mysql + mybatis等的项目案例,我们继续追加子模块,引入Google Authenticator
兼容 TOTP
实现:com.warrenstrange:googleauth:1.5.0
下面以 pom.xml
为例,列出主要依赖:
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-thymeleaf
org.springframework.boot
spring-boot-starter-security
com.baomidou
mybatis-plus-boot-starter
3.5.3.5
mysql
mysql-connector-java
runtime
com.warrenstrange
googleauth
1.5.0
org.projectlombok
lombok
provided
org.springframework.boot
spring-boot-starter-test
test
@Data
@TableName("users")
public class User {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private Boolean enabled;
private Boolean mfaEnabled;
private String mfaSecret;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
使用 Lombok @Data 简化 getter/setter
mfaEnabled 与 mfaSecret 字段分别表示该用户是否启用 MFA 及其对应的 TOTP 密钥
@Mapper
public interface UserMapper extends BaseMapper {
// 如果需要自定义 SQL,可在此处声明
}
我们将使用 com.warrenstrange.googleauth.GoogleAuthenticator
来生成并验证动态验证码(TOTP)
public class TotpUtils {
private static final GoogleAuthenticator gAuth = new GoogleAuthenticator();
/**
* 为用户生成一个新的 TOTP 密钥(Base32 编码格式)
*
* @return Base32 编码的密钥
*/
public static String generateSecretKey() {
GoogleAuthenticatorKey key = gAuth.createCredentials();
return key.getKey();
}
/**
* 验证用户提交的 TOTP 码是否合法(基于用户的 Secret Key)
*
* @param secretKey Base32 编码的 TOTP 密钥
* @param code 用户提交的 6 位验证码
* @return true 如果校验通过;false 否则
*/
public static boolean verifyTotp(String secretKey, int code) {
return gAuth.authorize(secretKey, code);
}
/**
* 将 Base32 编码的密钥转换为 Hex,若业务需要展示给前端 URI 可用该方法
*/
public static String getHexKey(String base32Secret) {
Base32 codec = new Base32();
byte[] bytes = codec.decode(base32Secret);
return Hex.encodeHexString(bytes);
}
/**
* 生成在 Google Authenticator 中添加账户的二维码 URI
*
* @param username 用户名
* @param secret Base32 编码密钥
* @param issuer 应用或企业名称,比如 "MyCompany"
* @return otpauth://totp/issuer:username?secret=SECRET&issuer=issuer
*/
public static String getOtpAuthURL(String username, String secret, String issuer) {
return String.format(
"otpauth://totp/%s:%s?secret=%s&issuer=%s",
issuer, username, secret, issuer
);
}
}
说明:
generateSecretKey()
:生成一个新的 Base32 格式秘钥,用于 TOTP 绑定。verifyTotp(secretKey, code)
:校验用户提交的 6 位 TOTP 码是否与当前时刻计算值匹配。getOtpAuthURL(...)
:方便在前端生成二维码,让用户用 Google Authenticator 扫描。
我们封装用户管理与 MFA 相关的业务逻辑到 UserService
IUserService.java(接口)
public interface IUserService {
User findByUsername(String username);
void register(User user);
void enableMfa(Long userId);
boolean verifyTotp(Long userId, int code);
}
UserServiceImpl.java(实现)
@Service
public class UserServiceImpl implements IUserService {
@Autowired
private UserMapper userMapper;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
public User findByUsername(String username) {
return userMapper.selectOne(new QueryWrapper().eq("username", username));
}
@Override
public void register(User user) {
// 加密密码
user.setPassword(passwordEncoder.encode(user.getPassword()));
user.setEnabled(true);
user.setMfaEnabled(false);
user.setMfaSecret(null);
userMapper.insert(user);
}
@Override
public void enableMfa(Long userId) {
// 为用户生成 TOTP Secret 并更新
User u = userMapper.selectById(userId);
String secret = TotpUtils.generateSecretKey();
u.setMfaSecret(secret);
u.setMfaEnabled(true);
userMapper.updateById(u);
}
@Override
public boolean verifyTotp(Long userId, int code) {
User u = userMapper.selectById(userId);
if (u == null || !u.getMfaEnabled() || u.getMfaSecret() == null) {
return false;
}
return TotpUtils.verifyTotp(u.getMfaSecret(), code);
}
}
说明:
register(User)
:用户注册时将密码加密存库,初始不启用 MFA。enableMfa(Long)
:为指定用户生成 TOTP Secret,更新到数据库,并将mfaEnabled
标记为true
。verifyTotp(Long, int)
:验证用户提交的 TOTP 码是否正确。
Spring Security 6
中,我们需要覆盖默认的认证流程,实现分为两步的 MFA 登录。思路如下:
AuthenticationProvider
:首先校验用户名+密码,如果用户启用了 MFA,就抛出一个自定义异常(MfaRequiredException
),在 AuthenticationFailureHandler
中捕获并重定向到 MFA 验证页。MfaAuthenticationFilter
,从 session 中读取“待 MFA”状态的用户信息,再调用 Service 校验 TOTP。如果通过,则直接构建最终的 UsernamePasswordAuthenticationToken
并置入 SecurityContext。public class MfaRequiredException extends AuthenticationException {
private final String username;
public MfaRequiredException(String msg, String username) {
super(msg);
this.username = username;
}
public String getUsername() {
return username;
}
}
/**
* 第一步:校验用户名 + 密码
* 如果用户启用 MFA,则抛出 MfaRequiredException,后续由 MfaAuthenticationFilter 处理
*/
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private IUserService userService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
User user = userService.findByUsername(username);
if (user == null || !user.getEnabled()) {
throw new BadCredentialsException("用户名或密码错误");
}
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new BadCredentialsException("用户名或密码错误");
}
// 如果用户启用了 MFA,则抛出自定义异常,提示进行第二步验证
if (Boolean.TRUE.equals(user.getMfaEnabled())) {
throw new MfaRequiredException("MFA 验证必需", username);
}
// 未启用 MFA 或继承走这里,直接构建 Authentication
return new UsernamePasswordAuthenticationToken(
username, null,
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
);
}
@Override
public boolean supports(Class> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
说明:
- 如果用户开启
mfaEnabled
,校验密码后不直接登录,而是通过抛出异常告知后续过滤器进行 MFA 验证。
/**
* 该过滤器负责处理 /mfa-verify POST 请求,
* 从 session 中获取待验证用户名,校验用户提交的 TOTP 码。
*/
@Component
public class MfaAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final IUserService userService;
public MfaAuthenticationFilter(IUserService userService) {
super(new AntPathRequestMatcher("/mfa-verify", "POST"));
this.userService = userService;
// 不让 Spring Security 为我们阻止 CSRF,示例中 CSRF 已关闭
}
@Override
public org.springframework.security.core.Authentication attemptAuthentication(
HttpServletRequest request,
HttpServletResponse response
) throws IOException, ServletException {
// 应用前端将用户名暂存到 sessionAttribute: "MFA_USER"
String username = (String) request.getSession().getAttribute("MFA_USER");
if (username == null) {
throw new RuntimeException("会话中找不到待 MFA 用户");
}
// 获取用户提交的 TOTP 码
String codeStr = request.getParameter("code");
if (codeStr == null || codeStr.isEmpty()) {
throw new RuntimeException("TOTP 码不能为空");
}
int code;
try {
code = Integer.parseInt(codeStr);
} catch (NumberFormatException e) {
throw new RuntimeException("TOTP 码格式不正确");
}
// 从数据库校验 TOTP
User user = userService.findByUsername(username);
boolean valid = userService.verifyTotp(user.getId(), code);
if (!valid) {
throw new RuntimeException("TOTP 验证失败");
}
// 验证成功,构建真正的 Authentication 对象
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
username, null, Collections.singletonList(() -> "ROLE_USER")
);
return auth;
}
@Override
protected void successfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
org.springframework.security.core.Authentication authResult
) throws IOException, ServletException {
// 将最终的 Authentication 填入 SecurityContext
SecurityContextHolder.getContext().setAuthentication(authResult);
// 登录成功后清除 session 中的 MFA 用户标志
request.getSession().removeAttribute("MFA_USER");
// 跳转到首页
response.sendRedirect("/");
}
@Override
protected void unsuccessfulAuthentication(
HttpServletRequest request,
HttpServletResponse response,
org.springframework.security.core.AuthenticationException failed
) throws IOException, ServletException {
// 验证失败,跳回 MFA 验证页面
response.setContentType(MediaType.TEXT_PLAIN_VALUE);
response.getWriter().write("MFA 验证失败:" + failed.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
说明:
- 该过滤器拦截
POST /mfa-verify
请求,读取 session 中预先放置的 “MFA_USER” 用户名,以及前端提交的code
。- 调用
userService.verifyTotp(...)
校验动态验证码,若通过则构建最终的Authentication
。
/**
* 处理第一步用户名/密码登录失败或触发 MFA 的情况
*/
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException, ServletException {
// 如果是 MfaRequiredException,重定向到 /mfa 页面,并将用户名存入 session
if (exception instanceof MfaRequiredException) {
String username = ((MfaRequiredException) exception).getUsername();
request.getSession().setAttribute("MFA_USER", username);
// 重定向到 MFA 验证页面
response.sendRedirect("/mfa");
} else {
// 普通登录失败,重定向回 /login?error
response.sendRedirect("/login?error=true");
}
}
}
说明:
- 当
CustomAuthenticationProvider
抛出MfaRequiredException
时,说明用户通过密码校验但需要第二步 MFA,此时将“待 MFA”用户名写入 session,并重定向到 MFA 验证页面/mfa
。- 普通失败(如密码错误)则带上
?error=true
重定向回登录页。
/**
* 核心安全配置:
* 1. 注入自定义 AuthenticationProvider
* 2. 配置表单登录和 MfaAuthenticationFilter
*/
@Configuration
public class SecurityConfig {
@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;
@Autowired
private MfaAuthenticationFilter mfaAuthenticationFilter;
@Autowired
private CustomAuthenticationFailureHandler customFailureHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfiguration authConfig) throws Exception {
// 禁用 CSRF 简化示例
http.csrf(csrf -> csrf.disable());
// 使用自定义 AuthenticationProvider 替换默认的 DaoAuthenticationProvider
http.authenticationProvider(customAuthenticationProvider);
// 1. 首先,配置表单登录
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login") // 与表单提交 action 保持一致
.failureHandler(customFailureHandler)
.defaultSuccessUrl("/", true)
);
// 2. 注册 MFA 过滤器,它要在 UsernamePasswordAuthenticationFilter 之后执行
http.addFilterAfter(mfaAuthenticationFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class);
// 3. Session 管理:MFA 过程中会话保持
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
);
// 4. 未授权时返回 401
http.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) ->
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))
);
return http.build();
}
// 若需手动获取 AuthenticationManager,可使用以下 Bean
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
关键点:
- 将
CustomAuthenticationProvider
注册到 Spring Security,替代默认的用户名/密码校验逻辑。- 配置表单登录,登录失败由
CustomAuthenticationFailureHandler
处理。- 注册
MfaAuthenticationFilter
,拦截/mfa-verify
提交。- SessionPersist 保持“待 MFA”状态直到第二步完成。
我们需要提供几个页面和对应的 Controller:
/login
:自定义登录页面(用户名+密码)
/register
:用户注册页面
/mfa
:MFA 验证页面,用户输入 6 位 TOTP 码
/mfa-verify
:MFA 验证提交接口,由 MfaAuthenticationFilter
处理
/enable-mfa
:在用户登录后打开此接口可为用户生成 TOTP Secret,并展示二维码
@Controller
public class AuthController {
@Autowired
private IUserService userService;
/**
* 登录页(第一步:用户名 + 密码)
*/
@GetMapping("/login")
public String loginPage(@RequestParam(required = false) String error, Model model) {
model.addAttribute("error", error != null);
return "login";
}
/**
* 注册页(仅示例)
*/
@GetMapping("/register")
public String registerPage() {
return "register";
}
@PostMapping("/register")
public String doRegister(@RequestParam String username, @RequestParam String password) {
User u = new User();
u.setUsername(username);
u.setPassword(password);
userService.register(u);
return "redirect:/login";
}
/**
* MFA 验证页:用户输入动态验证码
*/
@GetMapping("/mfa")
public String mfaPage(HttpSession session, Model model) {
String username = (String) session.getAttribute("MFA_USER");
if (username == null) {
// 无待验证用户,跳到登录页
return "redirect:/login";
}
model.addAttribute("username", username);
return "mfa";
}
/**
* 启用 MFA:登录后用户请求此接口可获取 TOTP Secret 与二维码 URL
*/
@GetMapping("/enable-mfa")
public String enableMfa(Authentication authentication, Model model) {
if (authentication == null || !authentication.isAuthenticated()) {
return "redirect:/login";
}
String username = authentication.getName();
User u = userService.findByUsername(username);
if (u.getMfaEnabled()) {
model.addAttribute("message", "MFA 已启用");
return "home";
}
// 为用户生成秘钥并开启 MFA
userService.enableMfa(u.getId());
u = userService.findByUsername(username); // 刷新
String secret = u.getMfaSecret();
String otpAuthURL = TotpUtils.getOtpAuthURL(username, secret, "MyCompany");
model.addAttribute("otpAuthURL", otpAuthURL);
model.addAttribute("secret", secret);
return "enable-mfa";
}
@GetMapping("/")
public String homePage() {
return "home";
}
}
说明:
GET /enable-mfa
用于用户主动绑定 MFA(生成 Secret 并呈现给用户)。若业务要求后台自动开通,可在注册后直接调用userService.enableMfa(...)
。
为简化,以下示例仅为最基本表单。生产环境可加入更丰富的样式与 JS 验证。
login.html
登录
登录
没有账号?注册
register.html
注册
注册
已有账号?登录
mfa.html
MFA 验证
多因素认证
用户 ,请输入手机应用上的 6 位动态验证码:
enable-mfa.html
启用 MFA
启用多因素认证 (MFA)
请使用 Google Authenticator 或其他兼容 TOTP 的应用扫描下方二维码,或使用秘钥手动添加:
OTPAuth URL:
Secret Key:
设置完成后,请退出重新登录并输入动态验证码。
home.html
首页
欢迎来到系统
您已成功登录(且通过 MFA 验证)。
注意:
- 如果需要在页面中展示二维码,可以使用前端 QRCode.js 等库,将
otpAuthURL
渲染为二维码。
本文从“为什么需要多因素认证”入手,讲解了基于 TOTP 的 MFA 核心原理,并详细演示了如何在 Spring Security 6 中分两步完成登录与 MFA 验证的流程。关键点回顾:
第一步:用户名+密码
AuthenticationProvider
,校验用户名与密码;MfaRequiredException
,并将用户名暂存到 Session。第二步:TOTP 验证
MfaAuthenticationFilter
,拦截 /mfa-verify
请求;Authentication
并置入 SecurityContext
。MySQL + MyBatis-Plus
users
表中增加 mfa_enabled
与 mfa_secret
字段;/enable-mfa
页面使用前端二维码生成库(如 qrcode.js
)将 otpAuthURL
渲染为二维码图片,方便用户扫码。mfa_secret
为敏感数据,建议对其进行数据库加密存储或使用 KMS 等专用系统保护。通过上面的设计与实现,企业级应用即可在原有用户名+密码的基础上,平滑地接入基于 TOTP 的多因素认证,大幅提升系统安全性,抵御常见的账户破解与钓鱼风险。
如果你在实践过程中有任何疑问或更好的扩展思路,欢迎在评论区留言,最后希望大家 一键三连 给博主一点点鼓励!