Symfonyのパスポート

[Symfony Advent Calendar 2021](https://qiita.com/advent-calendar/2021/symfony)の記事です。
Symfony5.1あたりから新しくなった認証に必要なパスポートについて紹介します。

## 新しくなった認証まわり

Symfonyは以前まで主にGuardコンポーネントという認証の仕組みを利用していました。AbstractGuardAuthenticatorクラスを継承したクラスを作成し、そこに認証処理を書いていきました。Symfony5.1から新しくEvent-basedと呼ばれる仕組みが追加され、Symfony5.3からGuardコンポーネントがdeprecatedとなりました。

Event-basedでは、ざっくりと以下の3つのイベントが発火します。

| 名前 | 内容 |
|--|--|
| CheckPassportEvent | メインイベント。パスワードやCSRFなどの認証情報が正しいかチェック |
| LoginSuccessEvent | 認証情報が正しい場合に発火 |
| LoginFailureEvent | 認証情報が誤っている場合に発火 |

Authenticatorも書き方が変わり、以下のようになりました。

```php:src/Security/LoginFormAuthenticator.php

class LoginFormAuthenticator extends AbstractAuthenticator
{
    public function supports(Request $request): ?bool
    {
        // 認証処理を行う条件
        // 例：return $request->isMethod('POST') && $this->getLoginUrl($request) === $request->getPathInfo();
    }

    public function authenticate(Request $request): PassportInterface
    {
        // 認証用のパスポートを作る
        $email = $request->request->get('email', '');

        $request->getSession()->set(Security::LAST_USERNAME, $email);

        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($request->request->get('password', '')),
            [
                new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
            ]
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // 認証成功時の処理
        // 例： return new RedirectResponse($this->urlGenerator->generate('dashboard')); 
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        // 認証失敗時の処理
        // 例： return new RedirectResponse($this->getLoginUrl());
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }
}

```

このAuthenticatorとEventは以下のような感じで実行されます。

```plantuml

start
if (Authenticator::supports()) then (yes)
:Authenticator::authenticate(): Passport;
#application:CheckPassportEvent発火;
#application:各パスポートチェック;
if (パスポートチェックが全部OK!) then (yes)
:Authenticator::onAuthenticationSuccess();
#palegreen:LoginSuccessEvent発火;
#palegreen:各ログイン成功処理実行;
else (no)
:Authenticator::onAuthenticationFailure();
#pink:LoginFailureEvent発火;
#pink:各ログイン失敗処理実行;
endif
endif;
end

```

色がついている箇所が、イベント発火とイベントで実行される処理です。`Authenticator::authenticate()`で、新たに追加されたクラス、**Passport**オブジェクトが作成されてその後の処理に活かされています。


## パスポート（Passport）とは

パスポートとは、そのユーザを認証するときにチェックするオブジェクトです。パスポート作成時、バッジ（Badge）というものが複数セットされます。

```php
        return new Passport(
            new UserBadge($email),
            new PasswordCredentials($request->request->get('password', '')),
            [
                new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')),
            ]
        );
```

引数は3つあり、
1つ目はユーザバッジで、該当のユーザ取得と存在チェックをするためのバッジです。
2つ目は認証バッジで、パスワードなどの本人が確認できるかチェックするためのバッジです。ここは`PasswordCredentials`というパスワード確認用バッジか`CustomCredentials`というカスタマイズした本人確認ロジックが用意できるバッジのどちらかがセットできます。
3つ目は追加バッジで、CSRFチェックなどのユーザと本人確認以外のチェックを行うためのバッジで、複数セットできます。
主な追加バッジに関しては、[こちら](https://symfony.com/doc/current/security/custom_authenticator.html#passport-badges)で確認できます。

各バッジには`isResolved()`というメソッドが用意されており、これがtrueを返すとそのバッジは有効という扱いになります。
なお、UserBadgeだけ特殊で`isResolved()`は常にtrueを返しますが、ユーザが存在しないと`UserNotFoundException`を返すようになっています。

要はパスポートは、**認証に必要なチェック（バッジ）をまとめたもの**です。

## このパスポートができたらどうなるの？

パスポートができると、`CheckPassportEvent`が発火します。これが発火すると、各イベントリスナー（確認係）がパスポートにあるバッジを使ってこのパスポートが妥当かチェックし、問題なければバッジの`markResolved()`が実行され`isResolved()`がtrueを返すように値を調整します。

例えばCsrfTokenBadgeは`CsrfProtectionListener`が動作して、CSRFに関してだけチェックします。それぞれのバッジをそれぞれの確認係が、さまざまな視点でチェックを行います。

### 認証が失敗すると

`isResolved()`が1つでもfalseの場合は、`BadCredentialsException`が投げられます。このExceptionと`UserNotFoundException`は`AuthenticationException`を継承しています。
`AuthenticationException`をキャッチすると、`Authenticator::onAuthenticationFailure()`でレスポンスオブジェクトを作成した後に`LoginFailureEvent`が発火し、クッキー情報をきれいにしたりなど、各処理が実行されてレスポンスオブジェクトに則った結果を返します。

### 認証が成功すると

成功すると、`Authenticator::onAuthenticationSuccess()`でレスポンスオブジェクトを作成した後に`LoginSuccessEvent`が発火し、ログイン試行回数リセットなど、各処理が実行されてレスポンスオブジェクトに則った結果を返します。


## で、結局何が良くなったの？

以前GuardコンポーネントでのAuthenticatorを見てみましょう。

```php:src/Security/LoginFormAuthenticator.php
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
{
// 割愛

    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(
            Security::LAST_USERNAME,
            $credentials['email']
        );

        return $credentials;
    }

    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }

        $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);

        if (!$user) {
            // fail authentication with a custom error
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        return $user;
    }

    public function checkCredentials($credentials, UserInterface $user)
    {
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    /**
     * Used to upgrade (rehash) the user's password automatically over time.
     */
    public function getPassword($credentials): ?string
    {
        return $credentials['password'];
    }

//割愛
}
```

以前は、Authenticator内にユーザ取得やパスワードチェック、CSRFチェックなどが記載されていました。このAuthenticatorはSymfony consoleコマンドで自動で生成されるものの、開発者が好き勝手いじれますし、なにより少し煩雑でした。

新しいAuthenticatorでは、単にPassportオブジェクトを作るだけで、各種イベントリスナーがよしなにやってくれるので、Authenticator自体は非常にシンプルになり、開発者がいじれる範囲も狭くなりました。


それぞれ開発しているサービスに合わせたパスポートを作るだけで良くなっているので、Guardコンポーネントに比べると随分とシンプルで可読性が高くなっているといえるでしょう。

5.1以前から稼働している既存のサービスに関しては、継承部分をちょっと修正してパスポートを返す`authenticate()`メソッドを用意し、`config/packages/security.yaml`をちょこっと変えれば、比較的簡単に？新しいタイプの認証に変更することができるので、年明けあたりに是非一度試してみてください。

## 備考1: SelfValidatingPassport

`PasswordCredential`, `CustomCredentials`、いずれの本人確認も必要ない場合は、`SelfValidatingPassport`を利用します。
通常の`Passport`と違い、ユーザバッジと任意のバッジのみがセットできるパスポートです。

```php:src/Security/LoginFormAuthenticator
    public function authenticate(Request $request): Passport
    {
        $apiToken = $request->headers->get('X-AUTH-TOKEN');
        if (null === $apiToken) {
            throw new CustomUserMessageAuthenticationException('No API token provided');
        }

        return new SelfValidatingPassport(new UserBadge($apiToken));
    }
```

## 備考2: Passport::setAttribute(), Passport::getAttribute()

`Passport`には任意の属性を扱えるよう、`getAttribute()`, `setAttribute()`が用意されています。パスポートを扱っている他のメソッド（`Authenticator::createToken()`など）で、属性を受け取ることができるようになります。


## 備考3： UserCheckerについて

Symfonyには認証するユーザのステータスをチェックするために[UserChecker](https://symfony.com/doc/current/security/user_checkers.html)という機能があります。`UserCheckerInterface`の実装クラスに処理を書きますが、このインターフェイスには`checkPreAuth()`というメソッドと`checkPostAuth()`というメソッドがあり、認証前のユーザステータスの確認（認証させるに値しないユーザかなど）と認証後のステータスの確認（認証できるユーザだけど有効期限が切れてるなど）を行います。

※有効にするには`config/packages/security.yaml`に設定が必要です。

このそれぞれの確認ですが、`checkPreAuth()`は`CheckPassportEvent`、`checkPostAuth()`は`LoginSuccessEvent`が発火したときに実行されます。

```php:src/Security/CustomUserChecker.php
class CustomUserChecker implements UserCheckerInterface
{
    public function checkPreAuth(UserInterface $user)
    {
        if (!$user instanceof User) {
            return;
        }

        // 削除されてるので、本人確認を行う必要がない
        if ($user->isDeleted()) {
            throw new CustomUserMessageAccountStatusException('このアカウントは無効です。');
        }
    }

    public function checkPostAuth(UserInterface $user)
    {
        if (!$user instanceof User) {
            return;
        }

        // 本人確認後、アカウントのステータスによって認証を破棄する必要がある
        if ($user->expired()) {
            throw new AccountExpiredException('有効期限切れです。');
        }
    }
}
```