Symfonyで2要素認証

こんにちは、岡野です。
最近Symfonyを使っているWebサイトで2要素認証を実現しました。その際に使用したscheb/2faバンドルは非常に使いやすく、Symfony公式マニュアルを見ながら容易に実現できました。但し一点問題があり、認証コードを入力しないままユーザが画面遷移すると中途半端な認証状態になってしまうので困りました。
今回はその回避方法を共有します。
 

環境

Symfony: 4.4.20
scheb/2fa-*: 5.13.1
 

起きていた現象

1. ユーザがID/PWを入力。
2. 認証コードを尋ねるポップアップを表示。
3. ユーザが他の画面へ遷移。

→ 2.でSymfony内部の認証状態が「Partially authenticated」になるため、3.でController#getUser()すると認証が完了していないユーザエンティティが取得できてしまう(※1)。
また3.のページがfirewallで制限している場合は認証コードを尋ねるページが表示される。一般的な通常サイトの様にログイン画面へ遷移させたい(※2)。
 

※1(完全認証前のユーザが取得できてしまう)の回避方法

EventListenerを使って、認証コードの確認が完了するまでuserをanonymousにする。
 
class AuthAttemptListener implements EventSubscriberInterface
{
  public static function getSubscribedEvents(): array
  {
      return [
          TwoFactorAuthenticationEvents::ATTEMPT => ['onTwoFactorAuthAttempt'],
          TwoFactorAuthenticationEvents::COMPLETE => ['onTwoFactorAuthComplete'],
          TwoFactorAuthenticationEvents::FAILURE => ['onTwoFactorAuthFailure'],
          KernelEvents::RESPONSE => ['onKernelResponse'],
          KernelEvents::TERMINATE => ['onKernelTerminate'],
      ];
    }

  /**
   * セッション内の情報が正しければ、バンドル内の2要素認証処理用にトークンへユーザを設定する。
* ユーザは後で解除する。
   */
  public function onTwoFactorAuthAttempt(TwoFactorAuthenticationEvent $event): void
  {
      // ...
      $this->attemptingToken->setUser($user);
    }

  /**
   * 2要素認証が成功した場合、トークンを認証状態に設定する。
   */
  public function onTwoFactorAuthComplete(TwoFactorAuthenticationEvent $event): void
  {
      // ...
      $this->attemptingToken->getAuthenticatedToken()->setAuthenticated(true);
    }

  /**
   * 2要素認証失敗時、トークンのユーザを元に戻す
   */
  public function onTwoFactorAuthFailure(TwoFactorAuthenticationEvent $event): void
  {
      if ($this->attemptingToken) {
          $this->revertTokenUser();
      }
    }

  /**
   * 例外発生時、トークンのユーザを元に戻す
   */
  public function onKernelResponse(): void
  {
      if ($this->attemptingToken) {
          $this->revertTokenUser();
      }
    }

  /**
   * 安全のためSymfonyの処理が終わる際に、万が一処理中のトークンが残っていたら
* 例外を発生させてエラー通知する。
   */
  public function onKernelTerminate(): void
  {
      if ($this->attemptingToken) {
          throw new \LogicException();
      }
    }

  private function revertTokenUser(): void
  {
      $this->attemptingToken->setUser('anon.');
      $this->attemptingToken->getAuthenticatedToken()->setAuthenticated(false);
      $this->attemptingToken = null;
  }
}

※2(認証コード要求ページが表示される)の回避方法

認証コードが要求された場合、ログイン画面へ遷移する。
 
services.yaml
  app_2fa_required_handler:
        class: App\Security\TwoFactorAuth\AuthRequiredHandler

class AuthRequiredHandler implements AuthenticationRequiredHandlerInterface
{
  public function onAuthenticationRequired(Request $request, TokenInterface $token): Response
  {
      $this->tokenStorage->setToken(new AnonymousToken('', 'anon.'));
      throw new AccessDeniedException();
  }
}
参考になれば幸いです。