[2021年版] バックエンドがSymfony5なSPAをFirebase Authenticationでユーザー認証できるようにする方法
はじめに
この記事は、以前僕が書いた バックエンドが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のクレデンシャルの登録など)を済ませておいてください🙏
- フロントエンドからFirebaseにログインしてIDトークン(中身はJWT)をもらう
- 認証が必要なリソースにアクセスする際に、リクエストの
Authorization
ヘッダーでIDトークンを送る - Symfony側でFirebase SDKを使ってIDトークンをデコード&検証し、Firebaseから該当するユーザーのUUIDを取得する
- 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のリクエストヘッダーをカスタマイズします。
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+ の新しい認証システム使って実装していきます。
新しい認証システム自体の使い方は以下の公式ドキュメントにまとまっています。
以下、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をもとに該当するユーザーを引ける
- 後述の
といったあたりでしょうか。
Passport
と Badge
でユーザーを認証認可するところが新しい認証システムの要点だと思いますが、今回のように認証はFirebase SDKで行ってSymfonyとしては認可する/しないの判断をするだけというケースでは、あまりこの機構を意識することはなかったですね。
security.yaml
を設定する
4. 最後に 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+ の新しい認証システム に対応した内容になっているので、どなたかの参考になればいいなと思います😇
参考リンク
- New in Symfony 5.1: Updated Security System (Symfony Blog)
- Using the new Authenticator-based Security (Symfony Docs)
- Firebase Auth のユーザ認証機能を自前のデータベースと連携する - Qiita
- Laravel + Nuxt.js + Firebase でいい感じにTwitterによるソーシャルログインを実現する - Qiita
- バックエンドがSymfonyなSPAをFirebase Authenticationでユーザー認証できるようにする | QUARTETCOM TECH BLOG
Discussion