Spring Security自定义过滤器实现多因素认证(MFA)实战指南

Spring Security自定义过滤器实现多因素认证(MFA)实战指南

1. 项目概述:为什么需要自定义过滤器实现多因素认证?

在构建现代Web应用时,登录安全早已不是输入用户名和密码那么简单了。我见过太多项目,初期只做了基础的账号密码校验,随着业务发展,安全需求提升,再想加入短信验证码、动态令牌(TOTP)等二次验证手段时,发现原有的登录逻辑像一团乱麻,改起来牵一发而动全身。Spring Security 6.X 提供了强大的认证授权框架,但其默认的UsernamePasswordAuthenticationFilter是为经典的“单因素认证”设计的。当我们需要在密码验证之后,再叠加一层甚至多层验证时,自定义过滤器就成了最清晰、最解耦的解决方案。

简单来说,这个项目的核心就是:在Spring Security的标准过滤器链中,插入一个我们自己的“关卡”,专门负责处理多因素认证(MFA)的逻辑。比如,用户输入正确的账号密码后,并不直接登录成功,而是进入一个“等待二次验证”的状态,系统要求用户输入手机短信验证码或Google Authenticator生成的6位数字。只有通过了这第二道关卡,才算真正认证成功。这样做的好处是,将不同维度的认证逻辑分离,代码结构清晰,也便于未来扩展(比如未来增加指纹、人脸识别等第三因素)。

从网络热词可以看到,大家对“登录”相关的安全实践非常关注,无论是JWT、OAuth2整合,还是具体的多因素实现,都是高频需求。而“自定义过滤器”正是Spring Security中满足这类定制化需求的利器。接下来,我就以一个实战项目为例,拆解如何从零开始,设计并实现一个支持TOTP(基于时间的一次性密码)和短信验证码的双因素认证登录流程。

2. 核心思路与架构设计

2.1 多因素认证流程拆解

在动手写代码之前,我们必须把整个交互流程想清楚。一个典型的多因素认证登录流程,可以分解为以下几个核心阶段:

  1. 第一阶段认证(通常为知识因素):用户提交用户名和密码。系统验证通过后,不直接颁发认证成功的凭证,而是标记该用户会话进入“待二次验证”状态,并生成一个临时的、代表此次登录尝试的令牌(我们称之为pendingToken)。同时,根据用户预设的MFA方式(如短信或TOTP),触发相应动作(发送短信或检查是否已绑定TOTP密钥)。
  2. 中间状态管理:用户被引导至一个专门的页面,要求输入第二因素凭证(如6位验证码)。系统需要有能力将用户输入的验证码、当前会话与之前生成的pendingToken关联起来。
  3. 第二阶段认证(通常为持有因素):用户提交验证码。系统校验验证码的有效性(检查是否匹配、是否在有效期内)。校验通过后,系统将之前“待二次验证”的认证信息升级为完整的、最终的成功认证(Authentication对象),并放入安全上下文(SecurityContext)。
  4. 成功跳转:用户被重定向到最初想访问的受保护页面。

这个流程的关键在于状态管理。我们不能让用户在第一阶段认证通过后就拥有全部权限,但又需要记住他已经通过了第一阶段,避免重复输入密码。

2.2 技术方案选型:为什么是自定义过滤器?

Spring Security的过滤器链就像一个安检流水线,每个过滤器负责一项检查。默认的登录过滤器 (UsernamePasswordAuthenticationFilter) 工作模式是“一次通过,全程放行”。要实现上述两阶段流程,我们有几种选择:

  • 方案A:改造AuthenticationProvider:在自定义的Provider里完成两阶段验证。缺点是逻辑容易臃肿,且不利于分离发送短信/生成TOTP等副作用操作。
  • 方案B:在Controller中处理:在登录接口的Controller里,手动调用AuthenticationManager,并管理两阶段状态。这会让Controller承担过多的安全逻辑,破坏了Spring Security的框架一致性。
  • 方案C:自定义过滤器(本次选择):在默认的登录过滤器之后,插入我们自己的MfaVerificationFilter。由默认过滤器完成密码校验并生成一个“未完全认证”的中间态对象。我们的过滤器拦截特定路径(如/login/mfa),专门处理第二阶段的验证码提交,并完成最终的认证升级。

为什么选C?因为它最符合“单一职责”和“开闭原则”。自定义过滤器只关心MFA验证,与密码验证逻辑完全解耦。它通过继承OncePerRequestFilter并重写doFilterInternal方法,可以精细地控制请求处理流程。状态可以通过Session、Redis或数据库来管理,灵活度高。此外,这样设计也使得未来可以轻松地在一个系统中支持多种MFA方式,甚至允许用户选择使用哪种。

2.3 核心组件与交互设计

基于方案C,我们需要设计以下几个核心组件:

  1. MfaAuthenticationToken(自定义Authentication对象):用于表示“已通过密码验证,待MFA验证”的中间状态。它需要包含用户的核心信息(如用户名、权限)和一个pendingToken,但authenticated属性应为false
  2. MfaVerificationFilter(自定义过滤器):核心处理器。它拦截像/login/mfa这样的请求,从请求中提取验证码和pendingToken,调用服务进行校验,校验成功后构建最终的UsernamePasswordAuthenticationToken并设置到SecurityContextHolder
  3. MfaService(服务层):负责MFA验证的具体业务逻辑,包括:
    • sendMfaCode: 根据用户ID和MFA类型,发送短信或生成TOTP URI(如果是首次绑定)。
    • verifyMfaCode: 校验用户提交的验证码是否有效。
    • isMfaRequired: 判断某个用户是否启用了MFA(可以从数据库用户表读取标志位)。
  4. MfaAuthenticationDetailsSource(可选):用于在密码认证阶段,将MFA相关的额外信息(如客户端IP、设备指纹)封装到Authenticationdetails属性中,供后续过滤器使用。
  5. 状态存储:使用HttpSession来存储pendingToken与用户ID、MFA类型的映射关系,简单高效。对于分布式应用,则需要使用Redis等集中式存储。

整个交互时序大致如下:用户提交登录表单 ->UsernamePasswordAuthenticationFilter验证密码,若用户启用了MFA,则生成MfaAuthenticationToken存入安全上下文,并返回要求MFA的响应 -> 前端引导用户至MFA验证页 -> 用户提交验证码至/login/mfa->MfaVerificationFilter拦截处理,验证通过后替换为完全认证的Token -> 登录成功。

3. 核心细节解析与实操要点

3.1 自定义Authentication对象:MfaAuthenticationToken

在Spring Security中,Authentication对象代表了一次认证的凭据和结果。我们需要创建一个新的类来表示“等待MFA验证”的状态。

public class MfaAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; // 通常是用户名或UserDetails对象 private Object credentials; // 这里可以存放pendingToken,或者为null private final String pendingToken; // 关键:本次MFA流程的唯一标识 // 构造函数:用于密码认证成功后创建 public MfaAuthenticationToken(Object principal, String pendingToken) { super(null); // 初始权限列表为null,因为还未完全认证 this.principal = principal; this.pendingToken = pendingToken; this.credentials = null; setAuthenticated(false); // 明确标记为未完全认证 } // 用于MfaVerificationFilter中,从请求中构建对象进行验证 public MfaAuthenticationToken(String pendingToken, String verificationCode) { super(null); this.principal = null; this.pendingToken = pendingToken; this.credentials = verificationCode; setAuthenticated(false); } @Override public Object getCredentials() { return this.credentials; } @Override public Object getPrincipal() { return this.principal; } public String getPendingToken() { return this.pendingToken; } }

关键点解析

  • 继承AbstractAuthenticationToken:这是Spring Security提供的便捷基类,帮助我们处理authenticated状态等通用逻辑。
  • 两个构造函数:第一个用于密码验证后,此时我们知道用户是谁(principal),但需要MFA。第二个用于MFA验证时,我们从请求参数中拿到了pendingTokenverificationCode,但还不知道具体用户(principal为null),需要根据pendingToken去查找。
  • setAuthenticated(false):这是灵魂所在!必须设置为false,这样Spring Security的授权拦截器(如FilterSecurityInterceptor)就会认为该请求未认证,从而阻止其访问受保护资源,迫使用户完成MFA流程。
  • pendingToken:这是一个随机生成的UUID,关联了此次登录会话和具体的用户。它需要被安全地传递给前端(通常通过响应体),并在MFA验证时由前端传回。

3.2 改造密码认证流程:生成中间状态

默认的DaoAuthenticationProvider在密码校验成功后,会直接返回一个authenticated=trueUsernamePasswordAuthenticationToken。我们需要干预这个过程。

方案一:自定义AuthenticationSuccessHandler在登录成功处理器中判断用户是否启用MFA。如果启用,则生成MfaAuthenticationToken并放入SecurityContext,然后返回一个JSON响应或重定向到MFA页面,而不是直接登录成功。

@Component public class MfaAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Autowired private MfaService mfaService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { UserDetails userDetails = (UserDetails) authentication.getPrincipal(); String username = userDetails.getUsername(); // 1. 判断该用户是否要求MFA if (mfaService.isMfaRequired(username)) { // 2. 生成pendingToken String pendingToken = UUID.randomUUID().toString(); // 3. 将pendingToken与用户信息关联存储(例如存入Session) request.getSession().setAttribute("MFA_PENDING_" + pendingToken, username); // 4. 根据用户配置的MFA方式,触发发送验证码 mfaService.sendMfaCode(username, pendingToken); // 5. 创建中间态Token,替换掉原有的已认证Token MfaAuthenticationToken mfaAuth = new MfaAuthenticationToken(userDetails, pendingToken); SecurityContextHolder.getContext().setAuthentication(mfaAuth); // 6. 返回指示需要MFA的响应 response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(String.format("{\"code\": 200, \"message\": \"需要MFA验证\", \"pendingToken\": \"%s\", \"mfaType\": \"SMS\"}", pendingToken)); return; } // 如果不需要MFA,走默认的成功逻辑(如跳转到首页) // ... 默认处理逻辑 ... } }

方案二:自定义AuthenticationProvider(更彻底)在Provider内部,密码验证通过后,如果用户启用了MFA,直接返回我们自定义的MfaAuthenticationToken。这样更内聚,但改造点更深。

实操心得:对于初次集成,我推荐使用方案一(自定义SuccessHandler)。因为它对原有认证流程侵入最小,只需要在配置中替换一个Bean即可。方案二虽然更“干净”,但需要小心处理AuthenticationManager的配置,容易与其他自定义Provider产生冲突。无论哪种方案,核心都是在密码验证通过后,用authenticated=false的Token替换掉原本成功的Token

3.3 实现MfaVerificationFilter:处理二次验证

这是整个多因素认证的核心枢纽。它需要:

  1. 拦截特定的MFA验证端点(如POST /api/login/mfa-verify)。
  2. 从请求中提取pendingTokenverificationCode
  3. 调用MfaService进行验证。
  4. 验证成功,则从存储中取出对应用户信息,构建完整的Authentication对象并设置到安全上下文。
  5. 验证失败,则抛出相应的AuthenticationException
public class MfaVerificationFilter extends OncePerRequestFilter { @Autowired private MfaService mfaService; @Autowired private UserDetailsService userDetailsService; // 验证成功后的处理器,例如跳转或返回JSON @Autowired private AuthenticationSuccessHandler successHandler; @Autowired private AuthenticationFailureHandler failureHandler; private final AntPathRequestMatcher verificationMatcher = new AntPathRequestMatcher("/api/login/mfa-verify", "POST"); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 1. 只处理MFA验证请求 if (!verificationMatcher.matches(request)) { chain.doFilter(request, response); return; } try { // 2. 提取请求参数 String pendingToken = obtainPendingToken(request); String verificationCode = obtainVerificationCode(request); if (pendingToken == null || verificationCode == null) { throw new BadCredentialsException("MFA验证参数缺失"); } // 3. 调用服务进行验证 String username = mfaService.verifyMfaCode(pendingToken, verificationCode); if (username == null) { throw new BadCredentialsException("MFA验证码无效或已过期"); } // 4. 验证成功,加载用户信息,构建完全认证的Token UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken fullAuth = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); fullAuth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 5. 将完全认证的Token放入安全上下文 SecurityContextHolder.getContext().setAuthentication(fullAuth); // 6. 清理Session中的pendingToken request.getSession().removeAttribute("MFA_PENDING_" + pendingToken); // 7. 调用成功处理器(例如返回成功JSON或重定向) successHandler.onAuthenticationSuccess(request, response, fullAuth); } catch (AuthenticationException e) { // 8. 调用失败处理器 SecurityContextHolder.clearContext(); failureHandler.onAuthenticationFailure(request, response, e); } } private String obtainPendingToken(HttpServletRequest request) { return request.getParameter("pendingToken"); } private String obtainVerificationCode(HttpServletRequest request) { return request.getParameter("code"); } }

关键点解析

  • OncePerRequestFilter:确保该过滤器在一次请求中只执行一次。
  • AntPathRequestMatcher:用于精确匹配需要处理的请求路径,避免干扰其他请求。
  • 状态清理:验证成功后,务必从Session(或Redis)中清理掉使用过的pendingToken,防止被重复使用,这是安全性的重要一环。
  • 完整的认证流程:在MFA验证通过后,我们手动构建了UsernamePasswordAuthenticationToken并设置其authenticated=true,这标志着用户完成了整个登录流程。随后调用successHandler,可以返回给前端一个标准的登录成功响应(如新的JWT Token或Session Cookie)。

3.4 配置Spring Security过滤器链

有了自定义的Filter和SuccessHandler,我们需要将它们装配到Spring Security的配置中。

@Configuration @EnableWebSecurity public class SecurityConfig { @Autowired private MfaAwareAuthenticationSuccessHandler mfaSuccessHandler; @Autowired private MfaVerificationFilter mfaVerificationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/api/login/mfa-verify").permitAll() // MFA验证端点需要放行 .requestMatchers("/public/**", "/login**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginProcessingUrl("/api/login") // 默认的密码登录端点 .successHandler(mfaSuccessHandler) // **关键:使用我们自定义的成功处理器** .failureHandler(...) .permitAll() ) .addFilterBefore(mfaVerificationFilter, UsernamePasswordAuthenticationFilter.class) // **关键:将MFA过滤器加到密码过滤器之前** .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ) .csrf(csrf -> csrf.disable()); // 根据API设计决定是否禁用,建议对登录端点保持防护 return http.build(); } // 其他Bean定义,如UserDetailsService, PasswordEncoder等... }

配置要点

  • successHandler:在formLogin配置中,将默认的成功处理器替换为我们的MfaAwareAuthenticationSuccessHandler
  • addFilterBefore:这是将自定义过滤器插入过滤器链的关键。我们将MfaVerificationFilter添加在UsernamePasswordAuthenticationFilter之前。这样,当请求到达/api/login/mfa-verify时,会先被我们的过滤器处理。如果请求不是MFA验证,则放行给后面的密码过滤器。
  • 端点权限:确保/api/login/mfa-verifypermitAll()的,因为此时用户尚未完全认证。

4. 实操过程与核心环节实现

4.1 TOTP(基于时间的一次性密码)实现详解

TOTP是Google Authenticator等应用使用的标准。其核心是:服务器和客户端共享一个密钥,双方根据当前时间(通常以30秒为一个时间窗口)通过HMAC算法计算出一个6位或8位的数字。

1. 依赖引入

<dependency> <groupId>com.warrenstrange</groupId> <artifactId>googleauth</artifactId> <version>1.5.0</version> </dependency>

这个库封装了TOTP的生成和验证逻辑,非常好用。

2. 服务层实现

@Service public class TotpService { private final GoogleAuthenticator gAuth = new GoogleAuthenticator(); /** * 为用户生成一个新的TOTP密钥和绑定URI */ public TotpSetupInfo generateSecret(String username, String issuer) { final GoogleAuthenticatorKey key = gAuth.createCredentials(); String secret = key.getKey(); // 生成一个二维码内容URI,方便用户用APP扫描 String qrCodeUri = GoogleAuthenticatorQRGenerator.getOtpAuthTotpURL(issuer, username, new GoogleAuthenticatorKey.Builder(secret).build()); return new TotpSetupInfo(secret, qrCodeUri); } /** * 验证用户输入的TOTP码 * @param secret 用户绑定的TOTP密钥 * @param code 用户输入的6位数字 * @return 是否验证通过 */ public boolean verifyCode(String secret, int code) { // 这里可以设置一个时间窗口容差,比如前一个、当前、后一个窗口都算有效,防止时间不同步 GoogleAuthenticator gAuth = new GoogleAuthenticator(); gAuth.setWindowSize(1); // 允许前后1个时间窗口的偏差 return gAuth.authorize(secret, code); } /** * 在用户绑定TOTP时,要求用户输入一次验证码以确保正确 */ public boolean validateInitialCode(String secret, int code) { // 首次绑定验证可以更严格,只允许当前窗口 GoogleAuthenticator gAuth = new GoogleAuthenticator(); gAuth.setWindowSize(0); return gAuth.authorize(secret, code); } }

3. 用户绑定TOTP流程

  • 在用户安全设置页面,提供一个“启用TOTP”的按钮。
  • 点击后,后端调用generateSecret,生成secretqrCodeUri返回给前端。
  • 前端展示二维码和手动输入密钥的选项,用户使用Google Authenticator等APP扫描绑定。
  • APP绑定后,会生成一个动态码。用户在前端输入这个动态码并提交。
  • 后端调用validateInitialCode进行验证。验证通过后,将secret安全地存储到该用户的数据库记录中(务必加密存储!),并标记用户已启用TOTP MFA。

4. 登录时的TOTP验证MfaService.isMfaRequired(username)返回true且MFA类型为TOTP时,在sendMfaCode阶段,实际上不需要“发送”什么,因为验证码由用户的APP生成。我们只需要在MfaVerificationFilter的验证环节,从数据库取出用户的secret,然后调用totpService.verifyCode(secret, inputCode)即可。

注意事项:服务器时间必须同步!TOTP严重依赖时间。务必确保服务器使用NTP服务保持时间准确。setWindowSize参数可以用来缓解微小的时间偏差。

4.2 短信验证码实现详解

短信验证码的实现更侧重于“发送”和“校验”的生命周期管理。

1. 服务层实现

@Service public class SmsService { @Autowired private StringRedisTemplate redisTemplate; // 假设的短信服务商客户端 @Autowired private SmsVendorClient smsClient; private static final String SMS_CODE_PREFIX = "sms:code:"; private static final long SMS_CODE_EXPIRE_SECONDS = 300; // 5分钟有效期 /** * 发送短信验证码 */ public void sendCode(String phoneNumber, String pendingToken) { // 1. 生成随机6位数字码 String code = String.format("%06d", new Random().nextInt(999999)); // 2. 将验证码与pendingToken关联存储到Redis,并设置过期时间 String key = SMS_CODE_PREFIX + pendingToken; redisTemplate.opsForValue().set(key, code, Duration.ofSeconds(SMS_CODE_EXPIRE_SECONDS)); // 3. 记录手机号,用于后续验证时比对(防止pendingToken被篡改指向其他手机号) redisTemplate.opsForValue().set(key + ":phone", phoneNumber, Duration.ofSeconds(SMS_CODE_EXPIRE_SECONDS)); // 4. 调用短信服务商接口发送 smsClient.sendVerificationCode(phoneNumber, code); } /** * 验证短信验证码 * @return 验证成功则返回关联的手机号,失败返回null */ public String verifyCode(String pendingToken, String inputCode) { String key = SMS_CODE_PREFIX + pendingToken; String storedCode = redisTemplate.opsForValue().get(key); String storedPhone = redisTemplate.opsForValue().get(key + ":phone"); // 验证码存在、未过期且匹配 if (storedCode != null && storedCode.equals(inputCode.trim())) { // 验证通过,立即删除Redis中的键,防止重放攻击 redisTemplate.delete(key); redisTemplate.delete(key + ":phone"); return storedPhone; } return null; } }

2. 与MFA流程集成MfaService.sendMfaCode方法中,如果用户配置的是短信验证,则调用smsService.sendCode(user.getPhone(), pendingToken)。 在MfaVerificationFilter中,调用mfaService.verifyMfaCode,其内部会调用smsService.verifyCode(pendingToken, inputCode)。如果返回了手机号,再根据手机号找到对应的用户名。

实操心得

  • 防刷与限流:一定要在发送短信的接口上做限流(如每个手机号每分钟1次,每天10次),防止被恶意利用刷短信导致资费损失。可以使用Spring的@RateLimit注解或Redis实现简单的计数器。
  • 验证码安全:验证码长度建议6位,有效期不宜过长,5分钟比较合适。存储时不要明文存储,可以存储其哈希值(如MD5(code+salt)),但考虑到有效期短且Redis本身有一定安全性,直接存储也可接受,但务必保证Redis服务的安全。
  • 重放攻击防护:验证成功后立即删除Redis中的验证码,这是最关键的一步。

4.3 前端与后端的协同交互

前端需要处理两阶段的登录流程。这里以RESTful API + JSON响应为例。

1. 密码登录接口 (POST /api/login)

  • 请求{“username”: “user”, “password”: “pass”}
  • 成功响应(需要MFA)
    { “code”: 200, “message”: “需要MFA验证”, “data”: { “requiresMfa”: true, “pendingToken”: “550e8400-e29b-41d4-a716-446655440000”, “mfaType”: “SMS” // 或 “TOTP” } }
  • 成功响应(无需MFA)
    { “code”: 200, “message”: “登录成功”, “data”: { “token”: “eyJhbGciOiJ...”, // JWT Token “userInfo”: { ... } } }

2. MFA验证接口 (POST /api/login/mfa-verify)

  • 请求{“pendingToken”: “上述token”, “code”: “123456”}
  • 成功响应:与无需MFA的登录成功响应一致。
  • 失败响应:返回标准的认证错误信息。

3. 前端逻辑控制

async function handleLogin(username, password) { const loginResp = await post(‘/api/login’, { username, password }); if (loginResp.data.requiresMfa) { // 1. 显示MFA验证输入框,将pendingToken保存在前端状态 setPendingToken(loginResp.data.pendingToken); setMfaType(loginResp.data.mfaType); showMfaModal(); // 2. 如果是TOTP,提示用户打开验证器APP;如果是SMS,提示查收短信 } else { // 直接登录成功,保存token,跳转首页 saveTokenAndRedirect(loginResp.data.token); } } async function handleMfaVerify(code) { const verifyResp = await post(‘/api/login/mfa-verify’, { pendingToken: pendingToken, code: code }); if (verifyResp.success) { saveTokenAndRedirect(verifyResp.data.token); } else { alert(‘验证码错误’); } }

这种设计使得前端无需关心具体的MFA类型,只需根据后端返回的requiresMfa标志和mfaType来调整UI提示即可,后端处理逻辑的变化对前端影响最小。

5. 常见问题与排查技巧实录

在实际开发和上线过程中,我遇到了不少坑。这里总结几个典型问题和解决方法。

5.1 会话(Session)管理问题

问题描述:用户完成密码验证后,进入了MFA等待页面。但在等待或输入验证码期间,Session可能因为超时或其他原因丢失,导致pendingToken找不到对应的用户信息,验证失败。

根因分析pendingToken与用户信息的映射默认存在Session中。Spring Security的默认会话超时时间可能较短,或者在一些无状态架构中,Session本身就不被使用。

解决方案

  1. 延长Session超时时间:针对MFA流程,可以临时延长会话有效期。在MfaAwareAuthenticationSuccessHandler中,获取Session后设置setMaxInactiveInterval
    HttpSession session = request.getSession(false); if (session != null) { // 设置一个较长的超时,例如10分钟,供用户完成MFA session.setMaxInactiveInterval(600); }
  2. 使用分布式存储:在生产环境,尤其是集群部署时,必须使用Redis等共享存储来保存pendingToken映射关系,并设置合理的TTL(如10分钟)。这样即使应用重启或请求落到不同服务器,状态也不会丢失。
  3. 将用户信息编码进Token:一种更无状态的做法是使用JWT等自包含令牌。将用户名、过期时间等信息用密钥签名后生成pendingToken。在验证时直接解析Token即可获取用户信息,无需查询存储。但要注意Token的撤销问题。

5.2 安全性强化:防重放与防暴力破解

风险1:验证码重放攻击。攻击者截获了一次有效的验证码请求,重复发送,可能利用时间差通过验证。

防护措施

  • 一次性使用:在SmsService.verifyCodeTotpService.verifyCode验证通过后,立即失效该验证码。对于TOTP,由于其本身具有时间窗,重放价值较低,但也可以记录最近使用过的时间戳,防止同一码在极短时间内重复使用。
  • 绑定请求:将pendingToken与客户端的一些指纹信息(如IP地址、User-Agent的哈希)绑定存储。验证时比对,不一致则拒绝。这增加了攻击者复用Token的难度。

风险2:验证码暴力破解。针对短信的6位数字码,理论上最多尝试100万次即可破解。

防护措施

  • 尝试次数限制:在Redis中为每个pendingToken记录一个错误尝试计数器。例如,键为mfa:attempts:<pendingToken>,值从0开始累加。当达到阈值(如5次)时,直接使该pendingToken失效,并可能临时锁定该用户账户。
  • 增加时间延迟:每次验证失败后,不立即返回错误,而是引入一个逐渐增长的延迟(如失败N次后延迟2^N秒),拖慢自动化攻击脚本的速度。
  • 验证码复杂度:虽然用户体验下降,但可以考虑使用6位数字字母混合码,增大爆破空间。

5.3 用户体验优化:记住设备与备用验证码

需求:对于用户经常登录的受信任设备(如自己的电脑),每次登录都进行MFA会很繁琐。

解决方案:实现“记住此设备”功能

  1. 在MFA验证通过的请求中,增加一个可选的rememberDevice参数。
  2. 如果用户勾选,后端生成一个长期的、高熵的“设备令牌”(Device Token),将其哈希值存储到数据库的user_trusted_devices表中,关联用户ID和设备信息(如浏览器指纹的哈希)。
  3. 同时,将这个设备令牌通过一个安全的、HttpOnly的Cookie发送给浏览器,设置一个较长的过期时间(如30天)。
  4. 下次用户在同一设备上登录时,在密码验证通过后、检查是否需要MFA之前,先检查请求中是否携带有效的设备令牌。如果有且匹配,则跳过MFA流程,直接完成认证。

关键实现点

  • 设备令牌生成:使用安全的随机数生成器。
  • 存储哈希:和密码一样,存储令牌的哈希值,而非明文。
  • Cookie安全:标记为Secure,HttpOnly,SameSite=Strict
  • 用户管理:在用户的安全设置页面,提供“管理受信任设备”的功能,允许用户查看和撤销特定设备。

备用验证码(Backup Codes): 为了防止用户丢失TOTP设备或收不到短信,可以在用户启用MFA时,为其生成一组(如10个)一次性使用的备用码。这些码需要显示给用户并让其安全保存(建议下载或打印)。当主验证方式不可用时,用户可以使用一个备用码登录。每个备用码使用后立即作废。

5.4 集成测试策略

测试多因素认证不能只测“快乐路径”。需要覆盖以下场景:

  1. 单元测试

    • MfaAuthenticationToken的构造和状态。
    • TotpService的代码生成和验证逻辑(可以使用固定的时间和密钥进行测试)。
    • SmsService的验证码存储、获取和删除逻辑(使用嵌入式Redis如Testcontainers)。
  2. 集成测试(使用@SpringBootTest

    • 场景1:禁用MFA的用户,密码登录成功,直接获取到最终Token。
    • 场景2:启用短信MFA的用户,密码登录后,返回requiresMfapendingToken,且模拟的短信发送被调用。
    • 场景3:使用正确的pendingToken和验证码调用/api/login/mfa-verify,成功获取最终Token,且Session/Redis中的pendingToken被清理。
    • 场景4:使用错误验证码或已过期的pendingToken调用验证接口,返回认证失败。
    • 场景5:在MFA等待状态,尝试直接访问受保护API/api/user/profile,应被拒绝(返回403或重定向到登录)。
    • 场景6:测试“记住设备”功能,在首次MFA验证时勾选,下次同设备登录应跳过MFA。
  3. 端到端测试: 使用Selenium或Cypress模拟用户完整的浏览器操作流程,从输入密码到收到短信(或输入TOTP),再到完成登录。这是确保整个交互链路正确的最终保障。

调试时,最有用的是打开Spring Security的Debug日志:logging.level.org.springframework.security=DEBUG。你可以清晰地看到请求经过过滤器链的每一步,以及Authentication对象在SecurityContextHolder中的变化,这对于理解自定义过滤器是否按预期工作至关重要。

最后,多因素认证是安全与用户体验的平衡。在实现时,务必提供一个清晰的用户界面来引导用户完成流程,并在用户可能遇到问题的地方(如收不到短信、TOTP设备丢失)提供明确的帮助入口和备用方案。这套自定义过滤器的架构提供了足够的灵活性,你可以在此基础上,轻松地集成更多的认证因素,如生物识别、硬件密钥(WebAuthn)等,构建更坚固的登录安全防线。