🎻

[2021年版] バックエンドがSymfony5なSPAをFirebase Authenticationでユーザー認証できるようにする方法

2021/09/01に公開

はじめに

この記事は、以前僕が書いた バックエンドがSymfonyなSPAをFirebase Authenticationでユーザー認証できるようにする | QUARTETCOM TECH BLOG という記事の2021年版です。

Symfonyでは Security Component を使ってアプリケーションにユーザー認証機能を追加することができますが、

  • サインアップ時のメールアドレス認証(本人確認)の処理
  • メールアドレス変更時のメールアドレス認証(本人確認)の処理
  • パスワードリセットの処理
  • SNSアカウントによるサインアップ/ログイン機能

などをすべて自前で実装しようと思うと結構大変なので、Firebase Authentication を使って楽してみましょう、という内容です。

Symfony 5.1で認証システムが大きく変更になった ことで記事内のサンプルコードの書き方などが陳腐化してしまったので、2021年版として改めてまとめておくことにしました。

ということで、今回はバックエンドにSymfony5(5.1+)を使っているSPAがあると仮定して、そのユーザー認証機構にFirebase Authenticationを使用する方法について解説してみたいと思います。

Firebase Authenticationとは

公式サイト によると、

Firebase Authentication には、バックエンド サービス、使いやすい SDK、アプリでのユーザー認証に使用できる UI ライブラリが用意されています。Firebase Authentication では、パスワード、電話番号、一般的なフェデレーション ID プロバイダ(Google、Facebook、Twitter)などを使用した認証を行うことができます。

とのことです。

色々な方法でのユーザー認証をFirebaseが一手に担ってくれて、こちらはSDKを使ってその機能にアクセスするだけでよいのでとても楽ができそうです。

大まかな流れ

SymfonyアプリでFirebase Authenticationを使うための大まかな流れは以下のようになります。

前提として、Firebaseの Webコンソール 上で各種設定(認証方法のオン/オフ、OAuthのクレデンシャルの登録など)を済ませておいてください🙏

  1. フロントエンドからFirebaseにログインしてIDトークン(中身はJWT)をもらう
  2. 認証が必要なリソースにアクセスする際に、リクエストの Authorization ヘッダーでIDトークンを送る
  3. Symfony側でFirebase SDKを使ってIDトークンをデコード&検証し、Firebaseから該当するユーザーのUUIDを取得する
  4. Symfony側でそのUUIDに対応するユーザーをログイン状態にする

フロントエンドの実装

上記の前半 1 2 にあたるフロントエンドの実装方法についてはこの記事では割愛します🙏

が、以下に挙げたあたりの公式ドキュメントを参考にすれば、そんなに難しいことはないと思います👌

Angularを使っている場合

もしフロントエンドにAngularを使っている場合は、Angular公式の angular/angularfire を使うと便利です。

Angularを使っていてバックエンドがGraphQLでクライアントにApollo Angularを使っている場合

だいぶニッチな話ですが笑、僕が最近Symfony5 + API Platform + Angular + Apollo AngularでSPAを作っていて、Apollo AngularのリクエストヘッダーにFirebase AuthenticationのIDトークンを付加するやり方がパッと分からなかったので、一応メモとして残しておきます。

表示する

基本的には以下の公式ドキュメントのようにしてApollo Angularのリクエストヘッダーをカスタマイズします。

Authentication | Apollo Angular

Firebase Authenticationとの併用を実践しているブログ記事などは見つけることができなかったのですが、YouTubeに実践動画があったので参考になりました。

Hasura Authentication with JWT Firebase and Angular 9 [tutorial, 2020] - YouTube

この動画の9:26あたりから該当するコードが見られます。

結論としては、GraphQLModule を以下のような感じで実装すれば対応できます✋(ここでは細かい説明は割愛します🙏)

export function createApollo(
  httpLink: HttpLink,
  afa: AngularFireAuth,
): ApolloClientOptions<any> {
  const auth = setContext(async () => {
    const token = await afa.idToken.pipe(take(1)).toPromise()
    return {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    }
  })
  return {
    link: ApolloLink.from([
      auth,
      httpLink.create({uri: environment.graphqlUri}),
    ]),
    cache: new InMemoryCache(),
  }
}

@NgModule({
  providers: [
    {
      provide: APOLLO_OPTIONS,
      useFactory: createApollo,
      deps: [HttpLink, AngularFireAuth],
    },
  ],
})
export class GraphQLModule {}

バックエンドの実装

さて、ここからが本稿の本題です。

上記「大まかな流れ」の後半部分(フロントエンドからIDトークンをもらい、それを元にFirebase SDKを使ってFirebaseからユーザー情報を取得する)を、Symfony 5.1+ の新しい認証システム使って実装していきます。

新しい認証システム自体の使い方は以下の公式ドキュメントにまとまっています。

Using the new Authenticator-based Security (Symfony Docs)

以下、Firebase Authenticationとの連携の仕方も含めて、順を追って具体的な実装方法を解説していきます✋

1. PHP用のFirebase SDKをインストール

まず、Symfony側でIDトークンをデコード&検証するために、PHP用のFirebase SDKである kreait/firebase-php を導入します。

$ composer require kreait/firebase-php

公式ドキュメント に従い、SDKの初期化にはサービスアカウントを使うことにします。

Firebaseの Webコンソール 上で目的のプロジェクトを開いて プロジェクトを設定 > サービスアカウント と進み、新しい秘密鍵の生成 で秘密鍵のJSONファイルをダウンロードします。

これを firebase_credentials.json などの分かりやすいファイル名にリネームした上で、プロジェクトルートなどに配置して .gitignore に追記しておきましょう。

以下のように config/services.yaml に追記することで、このファイルのパスをサービスクラスに自動でDIされるようにしておくと便利です。

services:
    _defaults:
        bind:
            $firebaseCredentialsPath: '%kernel.project_dir%/firebase_credentials.json'

これで、 $firebaseCredentialsPath という変数名の引数に自動でこのファイルのパスを表す文字列がインジェクトされるようになります。(参考

2. UserクラスにFirebase上のユーザーUIDを持たせる

Firebase SDKを使ってFirebaseからユーザーUIDを取得したあと、それをSymfony上のユーザーと対応づけるために、UserクラスにFirebaseのユーザーUIDを持たせておきます。

class User implements UserInterface
{
    // ...

+   /**
+    * @ORM\Column(type="string", length=255, unique=true)
+    */
+   private string $firebaseUid;

    // ...
}

DBマイグレーションも忘れずに。

3. Authenticatorクラスを実装する

IDトークンをデコード&検証した上でSymfony側の対応するユーザーをログイン状態にするためのAuthenticatorクラスを以下のような内容で実装します。

// src/Security/FirebaseIdTokenAuthenticator.php

namespace App\Security;

use App\Repository\UserRepository;
use Firebase\Auth\Token\Exception\InvalidToken;
use Kreait\Firebase\Contract\Auth;
use Kreait\Firebase\Exception\InvalidArgumentException;
use Kreait\Firebase\Factory;
use Lcobucci\JWT\UnencryptedToken;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class FirebaseIdTokenAuthenticator extends AbstractAuthenticator
{
    private Auth $auth;
    private UserRepository $userRepository;

    public function __construct(string $firebaseCredentialsPath, UserRepository $userRepository)
    {
        try {
            $factory = (new Factory)->withServiceAccount($firebaseCredentialsPath);
        } catch (InvalidArgumentException $e) {
            throw new \LogicException('"/firebase_credentials.json" is not placed');
        }

        $this->auth = $factory->createAuth();
        $this->userRepository = $userRepository;
    }

    public function supports(Request $request): ?bool
    {
        return $request->headers->has('Authorization');
    }

    public function authenticate(Request $request): PassportInterface
    {
        $idToken = preg_replace('/^Bearer +/', '', $request->headers->get('Authorization'));

        if ($idToken === null) {
            // 401 Unauthorized with custom message
            throw new CustomUserMessageAuthenticationException('No firebase id-token provided');
        }

        try {
            /** @var UnencryptedToken $verifiedIdToken */
            $verifiedIdToken = $this->auth->verifyIdToken($idToken);
        } catch (InvalidToken $e) {
            throw new CustomUserMessageAuthenticationException(sprintf('The firebase id-token is invalid: %s', $e->getMessage()));
        } catch (\InvalidArgumentException $e) {
            throw new CustomUserMessageAuthenticationException(sprintf('The firebase id-token could not be parsed: %s', $e->getMessage()));
        }

        $firebaseUid = $verifiedIdToken->claims()->get('sub');

        // if the correct firebase user is not registered to Symfony app, register it.
        $firebaseUid = $this->userRepository->findOrCreate($firebaseUid)->firebaseUid;

        return new SelfValidatingPassport(new UserBadge($firebaseUid));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        // on success, let the request continue
        return null;
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        $data = [
            // you may want to customize or obfuscate the message first
            'message' => strtr($exception->getMessageKey(), $exception->getMessageData())
        ];

        return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
    }
}

ポイントとしては、

  • Googleサービスアカウントの秘密鍵( $firebaseCredentialsPath )を使って Firebase SDKを初期化している(コンストラクタ)
  • Authorization ヘッダーを持つリクエストのみを認証の対象にしている(supports()
  • Authorization: Bearer {IDトークン} という形式でIDトークンを読み取っている(authenticate() の前半部分)
  • Firebase SDKを使ってIDトークンを検証し、FirebaseからユーザーUIDを取得している(authenticate() の後半部分)
  • FirebaseのユーザーUIDをもとにSymfony側のユーザーを findOrCreate() している(authenticate() の後半部分)
    • ユーザーのサインアップ時に、何かの手違いでFirebase側にだけユーザーが作られてSymfony側でユーザーが作られていないということがあり得るので、フロントエンドから正しいユーザーUIDが送られてきたけど対応するユーザーがSymfony側に見つからないという場合には、その時点でSymfony側に新たにユーザーを作るのが妥当という考え
  • SelfValidatingPassport クラス を使って、ユーザーUIDに該当するユーザーをログイン状態にしている(authenticate() の最後)
    • 後述の security.yaml の設定で property: firebaseUid としているのでユーザーUIDをもとに該当するユーザーを引ける

といったあたりでしょうか。

PassportBadge でユーザーを認証認可するところが新しい認証システムの要点だと思いますが、今回のように認証はFirebase SDKで行ってSymfonyとしては認可する/しないの判断をするだけというケースでは、あまりこの機構を意識することはなかったですね。

4. security.yaml を設定する

最後に config/packages/security.yaml を設定して、ここまでに実装してきたものたちがちゃんと連動するようにしてあげます。

特に、新しい認証システムを使う場合は enable_authenticator_manager: true を設定する必要があるので忘れないようにしましょう。

具体的には以下のような内容になるかと思います。

security:
    enable_authenticator_manager: true

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: firebaseUid
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            lazy: true
            provider: app_user_provider

            custom_authenticators:
                - App\Security\FirebaseIdTokenAuthenticator

    access_control:
        - { path: ^/, role: ROLE_USER }

まとめ

というわけで、バックエンドにSymfony5(5.1+)を使っているSPAで認証機構にFirebase Authenticationを使用する方法について解説しました。

Symfony + Firebase Authenticationの日本語情報はあまり見たことがないのと、一応 Symfony 5.1+ の新しい認証システム に対応した内容になっているので、どなたかの参考になればいいなと思います😇

参考リンク

GitHubで編集を提案

Discussion