Symfony製のMPAのユーザー認証にFirebase AuthenticationなどのIDaaSを使う
Symfony Advent Calendar 2023 の15日目の記事です!🎄✨
Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲
昨日は @polidog さんの こんにちわSymfony7 with Flyio でした✨
はじめに
Symfony製のMPA(Multi Page Application)のユーザー認証にIDaaSを使う方法をご紹介します。Firebase Authenticationを使うケースを例に、具体的に順を追って解説していきます。
SPAに導入する例として以下の記事などもあわせてご参照ください。
kreait/firebase-bundle
を導入
1. Firebase Authとのやりとりには kreait/firebase-bundle を活用します。
$ composer require kreait/firebase-bundle
テスト時にはFirebase Authには接続せず、
この記事で紹介したような方法で擬似的にログインさせる想定なので、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.json
の scripts.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のユーザーエンティティを紐付ける想定です。
security.yaml
を設定
3. 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;
}
}
- フロントエンドからPOSTリクエストの
access_token
パラメータでFirebase AuthのIDトークンを受け取り -
Auth
サービスを使ってログインを試行し、LoginResult
経由でユーザーUIDを受け取り - ユーザーUIDでユーザーエンティティを引いて
- そのユーザーを認証する
という流れです。
ユーザー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、明日は空きです🥺どなたかぜひご参加ください!
Discussion