0
Help us understand the problem. What are the problem?

posted at

updated at

Symfonyのパスポート

Symfony Advent Calendar 2021の記事です。
Symfony5.1あたりから新しくなった認証に必要なパスポートについて紹介します。

新しくなった認証まわり

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

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

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

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

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は以下のような感じで実行されます。

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

パスポート(Passport)とは

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

        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チェックなどのユーザと本人確認以外のチェックを行うためのバッジで、複数セットできます。
主な追加バッジに関しては、こちらで確認できます。

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

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

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

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

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

認証が失敗すると

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

認証が成功すると

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

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

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

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と違い、ユーザバッジと任意のバッジのみがセットできるパスポートです。

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という機能があります。UserCheckerInterfaceの実装クラスに処理を書きますが、このインターフェイスにはcheckPreAuth()というメソッドとcheckPostAuth()というメソッドがあり、認証前のユーザステータスの確認(認証させるに値しないユーザかなど)と認証後のステータスの確認(認証できるユーザだけど有効期限が切れてるなど)を行います。

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

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

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('有効期限切れです。');
        }
    }
}
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
0
Help us understand the problem. What are the problem?