---
title: Spring Securityで2要素認証 (2FA) を行う方法
summary: この記事では、Spring Securityでログイン後に2FA（TOTP）入力画面へ遷移させる実装方法を、TwoFactorAuthenticationSuccessHandlerとカスタム認可マネージャで解説します。
tags: ["Java", "Spring Boot", "Spring Security", "2FA", "MFA", "TOTP"]
categories: ["Programming", "Java", "org", "springframework", "security", "web", "authentication"]
date: 2023-09-08T08:01:09Z
updated: 2023-09-08T08:03:26Z
---

> [!NOTE]
> 2026-01-28 [Spring Secruity 7のMFAサポートを利用したバージョン](/entries/895)を公開しました。2FAを実装したい場合は、こちらの記事ではなく、最新版を参照してください。

Spring Securityで2要素認証 (2FA) を行う方法をメモします。

今回の実装は2要素に限定しているので、ここでは多要素認証 (MFA)というより2FAと明示しておきます。

"Spring Security 2FA" でGoogle検索すると、次の2例が見つかります。

* https://www.baeldung.com/spring-security-two-factor-authentication-with-soft-token
* https://www.javadevjournal.com/spring-security/two-factor-authentication-with-spring-security/

どちらも、TOTPを使った2FAを実装しています。しかし、ログインフォームの中で認証コードを入力する仕様になっています。
実装したいのは

* ログインフォームではユーザー名とパスワードのみ入力
* ユーザー名とパスワードでログインが成功した後、2FAが有効になっていれば認証コード(TOTP)入力フォームを表示

というフローです。上記の実装方法ではこのフローを実現できません。

このフローを実装しているサンプルがSpring Securityチームがメンテナンスしている公式サンプルの中にあります。<br>
https://github.com/spring-projects/spring-security-samples/tree/6.5.x/servlet/spring-boot/java/authentication/username-password/mfa <br>
こちらのサンプルはMFAのサンプルになっています。

このサンプルを参考に、上記のフローを実装したサンプルが <br>
https://github.com/making/demo-two-factor-authentication<br>
です。

### サンプルアプリのウォークスルー
まずはサンプルアプリをウォークスルーします。


http://localhost:8080/signup にアクセスし、アカウントを登録します。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/b93b706e-356b-42b4-b97e-bf0e60ca6885">

2FAはデフォルトで無効になっています。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/cce5282f-09b5-484f-b876-5c93d87b33fc">

ログアウトします。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/5f511532-f48e-460a-b825-6d7f896e5b3e">

もう一度ログインします。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/bcd1b6cc-c169-443b-a63a-4fe83dd4add1">

2FAが無効になっているので、ユーザー名とパスワードのみでログインが成功します。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/fd7773c9-e512-4c3c-b284-5af4ff9858fc">

2FAを有効化します。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/daea5be5-998c-4e73-a8d1-bba8f10d87f9">

Google Authenticatorを使ってQRコードを読み込みます。

![](https://github.com/making/blog.ik.am/assets/106908/a1a51abd-6b93-4210-a004-75a801d04040)

コードを確認します。

![](https://github.com/making/blog.ik.am/assets/106908/542691b9-f8d3-4a5a-bd0b-51a711f59fae)

コードを入力して、verifyボタンを押します。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/06b6f1d6-3190-4a62-831e-bd8d3d62e3d8">

2FAが有効になりました。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/78de0e76-257f-4f6d-9489-3ce27db9668f">

ログアウトします。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/5f511532-f48e-460a-b825-6d7f896e5b3e">

もう一度ログインします。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/bcd1b6cc-c169-443b-a63a-4fe83dd4add1">

今回は2FAが有効になっているので、コードの入力を求められます。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/49324a33-e6bd-42cb-8268-0e4e1cad3997">

Google Authenticatorでコードを確認します。

![](https://github.com/making/blog.ik.am/assets/106908/3b1d7927-5c50-4732-a0dd-c4274934f4be)

コードを入力して、verifyボタンを押します。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/7866fe1c-4245-45cc-9a52-b810361a4cb3">

ログインが成功しました。

<img width="1912" alt="image" src="https://github.com/making/blog.ik.am/assets/106908/01ea35a9-2f2b-48d9-a460-9653e02fac12">

### 実装の説明

`SecurityFilterChain`の定義は次のようになります。

```java
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
		AuthenticationSuccessHandler primarySuccessHandler) throws Exception {
	return http
		.authorizeHttpRequests(authorize -> authorize
				.requestMatchers("/signup", "/error").permitAll()
				.requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())
				.anyRequest().authenticated())
		.formLogin(form -> form
			.successHandler(new TwoFactorAuthenticationSuccessHandler("/challenge/totp", primarySuccessHandler)))
		.securityContext(securityContext -> securityContext.requireExplicitSave(false))
		.build();
}
```

ポイントは`formLogin`の`successHandler`に設定した`TwoFactorAuthenticationSuccessHandler`です。
このクラスが "ユーザー名とパスワードでログインが成功した後、2FAが有効になっていれば認証コード(TOTP)入力フォームを表示" を担います。

次のような実装になっています。

```java
public class TwoFactorAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

	private final AuthenticationSuccessHandler primarySuccessHandler;

	private final AuthenticationSuccessHandler secondarySuccessHandler;

	public TwoFactorAuthenticationSuccessHandler(String secondAuthUrl,
			AuthenticationSuccessHandler primarySuccessHandler) {
		this.primarySuccessHandler = primarySuccessHandler;
		this.secondarySuccessHandler = new SimpleUrlAuthenticationSuccessHandler(secondAuthUrl);
	}

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		AccountUserDetails accountUserDetails = (AccountUserDetails) authentication.getPrincipal();
		Account account = accountUserDetails.getAccount();
		if (account.twoFactorEnabled()) {
			SecurityContextHolder.getContext().setAuthentication(new TwoFactorAuthentication(authentication));
			this.secondarySuccessHandler.onAuthenticationSuccess(request, response, authentication);
		}
		else {
			this.primarySuccessHandler.onAuthenticationSuccess(request, response, authentication);
		}
	}

}
```

`primarySuccessHandler`はデフォルトで使われる`AuthenticationSuccessHandler`と同じもの(`SavedRequestAwareAuthenticationSuccessHandler`)です。`secondarySuccessHandler`はログインが成功すると指定したURL(ここでは`/challenge/totp`)に遷移する`AuthenticationSuccessHandler`です

ログインフォームからユーザ名とパスワードを入力して、認証が成功すると`TwoFactorAuthenticationSuccessHandler`の`onAuthenticationSuccess`メソッドが呼ばれます。
このメソッドを見ればわかるように、認証されたアカウントの2FAが無効であれば、`primarySuccessHandler`に処理が移譲されます。すなわち、これ以降は2FAを使わない場合と同じです。
2FAが有効であれば、`SecurityContext`に`TwoFactorAuthentication`を設定し、`secondarySuccessHandler`に処理が移譲されます。その結果、`/challenge/totp`にリダイレクトされます。

`TwoFactorAuthentication`は次のような実装になっています。

```java
public class TwoFactorAuthentication extends AbstractAuthenticationToken {

	private final Authentication primary;

	public TwoFactorAuthentication(Authentication primary) {
		super(List.of());
		this.primary = primary;
	}

	// 省略

	@Override
	public boolean isAuthenticated() {
		return false;
	}

	public Authentication getPrimary() {
		return this.primary;
	}
}
```

実際に認証処理を経て作成された`Authentication`オブジェクト(実装は`UsernamePasswordAuthenticationToken`)をラップしていますが、`isAuthenticated`が`false`を返します。つまり認証されていない状態にします。
2FAが有効の場合は、ユーザー名とパスワードによるログインが成功しても"authenticated"な状態にならないため、`anyRequest().authenticated()`に対して認可されません。
一方で、次の遷移先である`/challenge/totp`は認可される必要があるため、`.requestMatchers("/challenge/totp").access(new TwoFactorAuthorizationManager())`という設定をしています。

`TwoFactorAuthorizationManager`の実装は次のようになっています。

```java
public class TwoFactorAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

	@Override
	public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
		return new AuthorizationDecision(authentication.get() instanceof TwoFactorAuthentication);
	}

}
```

対象の`Authentication`オブジェクトが`TwoFactorAuthentication`かどうかだけを見ています。したがって、`TwoFactorAuthenticationSuccessHandler`によって`/challenge/totp`に遷移した場合に認可されます。


`/challenge/totp`に対するのControllerは次のようになっています。


```java
@Controller
public class TwoFactorAuthController {

	private final TwoFactorAuthenticationCodeVerifier codeVerifier;

	private final AuthenticationSuccessHandler successHandler;

	private final AuthenticationFailureHandler failureHandler;
	
	// 省略

	@GetMapping(path = "/challenge/totp")
	public String requestTotp() {
		return "totp";
	}

	@PostMapping(path = "/challenge/totp")
	public void processTotp(@RequestParam String code, TwoFactorAuthentication authentication,
			HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		Authentication primaryAuthentication = authentication.getPrimary();
		AccountUserDetails accountUserDetails = (AccountUserDetails) primaryAuthentication.getPrincipal();
		Account account = accountUserDetails.getAccount();
		if (this.codeVerifier.verify(account, code)) {
			SecurityContextHolder.getContext().setAuthentication(primaryAuthentication);
			this.successHandler.onAuthenticationSuccess(request, response, primaryAuthentication);
		}
		else {
			this.failureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Invalid code"));
		}
	}

}
```

`GET /challenge/totp`はコードを入力するフォームを表示するだけです。そのコードが送信される`POST /challenge/totp`に対しては、
`TwoFactorAuthenticationCodeVerifier`でTOTPコードの確認を行います。

コードがValidだったら`SecurityContext`に元の認証された`Authentication`を設定し、デフォルトの`AuthenticationSuccessHandler`で成功処理を行います。
コードがValidでなければ、デフォルトの`AuthenticationFailureHandler`でログイン失敗にします。

TOTPのSecret生成やverification、QRコード生成などは本記事では割愛します。Github上のソースコードを確認してください。

> [補足] Spring Security 6から、デフォルトで `SecurityContextHolder.getContext().setAuthentication(...)` だけではSession状態の保存が行われなくなり、Contextの保存を明示的に行う必要があります。<br>
> デメリットはありますが、今回は明示的なContextの保存をしなくても良いように `securityContext.requireExplicitSave(false)` を設定しました。<br>
> https://docs.spring.io/spring-security/reference/6.5/migration/servlet/session-management.html#_require_explicit_saving_of_securitycontextrepository
