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も書き方が変わり、以下のようになりました。
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とUserNotFoundException
はAuthenticationException
を継承しています。
AuthenticationException
をキャッチすると、Authenticator::onAuthenticationFailure()
でレスポンスオブジェクトを作成した後にLoginFailureEvent
が発火し、クッキー情報をきれいにしたりなど、各処理が実行されてレスポンスオブジェクトに則った結果を返します。
認証が成功すると
成功すると、Authenticator::onAuthenticationSuccess()
でレスポンスオブジェクトを作成した後にLoginSuccessEvent
が発火し、ログイン試行回数リセットなど、各処理が実行されてレスポンスオブジェクトに則った結果を返します。
で、結局何が良くなったの?
以前GuardコンポーネントでのAuthenticatorを見てみましょう。
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
と違い、ユーザバッジと任意のバッジのみがセットできるパスポートです。
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()
はCheckPassportEvent
、checkPostAuth()
はLoginSuccessEvent
が発火したときに実行されます。
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('有効期限切れです。');
}
}
}