エイチームグループの子会社のエイチームライフデザインでフロントエンドエンジニアをしてます。小堀輝(@anneau)です。
今回はNext.jsとFirebase Authenticationについてお話ししようと思います。
はじめに
ユーザーが複数端末を持つことが多い現代において、私たちが開発するWebアプリケーションでは、異なるデバイスで同じ体験をユーザーに提供するために、ユーザーIDを識別し、データを保存することはとても重要かと思います。
もちろん、独自にユーザーを識別するシステムを開発することは可能ですが、複雑なロジックになってしまい、メンテナンスコストが嵩んだり、不具合につながってしまう可能性があります。
そこで、議論のテーブルに上がってくるのが認証基盤を提供してくれるSaaS。
今回は、その中でもメジャーなFirebaseについて、実装する上で直面した課題をあげ、あらかじめ考えておいた方が良いことをまとめようと思います。
筆者がプロジェクトでNext.jsを使っている都合上、Next.jsを使っていることが前提の記事となっております。 他のフレームワーク等でも実現は可能だと思いますが、SSRが前提となっていたり、APIサーバーがあることが前提となってしまうため、ご了承ください。
Firebase JavaScript SDKについて
Firebaseを用いる場合、ほとんどはFirebase JavaScript SDKを使ったサンプルが出てくるかと思います。 簡単に認証処理を作れるため、簡易的なサイトには非常に有用かと思います。
ただ、実際のサービスに導入すると生まれてくる課題がいくつかあります。
課題1: サイトパフォーマンスが下がる
Firebase SDK v9にてツリーシェイキングがサポートされたため緩和されましたが、それでもかなりのファイルサイズになります。 導入前後でバンドルサイズがどのように変わるか見てみましょう
導入前
導入後
導入前は312Bだったファイルサイズが28.1kBまで上がっています。 currentUserを表示する機能を足しただけですが、これだけ差が出てきますし、Firestoreなどにアクセスする場合にはもっと大きなファイルサイズになってきます。
課題2: セキュリティ問題
Firebase SDKでは認証情報の永続化ストレージとして
- LOCAL(indexedDB): ブラウザを閉じても残る。消すためには明示的にログアウトする必要がある
- SESSION(session storage): ブラウザのタブを消すと消える。
- NONE(on memory): 遷移したりすると消える
の3つオプションがあります。
どのオプションを使うかは利便性とのトレードオフになります。 永続化する時間が長ければ長いほどXSSなどの攻撃により、トークンが取得されてしまった場合のリスクは上がります。
もちろん、XSSなどの攻撃対策は別途すべきですし、そのような脆弱性は作るべきではないとは思いますが、 100%はあり得ないと思うので、仮に攻撃を受けてしまった場合、 JavaScriptから認証情報にアクセスできる時点でセキュリティ的なリスクが大きいと思います。
課題3: 保守性の低下
Firebase SDKはJavaScriptで動きます。 つまり、アクセスコントロールをクライアントサイドに作る必要があり、これが保守性を低下させます。
例として、認証情報を持っていなかった場合signin
ページへリダイレクトさせるダッシュボードページをReactで作ってみます。
export default function Dashboard() { const { replace } = useRouter(); const auth = getAuth(app); const user = auth.currentUser; // ユーザーがいなかった場合はsigninページへリダイレクト useEffect(() => { if (!user) { replace("/auth/signin"); } }, [user, replace]); if (!user) { return <p>Loading...</p>; } return ( <div> <h1>Firebase SDK</h1> </div> ); }
もちろんカスタムフックを切り出すなど、努力をすることはできますが、保守性が高い状態とは言えないでしょう。 チラつきも発生するため、ユーザビリティにも良くありません。
Firebase REST APIを用いた改善
サイトパフォーマンスの低下、セキュリティ問題、保守性の低下。 どれもエンジニアにとっては譲れないものかと思います。
対処するためには、サーバーサイドでの認証処理が必須になってきます。
全体像
クライアントから直接Firebaseと通信を行うFirebase SDK Authenticationを使わずに、 クライアントからはAPIを叩き、サーバーサイド側でFirebase Auth REST APIを用いることで認証を行います。
- クライアントサイドからNext.jsのAPI routeを叩く
- Next.jsのAPI routeからFirebase Auth REST APIを叩く
- Firebaseから認証情報(idToken)が返却される
- サーバーサイドCookieに認証情報(idTokenを格納する)
以降の通信では、リクエストCookieに4で格納したidTokenが含まれるため、サーバーサイドでidTokenを取得することができ、 さらに、Firebaseと通信することでユーザー情報にアクセスすることができます。
・idTokenとは?
Firebaseによって署名済みのJWT。ユーザーを一意に識別することができます。
・サーバーサイドCookieとは?
サーバーサイドのみでアクセスできるCookie。 Cookieをセットする際、
httpOnly
オプションを付与すれば、JavaScriptからアクセスできないCookieとなり、直接的にXSSで抜き取ることはできなくなる。
これにより何が改善するか?
- サイトパフォーマンス Firebaseとの通信をクライアントでは行わず、サーバーサイドで行うことでバンドルサイズ増加の原因であるSDKを不要にする
- セキュリティ 認証情報をサーバーサイドCookie(httpOnly Cookie)に保持することで認証情報の漏洩を限りなく少なくする
- 保守性 アクセスコントロールをサーバーサイドで行うため、クライアントサイドの保守性が上がる
Next.jsでの実装例
認証
Next.jsのAPI Routeを用い、Next.jsのサーバーサイドからFirebaseと通信します。 Firebaseと通信し得られた認証情報(idToken)はサーバーサイドCookieに格納します。
const handler: NextApiHandler = async (req, res) => { const { email, password } = req.body; const response = await signUp(email, password); if (!response.ok) { throw new Error("認証エラー"); } const { idToken } = await response.json(); assignSession(res, idToken); res.json({ status: "ok" }); }; export default handler;
signUp処理とassignSessionでやっていることを詳しく見ていきます
signUp
メールアドレスとパスワードをFirebaseへPOSTすることでidTokenを入手します。
登録APIを例に実装しますが、ログインもエンドポイントが異なるだけで同様の処理で実現可能です
const signUp = async (email: string, password: string) => { const body = JSON.stringify({ email, password, returnSecureToken: true }); const apiKey = process.env.FIREBASE_API_KEY; return await fetch( `https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json", }, body, } ); };
assignSession
Next.jsでCookie操作をするために、nookies
というライブラリを用います
import { NextApiResponse } from "next"; import { setCookie } from "nookies"; const assignSession = (res: NextApiResponse, idToken: string) => { const SESSION_KEY = "session"; const COOKIE_OPTIONS = { maxAge: 60 * 60 * 24, // 1日 httpOnly: true, secure: true, path: "/", }; setCookie({ res }, SESSION_KEY, idToken, COOKIE_OPTIONS); };
注意:
上述の通り、Cookieにセッション情報を保持する際、
httpOnly
オプションを必ずつけるようにしてください。 また、secure
オプションはSSL通信でしかCookieに乗らないようにするオプションでこちらも付与しておくと良いと思います。
アクセスコントロール
Next.jsのgetServerSidePropsを用い、Next.jsのサーバーサイドでアクセスコントロールを実現します。 このように実装しておくことで、クライアントサイドでは認証情報のチェックをする必要がなく、チラつきなども発生しません。
export const getServerSideProps: GetServerSideProps = async ({ req }) => { const idToken = req.cookies.session || ""; const response = await verify(idToken); // verifyに失敗した時はsigninへリダイレクト if (!response.ok) { return { redirect: { destination: "/auth/signin", permanent: false, }, }; } return { props: {}, }; }; export default function Dashboard() { return ( <div> <h1>Dashboard</h1> </div> ); }
verify
verify関数ではFirebaseにidTokenを送りつけ検証を行ないます。
const verify = async (idToken: string) => { const apiKey = process.env.FIREBASE_API_KEY; const body = JSON.stringify({ idToken }); return await fetch( `https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=${apiKey}`, { method: "POST", headers: { "Content-Type": "application/json", }, body, } ); };
Next.jsのmiddleware(2022年6月現在はβ)を用いればより簡単にアクセスコントロールが実現できます。 さらに、Vercelにデプロイしておくと、エッジサーバー上で処理をしてくれるため、よりパフォーマンスの良いサイトを構築できます。
おわりに
Firebase SDK JavaScriptを導入することで、簡易的に認証を実現することはできますが、
- サイトパフォーマンスが下がる
- セキュリティ上の問題
- 保守性の低下
といった課題が生まれてくる。 上記課題の解決策として、Firebase REST APIをサーバーサイドで用い、サーバーサイドCookieに認証情報を保持する手法を書かせていただきました。 新たにFirebase Authenticationをアプリケーションに導入する際には議論の参考にしていただければ幸いです。