💭

自社のWebフロントエンドにFirebase Authenticationを導入する際に考えたこと

2024/08/26に公開

こんにちは!
マイベストでフロントエンドエンジニアをしているIchikiです!

マイベストテックブログ連載:「みんなでお買い物サポートクラブ」リリースまでの開発の裏側の3日目を担当します。

はじめに

これはマイベストというプロダクトのWebアプリケーションにFirebaseの認証機能(Firebase Authentication)を導入するにあたって自分が考えたことを整理したメモのような記事です。

本記事では弊社の技術スタックであるReactをサンプルコードに載せていますが、内容自体は実装面よりも考え方を中心にまとめているため、フロントエンドの技術全てで適用できるものだと考えています。

Firebase Authenticationとは?

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

Firebaseの中のユーザー認証を管理するためのサービスです。これにより、メールアドレスとパスワード、GoogleやAppleなどの外部プロバイダによる認証など、さまざまな認証方法を簡単にアプリに組み込むことができます。

なぜFirebase Authenticationを採用したか?

既にリリースされていたMobile版(React Native)で先にFirebase Authenticationを使っていたため、同じプラットフォームの機能を使用することでイニシャルコストと、メンテナンスコストを抑えたかったというのが採用した1番の理由です。

なぜMobile版のときにFirebase Authenticationを導入したか、については本筋とズレてしまうためここでは言及しませんが、機会があればまた別で記事にしたいと思います!

【考えたこと1】認証方式はどれにする?

Firebaseでは通常クライアント側で行う認証方式を基本としていますが、バックエンドと組み合わせることでCookieでの認証を行うこともできます。
そのため、まずはじめに導入する際にどの認証方式が今のプロダクトに合っているのか検討をする必要がありました。

以下が考えうるパターンとPros, Consの整理です。

パターン1: クライアント側のみでの認証

このパターンでは、Firebase Authenticationを使用してクライアント側(通常はWebやモバイルアプリ)でユーザーの認証を行います。

手順

  1. ユーザーがメール・パスワード、Google、Facebook、Appleなどのプロバイダを使用してログインします。
  2. クライアントアプリでFirebase SDKを使用して認証を実行し、firebase.auth().signInWith... メソッドでユーザーを認証。
  3. 認証に成功すると、FirebaseはIDトークン(JWT)を発行し、クライアントに返します。
  4. クライアントアプリは、このIDトークンをブラウザ上のストレージに保存し、以降のAPIリクエストでヘッダーにトークンを含めて送信します。

Pros

  • 実装が比較的簡単で、Firebaseが認証を完全に処理してくれる。
  • サーバー側の設定が必要ない場合が多い。

Cons

  • クライアント側に到達してからFirebaseの認証チェックが行われるので、認証後の画面表示までが遅い。

パターン2: Cookieを使ったサーバー + クライアント側での認証

このパターンでは、Firebase Authenticationを使用してクライアント側でユーザーを認証し、その後、サーバーサイドでトークンを検証してセッションを管理します。

手順

  1. ユーザーがクライアント側でFirebase Authenticationを使用してログインします。
  2. FirebaseはクライアントにIDトークンを発行します。
  3. クライアントアプリはこのIDトークンをサーバーに送信します。
  4. サーバーはトークンをFirebase Admin SDKを使用して検証し、ユーザーの認証情報を確認します。
  5. 認証が成功したら、サーバー側でセッションを開始し、CookieにセッションIDを保存してクライアントに返します。
  6. 以降のリクエストでは、クライアントが自動的にCookieを送信し、サーバー側でセッションの検証が行われます。

Pros

  • 初回アクセス時にクライアント側にCookieが存在すればクライアント側に到達する前にサーバー側で認証チェックを行うことができる。 (よってパターン1よりも速く表示できる)

Cons

  • サーバーサイドの設定と管理が必要になる。
  • Cookieの管理やSession管理のための追加のロジックが必要。

ここで考えたこと

  • クライアント側のみで認証を行うパターン1が比較的実装は簡単だが、クライアント側に到達してからFirebaseの認証チェックが行われるので、認証後の画面表示までが遅いのが懸念。サービスとしてこの遅延を許容できるのかどうか
  • 上の話が許容できない場合、SSRを含めた初期描画の体験を改善できるパターン2で進めた方が良い。ただし、対応コストが増えるのでリリーススケジュールとの兼ね合いに対して問題がないか確認が必要

結論

  • マイベストはアプリの性質上CDNを使うページが多くあるため、Cookieでの認証方式を採用する場合にはセキュリティの考慮をする箇所必要が増える → 実装工数、メンテナンス工数が増える可能性が高い
  • 別の記事で話がされていたようにリリーススケジュールがタイトだった
  • クライアント方式の場合は認証チェック→成功までにクライアント側で通信が走る関係上、画面描画してからタイムラグが発生するが、初回のアクセス時のみなので体験としてそこまで損なわないはず

以上3つの観点から、パターン1のidTokenを使ったクライアント側での認証方式を採用することに決めました。

【考えたこと2】実際に対応が必要なものは?

認証方式が決まったあとは、実際にどんな対応が必要なのかを整理しました。

ここでは実装方法自体というよりはFirebase Authenticationのクライアント側での認証方式を採用する際に対応したものを説明しています。

対応リスト

  1. 新規登録 & ログインの機能実装
  2. 画面へのアクセスが合った際の認証チェック
  3. 認証チェック→認証完了 or 失敗したあとのリダイレクト制御
  4. 認証済みユーザーのみアクセスができるAPIリクエストの実装

1. 新規登録 & ログインの機能実装

内容
新規登録、ログインの機能実装についてはドキュメントが充実しているので、下を参考に実装を進めるのが一番スムーズかと思います。
https://firebase.google.com/docs/auth/web/google-signin?hl=ja

やること

  • 新規登録、ログインするためにFirebaseの認証を行うメソッドを呼び出す
  • 認証が成功したらリダイレクトをする

新規登録、ログインの機能に関しては導入したいプロバイダによって対応方法が異なるため、導入したいプロバイダに合った適切なメソッドを呼び出す必要があります。

サンプルコード
以下はGoogle認証を行った場合の例です。

import { getAuth } from 'firebase/auth';

export const loginWithGoogle = async (): Promise<FirebaseProfile> => {
  const auth = getAuth();
  const provider = new GoogleAuthProvider();
  provider.addScope('email');
  const userCredential = await signInWithPopup(auth, provider);
  const { user } = userCredential;
  return {
    name: user.displayName,
    email:
      user.email || user.providerData.length
        ? user.providerData[0].email
        : null,
    imageUrl: user.photoURL,
  };
};

ログインが成功したあとは状況によって必要な画面に遷移させます。
マイベストの場合は新規登録→メール認証画面、ログイン→マイページなど状態によって遷移先を分けています。

2. 画面へのアクセスが合った際の認証チェック

内容
ページにアクセスがあったとき、ユーザーが既に認証されているかどうかを確認するための処理を実装します。

やること

  • どの画面からでも呼び出される箇所でauth.onAuthStateChangedを呼び出す
  • 認証状態によってstateの更新などを必要に応じて行う

サンプルコード (React)

  import { getAuth } from 'firebase/auth';
  
  export const useAuthEffect = () => {
	  useEffect(() => {
	    const auth = getAuth()
	    const unsubscribe = auth.onAuthStateChanged(async (user) => {
	      if (user === null) {
	        // ログインをしていない or 認証が失敗した場合はここを通る
	        return;
	      }
	
	      const idToken = await user.getIdToken();
	      // 認証が成功した場合にはここを通る
	    });
	
	    return unsubscribe;
	  }, []);
 
  };

こちらの認証しているかどうかをチェックすることで、画面側で認証が済んでいない場合はリダイレクトを行うなど適宜制御を入れることができます。

3. 認証チェック→認証完了 or 失敗したあとのリダイレクト制御

内容
認証チェックのためのfirebaseへのリクエストが送信→完了するまでの状態をハンドリングして、状態によってローディングを出したり、認証成功 or 失敗によってリダイレクトを行います。

やること

  • 認証チェック中はローディングを表示する
  • 認証が失敗した場合はログイン画面にリダイレクトする
  • 認証が成功した場合には各ページ(Children)のコンポーネントを表示する

サンプルコード (React)

type Props = {
  children: ReactNode;
};

export const AuthRequired: FC<Props> = ({
  children,
}) => {
  // マイベストのデータベース上で管理しているid
  // 認証完了と同時に作成される。undefined: 未認証(認証チェック中)、 null: 認証失敗、 val(string): 認証済み
  const { customerId } = useAuthState();

  if (customerId === undefined) return <div className="c-loading" />;

  if (customerId === null) {
    window.location.href = '/login';
    return null;
  }

  return children;
};

あとは画面側では以下のようにページコンポーネントにラップして呼び出しています。

const AuthRequiredPage = () => (
  <AuthRequired>
    <Page />
  </AuthRequired>
);

export default AuthRequiredPage;

補足: 2024年8月時点の弊社の技術スタックはNext.js(Pages Router)のため上記の書き方をしていますが、App Routerを利用する場合はLayoutを活用するのが良さそうです。

4. 認証済みユーザーのみアクセスができるAPIリクエストの実装

内容
セキュリティ対策として認証が完了しているユーザーのみアクセスができるAPIを用意していきます。

やること

  • クライアント側で取得した認証トークン(idToken)をAPIのリクエストヘッダーに含めてサーバーに送る
  • サーバー側でFirebase Admin SDKを用いてidTokenの検証を行う

サンプルコード (Apollo Client)
コードはApolloClientのGraphQLのリクエスト前にヘッダーにidTokenを追加する例です。
ApolloClientに関わらず他のfetch系のライブラリでも同様の実装ができます。

const authLink = setContext(async (_, { headers }) => {
  const authHeaders: Record<string, string> = {};

  const auth = getAuth();
  const { currentUser } = auth;
  if (currentUser !== null) {
    // IDトークンは常にFirebaseから取得されるので、リクエストを送信する前に必要であれば更新することができます
    authHeaders['X-Auth-Token'] = await auth.currentUser.getIdToken();
  }

  return {
    headers: {
      ...headers,
      ...authHeaders,
    },
  };
});

ポイント
APIリクエスト送信前に常にfirebaseからidTokenを取得しています。これによりidTokenの有効期限(1時間)を過ぎていた場合でもリフレッシュトークンを使ってidTokenはAPIリクエスト前に自動更新されるため、tokenの有効期限を迎えることなく、半永久的にリクエストを行うことができています。
詳しくは以下を参照してみてください。
https://firebase.google.com/docs/auth/admin/manage-sessions?hl=ja

その他今回はバックエンドに関して詳しく書きませんでしたが、一応idTokenを検証する場合はFirebase Admin SDKのAPIを利用することで検証ができます。
以下が例です。

getAuth()
  .verifyIdToken(idToken)
  .then((decodedToken) => {
    const uid = decodedToken.uid;
    // ...
  })
  .catch((error) => {
    // Handle error
  });

こちらを組み合わせて認証済みユーザーのみアクセス可能なAPIを作ることができます。
https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja

さいごに

実際の実装においては、上記に加えて各プロバイダの細かい実装やエラーハンドリングなど細かいチューニングが必要になってくるかと思います。

ですがその前段としてFirebase Authenticationを導入する際にはこちらの記事を見て少しでも参考にして頂ければ嬉しいです。

それではまた。

Discussion