Symfony Advent Calendar 2018 19日目の記事です!
昨日は @unio さんの Webpack Encoreをwebpack4に書き直す でした。

はじめに

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

しかし、メールアドレスとパスワードを使った一般的なユーザー認証だけを見ても、

  • サインアップ時のメールアドレス認証(本人確認)の処理
  • メールアドレス変更時のメールアドレス認証(本人確認)の処理
  • パスワードリセットの処理

など、意外と実装すべきものが多くて面倒だったりします。

また、昨今のWebアプリではお馴染みのSNSアカウントによるサインアップ/ログイン機能を提供したい場合などは、さらなる実装が必要です。(ほとんどバンドルを追加して設定するだけではありますが)

そこで、ユーザー認証の機能は自前では実装せず、Firebase Authentication などの文明の利器を使ってみるのも手ではないでしょうか。

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

どうせFirebase Authentication使うならバックエンドもSymfonyじゃなくFirebase使えばいいじゃん、というツッコミはここではスルーさせていただきます :sweat_smile:

Firebase Authenticationとは

公式サイトによると、

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

https://firebase.google.com/docs/auth/?hl=ja

とのことです。

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

大まかな流れ

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

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

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

今回は フロントエンドからFirebaseにログインしてIDトークンをもらう の部分の解説は割愛させていただきます :pray: が、以下に挙げたあたりの公式ドキュメントを参考にすれば、フロントエンドの実装はそんなに難しくないと思います。

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

Guard Componentを使ったカスタム認証機構の実装

さて、やっと本稿の本題ですが、上記「大まかな流れ」の後半部分(フロントエンドからIDトークンをもらい、それを元にFirebase SDKを使ってFirebaseからユーザー情報を取得する)を実装するには、Guard Componentというコンポーネントを使えばいいようです。

以下、上記リンク先のドキュメントの流れに沿って具体的な実装方法を解説していきます。

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

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

// src/Entity/User.php

// ...

class User implements UserInterface
{
    // ...

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

    // ...
}

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

2. カスタム認証用のAuthenticatorクラスを実装する

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

$ composer require kreait/firebase-php

この上で、以下のような内容でAuthenticatorクラスを実装します。

// src/Security/FirebaseJWTAuthenticator.php

namespace App\Security;

use Firebase\Auth\Token\Exception\InvalidToken;
use Kreait\Firebase;
use Kreait\Firebase\Factory;
use Kreait\Firebase\ServiceAccount;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

class FirebaseJWTAuthenticator extends AbstractGuardAuthenticator
{
    /**
     * @var Firebase
     */
    private $firebase;

    public function __construct(string $serviceAccountKeyJson)
    {
        // @see https://firebase-php.readthedocs.io/en/latest/setup.html
        $this->firebase = (new Factory())
            ->withServiceAccount(ServiceAccount::fromJson($serviceAccountKeyJson))
            ->create()
        ;
    }

    /**
     * {@inheritdoc}
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        return new JsonResponse(['message' => 'Authentication Required'], Response::HTTP_UNAUTHORIZED);
    }

    /**
     * {@inheritdoc}
     */
    public function supports(Request $request)
    {
        return $request->headers->has('Authorization');
    }

    /**
     * {@inheritdoc}
     */
    public function getCredentials(Request $request)
    {
        preg_match('/Bearer +(.+)$/', $request->headers->get('Authorization'), $m);

        if (!isset($m[1])) {
            throw new BadRequestHttpException('Invalid Authorization Header');
        }

        return [
            'id_token' => $m[1],
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        try {
            $verifiedIdToken = $this->firebase->getAuth()->verifyIdToken($credentials['id_token']);
        } catch (InvalidToken $e) {
            return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_BAD_REQUEST);
        }

        // @see https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
        $uid = $verifiedIdToken->getClaim('sub');

        return $userProvider->loadUserByUsername($uid);
    }

    /**
     * {@inheritdoc}
     */
    public function checkCredentials($credentials, UserInterface $user)
    {
        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        return new JsonResponse(['message' => strtr($exception->getMessageKey(), $exception->getMessageData())], Response::HTTP_FORBIDDEN);
    }

    /**
     * {@inheritdoc}
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
    }

    /**
     * {@inheritdoc}
     */
    public function supportsRememberMe()
    {
        return false;
    }
}

ポイントとしては、

  • GoogleサービスアカウントのクレデンシャルJSONを使ってFirebase SDKを初期化している(コンストラクタ)
  • Authorization ヘッダーを持つリクエストを認証の対象にしている(supports()
  • Authorization: Bearer {IDトークン} という形式でIDトークンを読み取っている(getCredentials()
  • Firebase SDKを使ってIDトークンを検証し、FirebaseからユーザーUIDを取得している(getUser()
  • FirebaseのユーザーUIDを $userProvider->loadUserByUsername($uid) してユーザーを取得している(getUser()
    • 後述の security.yaml の設定で property: firebaseUid としているのでこれが可能

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

3. security.yamlでAuthenticatorを設定する(Symfony4の例)

Symfony4の例になりますが、以下のような内容で設定する感じになると思います。

# config/packages/security.yaml
security:
    # ...

    providers:
        firebase_provider:
            entity:
                class: App\Entity\User
                property: firebaseUid

    firewalls:
        # ...

        api:
            pattern: ^/api/v\d+/
            anonymous: ~

            stateless: true

            provider: firebase_provider
            guard:
                authenticators:
                    - App\Security\FirebaseJWTAuthenticator

            # ...

おわりに

以上の方法で、Firebaseから取得したIDトークン(JWT)を使ってSymfony側でユーザー認証を行うことができます。

少し前に趣味プロジェクトで初めて導入してみたのですが、個人的にはかなり簡単に実装できて驚きました。ほとんど公式ドキュメントの焼き直しみたいな記事になってしまいましたが :sweat_smile: 、どこかの誰かのお役に立てば幸いです :pray: