Spring Security 7 自带了多因素认证。@EnableMultiFactorAuthentication 注解声明要哪几个因子,再配上表单登录和一次性令牌登录,「密码加一次性令牌」的两步登录就成了,不用自己写过滤器。我在 Spring Boot 4.1.1(Spring Security 7.1.1)上搭了一个,用 MockMvc 测试和 curl 实际请求都验证过:只过密码进不去,两步都过才能进,令牌用过一次就作废。

原理

Security 7 在认证成功时会往 Authentication 里加一个 FactorGrantedAuthority。用密码登录得到 FACTOR_PASSWORD,用一次性令牌登录得到 FACTOR_OTT。这个类和 @EnableMultiFactorAuthentication 都是 7.0 新加的,见官方 Multi-Factor Authentication 文档。

MFA 的做法就是两件事。授权规则要求同时具备这几个 factor,再给每个 factor 配一种登录方式。缺哪个,Security 会把用户带到对应的登录页,跳转地址里用 factor.type 和 factor.reason 两个参数说明缺的是什么。

依赖

只要 Web 和 Security 两个 starter,测试再加两个:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webmvc-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security-test</artifactId>
  <scope>test</scope>
</dependency>

Boot 4.1.1 解析到的 Spring Security 是 7.1.1。

安全配置

配置和官方 MFA 文档里的示例基本一致,多放行了一个令牌发送后的提示页 /ott/sent:

@Configuration(proxyBeanMethods = false)
@EnableMultiFactorAuthentication(authorities = {
        FactorGrantedAuthority.PASSWORD_AUTHORITY,
        FactorGrantedAuthority.OTT_AUTHORITY })
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/ott/sent").permitAll()
                .anyRequest().authenticated())
            .formLogin(Customizer.withDefaults())
            .oneTimeTokenLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(
                User.withDefaultPasswordEncoder()
                        .username("user")
                        .password("password")
                        .authorities("app")
                        .build());
    }
}

authorities 里写了两个 factor,意思是每条授权规则都会自动加上「必须有 FACTOR_PASSWORD 和 FACTOR_OTT」。所以 anyRequest().authenticated() 在这里的实际含义是两步都要过。formLogin 提供密码这一步,oneTimeTokenLogin 提供令牌这一步。这几样东西都挂在同一条过滤器链上,过滤器链的来龙去脉可以看 Spring Security 中的过滤器链。

所以标题里说的不是只写一个注解。注解负责「要两个因子」,两种登录方式和下面的令牌投递还得自己配。

令牌怎么送到用户手里

一次性令牌要通过别的渠道送给用户,比如邮件或短信。Security 不管投递,One-Time Token Login 文档写明要自己实现一个 OneTimeTokenGenerationSuccessHandler 负责把令牌交给用户。示例里用日志代替邮件,同时把最新的令牌存在内存里给测试读:

@Component
public class LoggingOttHandler implements OneTimeTokenGenerationSuccessHandler {

    private static final Logger log = LoggerFactory.getLogger(LoggingOttHandler.class);

    private final Map<String, String> latest = new ConcurrentHashMap<>();
    private final OneTimeTokenGenerationSuccessHandler redirect =
            new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken token)
            throws IOException, jakarta.servlet.ServletException {
        latest.put(token.getUsername(), token.getTokenValue());
        log.info("one-time token for {}: {}", token.getUsername(), token.getTokenValue());
        redirect.handle(request, response, token);
    }

    public String latestFor(String username) {
        return latest.get(username);
    }
}

令牌默认存在 InMemoryOneTimeTokenService 里。令牌值是 UUID,消费时直接从 Map 里 remove,所以只能用一次。默认有效期 5 分钟,定义在 GenerateOneTimeTokenRequest 的 DEFAULT_EXPIRES_IN = Duration.ofMinutes(5)。内存实现只在单个实例里有效,多实例部署时用同一个包里存数据库的 JdbcOneTimeTokenService,令牌放在共享的表里。

受保护的接口很简单,把当前用户的权限打印出来,方便看 factor:

@GetMapping("/hello")
String hello(Authentication authentication) {
    List<String> authorities = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).sorted().toList();
    return "hello " + authentication.getName() + " " + authorities;
}

用 curl 走一遍

应用用 mvn spring-boot:run 跑在 8080 端口。curl 脚本,用 cookie 文件保持会话,每次 POST 前先从页面上抠 CSRF token。令牌从应用日志里读:

echo "== step 1: password"
T=$(csrf "$JAR" /login)
curl -s -o /dev/null -w 'POST /login        -> %{http_code} %{redirect_url}\n' -b "$JAR" -c "$JAR" \
  -d "username=user&password=password&_csrf=$T" $B/login
curl -s -o /dev/null -w 'GET  /hello        -> %{http_code} %{redirect_url}\n' -b "$JAR" -c "$JAR" $B/hello

echo "== step 2: one-time token"
T=$(csrf "$JAR" /login)
curl -s -o /dev/null -w 'POST /ott/generate -> %{http_code} %{redirect_url}\n' -b "$JAR" -c "$JAR" \
  -d "username=user&_csrf=$T" $B/ott/generate
TOKEN=$(grep -o 'one-time token for user: [0-9a-f-]*' "$APP_LOG" | tail -1 | awk '{print $NF}')
echo "token from app log: $TOKEN"
T=$(csrf "$JAR" /login/ott)
curl -s -o /dev/null -w 'POST /login/ott    -> %{http_code} %{redirect_url}\n' -b "$JAR" -c "$JAR" \
  -d "token=$TOKEN&_csrf=$T" $B/login/ott
curl -s -w '\nGET  /hello        -> %{http_code}\n' -b "$JAR" -c "$JAR" $B/hello

echo "== replay the same token in a fresh session"
T=$(csrf "$JAR2" /login)
curl -s -o /dev/null -w 'POST /login/ott    -> %{http_code} %{redirect_url}\n' -b "$JAR2" -c "$JAR2" \
  -d "token=$TOKEN&_csrf=$T" $B/login/ott
curl -s -o /dev/null -w 'GET  /hello        -> %{http_code} %{redirect_url}\n' -b "$JAR2" -c "$JAR2" $B/hello

输出:

== step 1: password
POST /login        -> 302 http://localhost:8080/
GET  /hello        -> 302 http://localhost:8080/login?factor.type=ott&factor.reason=missing
== step 2: one-time token
POST /ott/generate -> 302 http://localhost:8080/ott/sent
token from app log: 18822cfc-618a-45f2-89a1-e7a3d4832d9f
POST /login/ott    -> 302 http://localhost:8080/hello?continue
hello user [FACTOR_OTT, FACTOR_PASSWORD, app]
GET  /hello        -> 200
== replay the same token in a fresh session
POST /login/ott    -> 302 http://localhost:8080/login?error
GET  /hello        -> 302 http://localhost:8080/login

应用那边对应的日志:

2026-10-12T08:09:12.582+08:00  INFO 574216 --- [nio-8080-exec-5] com.example.mfa.LoggingOttHandler        : one-time token for user: 18822cfc-618a-45f2-89a1-e7a3d4832d9f

密码登录成功后,访问 /hello 被 302 到 /login?factor.type=ott&factor.reason=missing,URL 里直接写明缺的是 OTT。提交令牌后,/hello 返回 200,权限里同时有 FACTOR_PASSWORD 和 FACTOR_OTT。同一个令牌换个会话再交一次,直接 /login?error。

用测试固定下来

测试用 MockMvc 加 MockHttpSession 串起同一个流程。CSRF 用 spring-security-test 的 csrf():

@SpringBootTest
@AutoConfigureMockMvc
class MfaFlowTest {

    @Autowired
    MockMvc mvc;

    @Autowired
    LoggingOttHandler tokens;

    @Test
    void passwordAloneIsNotEnough() throws Exception {
        var session = new MockHttpSession();
        mvc.perform(post("/login").session(session).with(csrf())
                        .param("username", "user").param("password", "password"))
                .andExpect(status().is3xxRedirection());

        mvc.perform(get("/hello").session(session))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/login?factor.type=ott&factor.reason=missing"));
    }

    @Test
    void passwordPlusTokenGetsIn() throws Exception {
        var session = new MockHttpSession();
        mvc.perform(post("/login").session(session).with(csrf())
                .param("username", "user").param("password", "password"));
        mvc.perform(post("/ott/generate").session(session).with(csrf()).param("username", "user"))
                .andExpect(redirectedUrl("/ott/sent"));
        String token = tokens.latestFor("user");

        mvc.perform(post("/login/ott").session(session).with(csrf()).param("token", token))
                .andExpect(status().is3xxRedirection());

        var body = mvc.perform(get("/hello").session(session))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString();
        System.out.println("MfaFlowTest /hello -> " + body);
        assertThat(body).contains("FACTOR_PASSWORD", "FACTOR_OTT");
    }

    @Test
    void tokenWorksOnlyOnce() throws Exception {
        mvc.perform(post("/ott/generate").with(csrf()).param("username", "user"));
        String token = tokens.latestFor("user");

        mvc.perform(post("/login/ott").session(new MockHttpSession()).with(csrf()).param("token", token))
                .andExpect(redirectedUrl("/"));
        mvc.perform(post("/login/ott").session(new MockHttpSession()).with(csrf()).param("token", token))
                .andExpect(redirectedUrl("/login?error"));
    }

    @Test
    void tokenAloneIsNotEnoughEither() throws Exception {
        var session = new MockHttpSession();
        mvc.perform(post("/ott/generate").with(csrf()).param("username", "user"));
        mvc.perform(post("/login/ott").session(session).with(csrf()).param("token", tokens.latestFor("user")));

        mvc.perform(get("/hello").session(session))
                .andExpect(status().is3xxRedirection())
                .andExpect(redirectedUrl("/login?factor.type=password&factor.reason=missing"));
    }
}

最后一个测试反过来走,只用令牌登录,再访问 /hello 会被带去 factor.type=password。顺序反过来也一样要两步。mvn test 结果:

MfaFlowTest /hello -> hello user [FACTOR_OTT, FACTOR_PASSWORD, app]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.844 s -- in com.example.mfa.MfaFlowTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

踩到的坑

第一版测试照着 curl 的输出写了绝对地址 http://localhost/login?...,挂了两个:

java.lang.AssertionError: Redirected URL expected:<http://localhost/login?factor.type=ott&factor.reason=missing> but was:</login?factor.type=ott&factor.reason=missing>

MockMvc 不经过 Tomcat,拿到的是相对路径,真服务器上 curl 看到的才是绝对地址。改成相对路径后 4 个测试都过了。

/ott/generate 不需要先登录,tokenWorksOnlyOnce 就是在没有会话的情况下生成令牌的。谁都能替某个用户名申请令牌,安全性靠的是令牌只发到用户自己的邮箱或手机上。示例里令牌是写进日志的。

表单登录默认开着 CSRF 防护,curl 的每个 POST 都要带 _csrf,所以脚本每次 POST 前都从页面上重新取一次。

环境是 Debian 13、OpenJDK 21.0.12.1、Maven 3.9.9、curl 8.14.1,Spring Boot 4.1.1,Spring Security 7.1.1。

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。