🎻

Symfony製のMPAのユーザー認証にFirebase AuthenticationなどのIDaaSを使う

2023/12/15に公開

Symfony Advent Calendar 2023 の15日目の記事です!🎄✨

Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲

昨日は @polidog さんの こんにちわSymfony7 with Flyio でした✨

はじめに

Symfony製のMPA(Multi Page Application)のユーザー認証にIDaaSを使う方法をご紹介します。Firebase Authenticationを使うケースを例に、具体的に順を追って解説していきます。

SPAに導入する例として以下の記事などもあわせてご参照ください。

https://zenn.dev/ttskch/articles/8453c5e484c3e2

1. kreait/firebase-bundle を導入

Firebase Authとのやりとりには kreait/firebase-bundle を活用します。

$ composer require kreait/firebase-bundle

テスト時にはFirebase Authには接続せず、

https://zenn.dev/ttskch/articles/a5f8635e7e0f57

この記事で紹介したような方法で擬似的にログインさせる想定なので、config/bundles.php は以下のように変更しておきます。

return [
    // ...
    Kreait\Firebase\Symfony\Bundle\FirebaseBundle::class => ['prod' => true, 'dev' => true],
];

また、config/packages/firebase.yaml の内容は以下のようにします。

kreait_firebase:
  projects:
    default:
      credentials: '%kernel.project_dir%/firebase-credentials.json'

firebase-credentials.json に実際のサービスアカウントの秘密鍵ファイルを配置する想定です。忘れずに .gitignore しておきましょう。

# .gitignore
/firebase-credentials*.json

こんな感じにしておくと、例えば firebase-credentials-dev.json firebase-credentials-stg.json などをローカルに置いておきつつ、必要に応じて firebase-credentials.json にリネーム(コピー)すればすぐに接続を切り替えられて便利です。

本番環境などファイルシステムが揮発的な環境においては、環境変数に設定した内容で firebase-credentials.json を生成できるようにしておきたいので、composer.jsonscripts.auto-scripts に以下のような1行を追記しておきます。ファイルシステムに firebase-credentials.json がなければ FIREBASE_CREDENTIALS 環境変数の内容でファイルを作成する、というものです。

  {
      // ...
  
      "scripts": {
          "auto-scripts": {
              "cache:clear": "symfony-cmd",
              "assets:install %PUBLIC_DIR%": "symfony-cmd",
+             "if [ ! -f firebase-credentials.json ]; then php -r \"echo getenv('FIREBASE_CREDENTIALS');\" > firebase-credentials.json; fi": "script"
          },
  
          // ...
  
      },
  
      // ...
  }

2. ユーザーエンティティを用意

<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(unique: true)]
    private ?string $uuid = null;

    /**
     * @var array<string>
     */
    #[ORM\Column]
    private array $roles = ['ROLE_USER'];

    /**
     * {@inheritDoc}
     */
    public function getRoles(): array
    {
        return array_values(array_filter($this->roles));
    }

    // 他の getter/setter は略

    /**
     * {@inheritDoc}
     */
    public function getUserIdentifier(): string
    {
        return $this->uuid ?? '';
    }

    /**
     * {@inheritDoc}
     */
    public function eraseCredentials(): void
    {
    }
}

例として $id $uuid $roles しかプロパティを持たないシンプルな内容としています。

$uuid プロパティにFirebase AuthのユーザーUIDの値を格納し、これによってFirebase AuthのユーザーとSymfonyのユーザーエンティティを紐付ける想定です。

3. security.yaml を設定

config/packages/security.yaml を以下のように設定します。

security:
  providers:
    user_provider:
      entity:
        class: App\Entity\User
        property: uuid
  firewalls:
    dev:
      pattern: ^/(_(profiler|wdt)|css|images|js)/
      security: false
    main:
      lazy: true
      provider: user_provider
      custom_authenticator: App\Security\LoginFormAuthenticator
      logout:
        path: user_logout
        target: user_login

      remember_me:
        secret: '%kernel.secret%'
        # デフォルトで [password] になっているので変更
        signature_properties: [uuid]

  access_control:
    - { path: ^/user/(signup|login|resetPassword)$, role: PUBLIC_ACCESS }
    - { path: ^/, role: ROLE_USER }

user_provider というプロバイダーを定義し、App\Entity\User クラスの uuid プロパティをキーとするよう設定しています。

main ファイアウォールでこの user_provider を使用し、App\Security\LoginFormAuthenticator というカスタムAuthenticatorを使用するよう設定しています。このカスタムAuthenticatorの実装については次章で解説します。

また、remember_me.signature_properties[uuid] をセットしている点にも要注目です。

ドキュメント によると、このフィールドのデフォルト値は [password] となっており、それによってパスワードが変更されたらRemember meクッキーが無効になるようになっているのですが、今回は $password プロパティを持たないユーザーエンティティを使用したいため、[uuid] など有効かつ無害な値で上書きしておかないと、実行時にエラーになってしまいます。

4. カスタムAuthenticatorを実装

security.yaml で使用を宣言していたカスタムAuthenticatorを実装します。Firebase AuthのIDトークンを検証してユーザーエンティティとして認証するAuthenticatorです。

これに先駆けて、まずは kreait/firebase-bundle を使ってFirebase Authにログインするためのサービスクラスを実装します。

<?php
// src/Security/Auth/Auth.php

declare(strict_types=1);

namespace App\Security\Auth;

use App\Exception\Security\Auth\AccessTokenExpiredException;
use App\Exception\Security\Auth\AccessTokenInvalidException;
use App\Exception\Security\Auth\AccessTokenRevokedException;
use Kreait\Firebase\Contract\Auth as Firebase;
use Kreait\Firebase\Exception\Auth\FailedToVerifyToken;
use Kreait\Firebase\Exception\Auth\RevokedIdToken;

final readonly class Auth
{
    public function __construct(private Firebase $firebase)
    {
    }

    public function login(string $accessToken): LoginResult
    {
        assert('' !== $accessToken);

        try {
            $verifiedIdToken = $this->firebase->verifyIdToken($accessToken, leewayInSeconds: 3); // フロントエンドとの時計のズレによる失敗を考慮
        } catch (FailedToVerifyToken $e) {
            throw new AccessTokenInvalidException(previous: $e); // Firebase Authのエラーをドメインレイヤーの例外クラスに丸める
        } catch (RevokedIdToken $e) {
            throw new AccessTokenRevokedException(previous: $e); // Firebase Authのエラーをドメインレイヤーの例外クラスに丸める
        }

        if ($verifiedIdToken->isExpired(new \DateTime())) {
            throw new AccessTokenExpiredException(); // Firebase Authのエラーをドメインレイヤーの例外クラスに丸める
        }

        $uuid = strval($verifiedIdToken->claims()->get('sub'));
        // 他にも例えばメールアドレスを取得したければこんな感じで
        // $email = strval($verifiedIdToken->claims()->get('email'));

        return new LoginResult($uuid/*, $email*/);
    }
}
<?php
// src/Security/Auth/LoginResult.php

declare(strict_types=1);

namespace App\Security\Auth;

final readonly class LoginResult
{
    public function __construct(
        public string $uuid,
        // public string $email,
    ) {
    }
}

これで、Firebase AuthのIDトークンを渡せばログインを試行してくれて、成功時にはユーザーUIDを持った LoginResult クラスのインスタンスを返してくれるサービスができました。

このサービスを使って、カスタムAuthenticatorを実装します。

<?php
// src/Security/LoginFormAuthenticator.php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Exception\Security\Auth\AccessTokenExpiredException;
use App\Exception\Security\Auth\AccessTokenInvalidException;
use App\Exception\Security\Auth\AccessTokenRevokedException;
use App\Repository\UserRepository;
use App\Security\Auth\Auth;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'user_login';

    public function __construct(
        private readonly Auth $auth,
        private readonly UserRepository $userRepository,
        private readonly EntityManagerInterface $em,
        private readonly UrlGeneratorInterface $urlGenerator,
    ) {
    }

    public function authenticate(Request $request): Passport
    {
        $accessToken = $request->request->get('access_token') ?? '';

        try {
            $loginResult = $this->auth->login($accessToken);

            return new SelfValidatingPassport(new UserBadge($loginResult->uuid, $this->loadUser(...)), [
                new CsrfTokenBadge('authenticate', strval($request->request->get('_csrf_token'))),
                new RememberMeBadge(),
            ]);
        } catch (AccessTokenExpiredException|AccessTokenInvalidException|AccessTokenRevokedException $e) {
            throw new CustomUserMessageAuthenticationException($e->getMessage(), previous: $e);
        }
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('home_index'));
    }

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

    private function loadUser(string $uuid): UserInterface
    {
        $user = $this->userRepository->findOneBy(['uuid' => $uuid]);

        // ユーザーが未作成なら作成する
        if (!$user) {
            $user = (new User())->setUuid($uuid);
            $this->em->persist($user);
            $this->em->flush();
        }

        return $user;
    }
}
  1. フロントエンドからPOSTリクエストのaccess_token パラメータでFirebase AuthのIDトークンを受け取り
  2. Auth サービスを使ってログインを試行し、LoginResult 経由でユーザーUIDを受け取り
  3. ユーザーUIDでユーザーエンティティを引いて
  4. そのユーザーを認証する

という流れです。

ユーザーUIDでユーザーエンティティを引いた際に、ユーザーが見つからなければその場でユーザーを作成して、作成したユーザーを認証する という実装になっている点に注目してください。

この時点でFirebase AuthのIDトークンが正しいことは検証済みなので、正しいIDトークンから得られたユーザーUIDを持ったユーザーエンティティがまだ存在していなければ作成することは何の問題もありません。というか、今回実装している認証機構においては、「ユーザー登録 = Firebase Authにユーザーを登録し、そのIDトークンでもってSymfonyアプリケーションに初めてログインすること」 なので、初めてのログインにおいてはここでユーザーエンティティが作成されるのが設計上妥当です。

これで、Firebase AuthのIDトークンをフォームで送りさえすればSymfonyの認証機構においてユーザーエンティティが認証できるようになりました。

5. ログインフォームを実装

ではそのログインフォームを実装しましょう。

今回は、Firebase Authのログインプロバイダとしてはメール/パスワードとGoogleの2つに対応することにしてみます。

まずコントローラ側ですが、これは通常のログインフォームを実装する場合とまったく同じ感じで大丈夫です。

final class UserController extends AbstractController
{
    // ...

    #[Route(path: '/user/login', name: 'user_login', methods: ['GET', 'POST'])]
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        if ($this->getUser()) {
            return $this->redirectToRoute('home_index');
        }

        $error = $authenticationUtils->getLastAuthenticationError();

        return $this->render('user/login.html.twig', [
            'error' => $error,
        ]);
    }

    // ...
}

次にビューの実装ですが、ポイントとしては、

  • Firebase AuthにログインしてIDトークンをもらってくる処理
  • もらってきたIDトークンをSymfonyバックエンドに送るためのフォーム

の2つが必要という点です。

装飾を省いてHTMLの骨子を示すと、以下のようなイメージです。

<div id="error" style="display:none">
{%- if error -%}{{ error.messageKey|trans(error.messageData, 'security') }}{%- endif -%}
</div>

<form id="idaas">
  <input type="email" placeholder="メールアドレス" id="email" name="email" required autofocus>
  <input type="password" placeholder="パスワード" id="password" name="password" required>
  <div class="mb-2">
    <input type="checkbox" id="remember_me" checked onchange="document.querySelector(`#app [name='_remember_me']`).value = this.checked ? 1 : 0">
    <label for="remember_me">ログイン状態を記憶する</label>
  </div>
  <button type="submit">ログイン</button>
</form>

<span>または</span>

<button type="button" id="google">Googleアカウントでログイン</button>

<form id="app" action="{{ path('user_login') }}" method="post">
  <input type="hidden" name="access_token">
  <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
  <input type="hidden" name="_remember_me" value="1">
</form>

form#idaas のサブミットおよび button#google のクリックを契機にFirebase Authへのログインを試行し、Firebase AuthからIDトークンを受け取った後で form#app にその値をセットしてサブミットする、という流れを想定しています。

これらの処理はJSでハンドリングする必要があります。

$ npm i -D firebase

しておいた上で(Webpack Encore Bundleを導入している想定です)、例えば以下のようなJSを書きます。

import '/path/to/firebase-app-initializer'
import {FirebaseError} from 'firebase/app'
import {
  getAuth,
  signInWithEmailAndPassword,
  signInWithPopup,
  GoogleAuthProvider,
} from 'firebase/auth'

const auth = getAuth()

const $error = document.querySelector('#error')
const $idaasForm = document.querySelector('form#idaas')
const $appForm = document.querySelector('form#app')
const $submitButton = $idaasForm.querySelector('[type="submit"]')
const $googleButton = document.querySelector('button#google')

// アプリレイヤーのエラーがもともと出力されていたら画面に表示
if ($error.textContent) {
  $error.style.display = 'block'
}

$idaasForm.addEventListener('submit', async (ev) => {
  ev.preventDefault()

  // 一旦ログインボタンを無効化
  $submitButton.setAttribute('disabled', true)

  const email = $idaasForm.querySelector('[name="email"]').value
  const password = $idaasForm.querySelector('[name="password"]').value

  try {
    // メールアドレスとパスワードでFirebase Authにログイン
    const userCredential = await signInWithEmailAndPassword(
      auth,
      email,
      password,
    )
    const idToken = await userCredential.user.getIdToken()

    // IDトークンをセットしてアプリにログイン
    $appForm.querySelector('[name="access_token"]').value = idToken
    $appForm.submit()
  } catch (error) {
    let errorMessage
    if (error instanceof FirebaseError) {
      errorMessage = {
        'auth/invalid-login-credentials': 'メールアドレスまたはパスワードが間違っています。',
        'auth/user-disabled': 'そのアカウントは現在利用できません。',
        'auth/too-many-requests': 'ログインに連続して失敗したため、一時的にアカウントがロックされました。しばらく経ってから再度お試しください。',
      }[error.code]
    }
    if (!errorMessage) {
      errorMessage ??= '予期しないエラーが発生しました。大変恐れ入りますが、しばらく経ってから再度お試しください。'
      console.error(error)
    }

    // IDaaSレイヤーのエラーを画面に表示
    $error.style.display = 'block'
    $error.textContent = errorMessage

    // ログインボタンを再度有効化
    $submitButton.removeAttribute('disabled')
  }
})

$googleButton.addEventListener('click', async (ev) => {
  // GoogleアカウントでFirebase Authにログイン
  const userCredential = await signInWithPopup(auth, new GoogleAuthProvider())
  const idToken = await userCredential.user.getIdToken()

  // IDトークンをセットしてアプリにログイン
  $appForm.querySelector('[name="access_token"]').value = idToken
  $appForm.submit()
})

詳細な説明は省きますが、頭から読んでみれば大体何をやっているかは分かっていただけるかと思います🙏

1行目の import '/path/to/firebase-app-initializer' だけ初出ですが、これはフロントエンドのFirebase SDKを初期化するためのコード(下図の画面で見られるやつ)を /path/to/firebase-app-initializer.js という別のファイルに逃しているだけです。

この初期化コードは使用するFirebaseプロジェクトごとに異なるため、バックエンド用の firebase-credentials.json をそうしたように、このコードも firebase-app-initializer.js というファイル名で独立させ、環境変数から生成できるようにしておきます。

  // composer.json
  {
      // ...
  
      "scripts": {
          "auto-scripts": {
              "cache:clear": "symfony-cmd",
              "assets:install %PUBLIC_DIR%": "symfony-cmd",
              "if [ ! -f firebase-credentials.json ]; then php -r \"echo getenv('FIREBASE_CREDENTIALS');\" > firebase-credentials.json; fi": "script",
+             "if [ ! -f firebase-app-initializer.js ]; then php -r \"echo getenv('FIREBASE_APP_INITIALIZER');\" > firebase-app-initializer.js; fi": "script"
          },
  
          // ...
  
      },
  
      // ...
  }
  # .gitignore
  /firebase-credentials*.json
+ /firebase-app-initializer*.js

以上で、Firebase Authにユーザーが登録されている状態での、メールアドレス/パスワードまたはGoogleアカウントによるログインが実装完了しました🙌

ログインフォームをいくらか装飾して下図のような感じで使うイメージです。

6. ユーザー登録とパスワード再設定

Firebase Authへのユーザー登録とFirebase Authでのパスワード再設定も実装する場合は、それぞれ専用の画面を用意した上で、ログイン画面のJSと同様にJSからFirebase Auth SDKを使って然るべき処理を行うことになります。

これらについて詳細に解説するとさすがに記事が長くなりすぎるので今回は割愛します🙏

大枠はログイン画面のJSとほとんど変わらないので、ログイン画面のJSの例を参考にしていただければ問題なく実装できると思います。

おわりに

というわけで、Symfony製のMPA(Multi Page Application)のユーザー認証にFirebase AuthenticationなどのIDaaSを使う方法をご紹介しました。

多少のJSを書くことで

  • メールアドレス/パスワードによる認証
  • 主要なSNSアカウントによるOAuth
  • メールアドレスによるユーザー登録時およびメールアドレス変更時のメールアドレス所有者確認
  • パスワード再設定

あたりを自分で実装しなくてよくなるのが結構嬉しいので、僕はこの辺りが要件に含まれるMPAを作るときにはFirebase Authを使っています。(ガッツリWebサービスを作る場合は最初からSPAにするので、MPAでこの辺りが要件に含まれる案件は正直稀ですが)

何かしら参考になれば嬉しいです🍵

Symfony Advent Calendar 2023、明日は空きです🥺どなたかぜひご参加ください!

GitHubで編集を提案

Discussion