🚒

LaravelでFirebaseのID Tokenで認証するGuardの実装

2021/10/21に公開

マナリンクではReact NativeアプリでFirebase認証を採用しており、バックエンドにLaravel製のAPIを使っていることから、Laravel上でFirebaseのID Tokenを使ったユーザーの特定(認証)を行う必要があります。

本記事ではLaravelのGuard機能を使ってFirebaseのID Tokenによる認証機能を実装する方法を示します。

実装手順

config/auth.php

auth.phpguardsに対して、firebaseというドライバを指定します。apiはガード名です。ここを変えた場合は以降のソース中でのapiの指定も置換する必要があります。

    'guards' => [
        'api' => [
            'driver' => 'firebase',
        ],
    ],

サービスプロバイダの実装

ガードに指定した'firebase'ドライバに対して、実際に動作するGuardクラスを紐付けます。FirebaseGuardについては次節で解説します。

ここではFirebaseAuthenticationServiceProviderというクラス名にします。

Auth::extendメソッドを実行することで、ドライバに対応したガード名でAuth::guard('api')などと使えるようになります。
また、Auth::viaRequestメソッドを実行することで、後述しますがmiddlewareメソッドにGuardを指定してルート単位で認証チェックすることができます。

マナリンクの場合、ローカル開発時ではFirebase Emulatorを使っています。そのためEmulatorにFirebase PHP SDKが非対応のため専用で実装クラスを作らないといけない点がポイントかもしれません。なので環境変数に応じて処理を切り替えられるようにInterfaceを挟んでいます。

FirebaseAuthenticationServiceProvider.php
<?php
declare(strict_types=1);

namespace App\Providers;

use App\Providers\Guards\Firebase\EmulatorVerifyIdToken;
use App\Providers\Guards\Firebase\FirebaseGuard;
use App\Providers\Guards\Firebase\LiveVerifyIdToken;
use App\Providers\Guards\Firebase\VerifyIdTokenInterface;
use Illuminate\Support\Facades\Auth;
use Kreait\Firebase\JWT\IdTokenVerifier;

final class FirebaseAuthenticationServiceProvider extends \Illuminate\Foundation\Support\Providers\AuthServiceProvider
{
    /**
     * Register any authentication / authorization services.
     *
     * @return void
     */
    public function boot()
    {
        /**
         * Firebase PHP SDK(非公式)がEmulatorには対応していないので別途実装する必要があるので
         * サービスコンテナで環境ごとに差し替えします。
         */
        if (config('services.firebase.use_emulator') === true) {
            $this->app->bind(VerifyIdTokenInterface::class, EmulatorVerifyIdToken::class);
        } else {
            $this->app->bind(VerifyIdTokenInterface::class, LiveVerifyIdToken::class);
        }

        /**
         * @see https://laravel.com/docs/8.x/authentication#adding-custom-guards
         */
        Auth::extend('firebase', function ($app, $name, array $config) {
            return new FirebaseGuard(app()->make(VerifyIdTokenInterface::class));
        });

        Auth::viaRequest('firebase', function ($request) {
            return app(FirebaseGuard::class)->user();
        });
    }

    /**
     * Register bindings in the container.
     *
     * @return void
     */
    public function register()
    {
        /**
         * ID Tokenの検証クラスIdTokenVerifierをシングルトンで登録しておく
         */
        $this->app->singleton(IdTokenVerifier::class, function ($app) {
            $projectId = config('services.firebase.project_id');
            return IdTokenVerifier::createWithProjectId($projectId);
        });
    }
}

Guardの実装

続いてFirebaseGuardの実装を示します。
今記事にする用にファイルを読みましたが、コメントに書かれている参考文献の数が8つもあって当時それなりに詰まったことが想像されます笑

なんにせよ、Guardがやるべきこととしては、なんらかの方法でユーザーのIDを取得して最終的にはAuthenticatable型の値(一般にはUserモデル)を返すことです。

FirebaseGuardでは、Bearer TokenからFirebaseのJWT Tokenを取得して、VerifyIdTokenInterface(後述)を使ってIDを取得します。最終的にUserクラスの変数に格納してreturnします。

FirebaseのID Tokenは1時間おきと割としばしば期限切れになるため、その場合は例外をThrowしています。この場合はGuardの仕様で401ステータスコードでクライアントにレスポンスされます。そうなるとaxiosなどでリトライ処理をinterceptするとよいです。

FirebaseGuard.php
<?php
declare(strict_types=1);

namespace App\Providers\Guards\Firebase;

use App\Models\User;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Support\Facades\Log;

/**
 * 参考文献
 * https://github.com/firevel/firebase-authentication
 * https://github.com/kreait/firebase-tokens-php
 * https://qiita.com/tomoeine/items/91163ec5a674d697bc75
 * https://firebase.google.com/docs/auth/admin/verify-id-tokens#web
 * https://readouble.com/laravel/6.x/ja/authentication.html
 * https://gist.github.com/isaumya/bfa2f5fc20beee897938c099762cc3e1
 * https://qiita.com/basho/items/acd6a17bb6e2a2f7a932
 * https://github.com/tymondesigns/jwt-auth/issues/1436
 *
 * 動作確認方法
 * フロントエンドで以下のように実装し、トークンを取得する。
 * useAuth().onAuthStateChanged(async (user) => {
 *   state.firebaseUser = user
 *   console.log({
 *     token: await user?.getIdToken(true)
 *   })
 * })
 * 取得したトークンをInsomniaなどのクライアントを使うなどしてBearerに付与してリクエストすると動作確認ができる
 * トークンを取得するフロントエンドの接続先をプロダクションにしたりエミュレータにすることで双方の振る舞いテストができる
 *
 * Class FirebaseGuard
 * @package App\Providers\Guards
 */
class FirebaseGuard implements Guard
{
    protected VerifyIdTokenInterface $verifier;

    // くりかえし利用された場合のキャッシュ用
    private ?Authenticatable $user = null;

    /**
     * プロダクションなFirebaseに接続されているときと
     * ローカルのエミュレーターに繋がっているときで動作を変更しているため
     * Interfaceへの依存にして、実装クラスをそれぞれの環境ごとに実装している
     *
     * @param VerifyIdTokenInterface $verifier
     *
     */
    public function __construct(VerifyIdTokenInterface $verifier)
    {
        $this->verifier = $verifier;
    }

    /**
     * Get User by request claims.
     *
     * @return Authenticatable|null
     * @throws \Exception
     */
    public function user()
    {
        $token = \Illuminate\Support\Facades\Request::bearerToken();

        if (empty($token)) {
            return null;
        }

        if (! is_null($this->user)) {
            return $this->user;
        }

        /**
         * いずれの実装クラスでも、統一されたレスポンスクラスを返している
         */
        $verifyTokenResponse = $this->verifier->verifyIdToken($token);

        if ($verifyTokenResponse->isOK()) {
            $this->user = User::query()->findOrFail($verifyTokenResponse->getUserId());
            return $this->user;
        }

        if ($verifyTokenResponse->isExpired()) {
            Log::info('[Auth]トークンが期限切れのため401ステータスコードを返しました');
            throw new AuthenticationException('JWT token is expired.');
        }
    }

    // 以下略
}

VerifyIdTokenInterface

VerifyIdTokenInterfaceでは、JWTトークンを受け取って、そのVerifyした結果とユーザーIDを返します。

以下のようなインターフェースを切りました。

<?php
declare(strict_types=1);

namespace App\Providers\Guards\Firebase;

interface VerifyIdTokenInterface
{
    public function verifyIdToken(string $token): VerifyTokenResponse;
}

気になる方のためにVerifyTokenResponseクラスについても記載しておきますが、本筋ではないので折りたたみます。要は単なるDTOです。

VerifyTokenResponse
<?php
declare(strict_types=1);

namespace App\Providers\Guards\Firebase;

final class VerifyTokenResponse
{
    private VerifyIdTokenStatus $status;
    private ?int $userId;

    /**
     * VerifyTokenResponse constructor.
     * @param VerifyIdTokenStatus $status
     * @param int|null $userId
     */
    public function __construct(VerifyIdTokenStatus $status, ?int $userId = null)
    {
        $this->status = $status;
        $this->userId = $userId;
    }

    public function isOK(): bool
    {
        return $this->status->value() === VerifyIdTokenStatus::SUCCEED;
    }

    public function isExpired(): bool
    {
        return $this->status->value() === VerifyIdTokenStatus::EXPIRED;
    }

    /**
     * @return string|null
     */
    public function getUserId(): ?int
    {
        return $this->userId;
    }
}

実装クラスは以下のように、kreait/firebase-tokensパッケージのIdTokenVerifierを使って検証します。Emulator用の実装はPHPパッケージが未対応のため、docker-composeコンテナ内にNodeコンテナを立ててそちらにリクエストを飛ばして検証していますがここでの添付は割愛します。

final class LiveVerifyIdToken implements VerifyIdTokenInterface
{
    public function verifyIdToken(string $token): VerifyTokenResponse
    {
        try {
            /** @var IdTokenVerifier $client */
            $client = app()->make(IdTokenVerifier::class);
            $firebaseToken = $client->verifyIdToken($token);
            $payload = $firebaseToken->payload();
            return new VerifyTokenResponse(
                new VerifyIdTokenStatus(VerifyIdTokenStatus::SUCCEED),
                (int)$payload['sub']
            );
        } catch (\Throwable $e) {
            if (strpos($e->getMessage(), 'The token is expired.') !== false) {
                return new VerifyTokenResponse(
                    new VerifyIdTokenStatus(VerifyIdTokenStatus::EXPIRED),
                );
            }
            return new VerifyTokenResponse(
                new VerifyIdTokenStatus(VerifyIdTokenStatus::OTHER_FAILURE),
            );
        }
    }
}

routes/hogehoge.phpでの実装

->middleware('auth:api')をつけることでFirebaseのJWTトークンが必須になります。

Route::prefix('/notifications')->middleware('auth:api')->group(function () {
    Route::get('/', GetNotificationsController::class);
});

まとめ

こうやってまとめてみるとさして複雑なことはしていないのですが、Custom Guardの実装ってなかなかする機会がないことと、Firebaseのトークン検証に公式のSDKがなく非公式のパッケージを入れる必要があることなどの要因で実装したとき大変だった記憶があります。

どなたかの参考になれば幸いです。

マナリンク Tech Blog

Discussion