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。
一起交流
分享你的思考,让讨论更进一步。