🎆

Next.jsとFirebaseでCookieを使った認証処理を実装する

commits15 min read

この記事について

Firebase Authenticationと Next.js のgetServerSideProps()を使って、Cookie を使ったセッション管理方法を、この場を借りて共有したいと思います 💪

また、この記事の内容は基本的に以下の記事の内容を踏襲したものとなっています。そのため、内容やソースコードに引用などが含まれますので、予めご了承ください 🙇‍♂️

https://firebase.google.com/docs/auth/admin/manage-cookies?hl=ja

それでは行きましょう 🛴

環境構築

この記事では Firebase の環境構築については解説しません。
構築方法が知りたい方は、以下の公式ドキュメントを参照して頂けると幸いです。

まず初めに、create-next-appを使って雛形を作成します。この時、--typescriptオプションを使うと TypeScript の雛形を作成できますので、それを使って作成します 👇

雛形を作成する
$> npx create-next-app --typescript

雛形が作成できましたら、生成されたフォルダーに入り、必要なモジュールをインストールします。以下のコマンドを実行しましょう 👇

必要なモジュールをインストール
$> yarn add nookies firebase-admin firebase@9.0.0

ちなみに、インストールしたモジュールの詳細は以下のようになります 👇

  • firebase : Firebase の SDK。認証処理を行うのに使う。
  • firebase-admin : 管理者用の SDK。セッション ID の検証などに使う。
  • nookies : Cookie を扱いやすくするためのライブラリ。

firebaseについては、Tree Shakingを使うため@9.0.0を付けてインストールしています。詳しい事は以下の記事を参照してください 👇

https://firebase.google.com/docs/web/modular-upgrade

上記のモジュールがインストールできましたら、次はログイン処理を実装していきます 🛶

認証処理を行う関数を実装する

まずは、フロント側の認証処理を行う関数を実装していきます 👇

./utils.ts
import type { FirebaseApp } from "firebase/app";
import type { Auth as FirebaseAuth } from "firebase/auth";

import { getApps, initializeApp } from "firebase/app";
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";

/**
 * @description Firebaseの管理画面から取得したAPIオブジェクト
 * @note 環境変数は`.env.local`ファイルに定義しています
 */
export const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
  measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
};

/**
 * @description FirebaseAppを返す
 */
export const getFirebaseApp = (): FirebaseApp | undefined => {
  if (typeof window === "undefined") return; // バックエンドで実行されないようにする

  return getApps()[0] || initializeApp(firebaseConfig);
};

/**
 * @description FirebaseAuthを返す
 */
export const getFirebaseAuth = (): FirebaseAuth => {
  return getAuth(getFirebaseApp());
};

/**
 * @description メールアドレスとパスワードでログイン
 */
export const login = async (email: string, password: string) => {
  // FirebaseAuthを取得する
  const auth = getFirebaseAuth();

  // メールアドレスとパスワードでログインする
  const result = await signInWithEmailAndPassword(auth, email, password);

  // セッションIDを作成するためのIDを作成する
  const id = await result.user.getIdToken();

  // Cookieにセッションを付与するようにAPIを投げる
  await fetch("/api/session", { method: "POST", body: JSON.stringify({ id }) });
};

/**
 * @description ログアウトさせる
 */
export const logout = async () => {
  // セッションCookieを削除するため、Firebase SDKでなくREST APIでログアウトさせる
  await fetch("/api/sessionLogout", { method: "POST" });
};

少し長いですが、上記のコードはlogin()logout()を実装しています。

今回はメールアドレスとパスワードでログインするため、login()内でsignInWithEmailAndPassword()を使っていますが、もし他のログイン方法を使いたい場合は、Firbase SDK が提供する別の関数などを使うことで対応できると思います。

Firebase Authentication を使うには、Firebase のプロジェクト管理画面で設定をする必要があります。詳しくは公式ドキュメントを参照してください。

またlogout()は、コメントでも書いてあるように Cookie に保存されているセッション ID を削除するため、Firebase SDK が提供するsignOut()でなく、"/api/sessionLogout"という、この後実装する API を使ってログアウトを行います。

上記が実装できましたら、次はページコンポーネントを実装していきます 🎿

ログインページの実装

ログインページを記述していきますが、簡略化のためにデザイン部分などは排除していますので、もし画面がショボイと感じた方は適時自前で実装して頂けると幸いです。

./pages/login.tsx
import type { FormEvent } from "react";

import { NextPage } from "next";
import { useState } from "react";
import { useRouter } from "next/router";

import { login } from "../utils";  // 上記で実装したファイル

const LoginPage: NextPage = () => {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const onSubmit = async (event: FormEvent) => {
    event.preventDefault(); // デフォルトの<form />の挙動を無効にする
    await login(email, password); // email・passwordを使ってログイン
  router.push("/dashboard"); // ダッシュボードページへ遷移させる
  };

  return (
    <div>
      <h1>ログイン画面</h1>

      <form onSubmit={onSubmit}>
        <div>
          <label htmlFor="email">Email:</label>

          <input
            id="email"
            value={email}
            onInput={(e) => setEmail(e.currentTarget.value)}
          />
        </div>

        <div>
          <label htmlFor="password">Password:</label>

          <input
            id="password"
            type="password"
            value={password}
            onInput={(e) => setPassword(e.currentTarget.value)}
          />
        </div>

        <button type="submit">login</button>
      </form>
    </div>
  );
};

export default LoginPage;

上記の実装では、フォーム値を使って上記で実装したlogin()を実行しています。簡略化のため色々とツッコミどころはありますが、ご愛嬌という事でお願いします。

上記の実装ができましたら、次はダッシュボードページを実装していきましょう 🏉

ダッシュボードページの実装

これからダッシュボードページを実装していきますが、このページはログインしているユーザーのみ閲覧できるようにし、ログインして無い場合はログインページ(/login)へ遷移させるようにします。

まずは、コンポーネント部分を実装していきましょう 👇

./pages/dashboard.tsx
import type { GetServerSideProps, NextPage } from "next";

import nookies from "nookies";
import { useRouter } from "next/router";

import { logout } from "../utils"; // 上記で実装したファイル
import { firebaseAdmin } from "../firebaseAdmin"; // この後に実装するファイル

const DashboardPage: NextPage<{ email: string }> = ({ email }) => {
  const router = useRouter();

  const onLogout = async () => {
    await logout(); // ログアウトさせる
    router.push("/login"); // ログインページへ遷移させる
  };

  return (
    <div>
      <h1>Dashboard Pages</h1>

      <h2>email: {email}</h2>

      <button onClick={onLogout}>Logout</button>
    </div>
  );
};

export default DashboardPage;

特に解説するところは無いと思いますが、一応ログインしている事を確かめるためにemailを porps からもらって表示しています。

次は、認証処理を行うgetServerSideProps()を実装していきます 🎲

getServerSideProps()の実装

上記で実装した<DashboardPage />の下にgetServerSideProps()を実装していくと以下のようになります 👇

./pages/dashboard.tsx
// ...

const DashboardPage: NextPage<{ email: string }> = ({ email }) => {
  // ...
}

export const getServerSideProps: GetServerSideProps = async (ctx) => {
  const cookies = nookies.get(ctx);
  const session = cookies.session || "";

  // セッションIDを検証して、認証情報を取得する
  const user = await firebaseAdmin
    .auth()
    .verifySessionCookie(session, true)
    .catch(() => null);

  // 認証情報が無い場合は、ログイン画面へ遷移させる
  if (!user) {
    return {
      redirect: {
        destination: "/login",
        permanent: false,
      },
    };
  }

  return {
    props: {
      email: user.email,
    },
  };
};

export default DashboardPage;

やっている事は、nookiesを使って Cookie からセッション ID を取得し、Firebase Admin SDK のverifySessionCookie()を実行してセッション ID を検証します。検証が成功すると認証情報を含んだ値を返り値として受け取れますので、それを使ってページの出し分けをしています。

注意点として、verifySessionCookie()第二引数には必ずtrueを渡してください。 そうしないと、無効なセッション ID でも認証情報を取得できてしまいます。

また、リダイレクト処理の詳細については以前記事を書きましたので、そちらを参照して頂けると幸いです 👇

https://zenn.dev/uttk/articles/4649e49f1e6628

さて、これでフロント部分は実装できましたので、残りの API 部分の実装をしていきましょう 🧸

API の実装

この記事では、Next.js が提供しているAPI Routesを使って実装していきます。もしこの機能を使わずに実装したい場合は、適時対応して実装してください。

firebase-admin の初期化

まずは、Firebase Admin SDK を初期化するための処理を実装していきます 👇

./firebaseAdmin.ts
import admin from "firebase-admin";

/**
 * @description Firebaseの管理画面から取得した管理者アカウント情報
 * @note 環境変数は`.env.local`ファイルに定義しています
 */
const serviceAccount: admin.ServiceAccount = {
  projectId: process.env.FIREBASE_ADMIN_PROJECT_ID,
  clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL,
  privateKey: (process.env.FIREBASE_ADMIN_PRIVATE_KEY || "").replace(
    /\\n/g,
    "\n"
  ),
};

/**
 * @description Firebase Admin SDKを扱うためのオブジェクト
 * @note バックエンドのみで使用可能
 */
export const firebaseAdmin =
  admin.apps[0] ||
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
  });

初期化するだけなので特に難しい事はしていませんが、serviceAccountの情報は Firebase のプロジェクト管理画面から取得する必要がある事に注意してください!

詳しいことは公式ドキュメントに載っていますので、ご参照ください 👇

https://firebase.google.com/docs/admin/setup?hl=ja#initialize-sdk

/api/session の実装

本来であればCSRF対策などをするべきですが、今回は簡略化のために実装していません。予めご了承下さい 🙇‍♂️

次にセッション管理を実装していきます 👇

./pages/api/session.ts
import type { NextApiRequest as Req, NextApiResponse as Res } from "next";

import { setCookie } from "nookies";
import { firebaseAdmin } from "../../firebaseAdmin"; // 上記で実装したファイル

export default async function sessionApi(req: Req, res: Res) {
  // "POST"以外は、"404 Not Found"を返す
  if (req.method !== "POST") return res.status(404).send("Not Found");

  const auth = firebaseAdmin.auth();

  // Tokenの有効期限
  const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5日

  // セッションCookieを作成するためのIDを取得
  const id = (JSON.parse(req.body).id || "").toString();

  // Cookieに保存するセッションIDを作成する
  const sessionCookie = await auth.createSessionCookie(id, { expiresIn });

  // Cookieのオプション
  const options = {
    maxAge: expiresIn,
    httpOnly: true,
    secure: true,
    path: "/",
  };

  // セッションIDをCookieに設定する
  setCookie({ res }, "session", sessionCookie, options);

  res.send(JSON.stringify({ status: "success" }));
}

上記の実装は、createSessionCookie()を使ってセッション ID を作成し、そのセッション ID を Cookie に保存してレスポンスを返すだけなので、やっている事は簡単ですね。

注意点としては、Cookie のpathオプションはちゃんと設定しないと上手く処理ができないので、適切な値を設定してください。今回はサービス全体で扱えるようにしています。

Cookie の仕様については以下のサイトが参考になると思います 👇

https://developer.mozilla.org/ja/docs/Web/HTTP/Cookies

/api/sessionLogout の実装

次は、ログアウトを行う/api/sessionLogoutを実装します 👇

./pages/api/sessionLogout.ts
import type { NextApiRequest as Req, NextApiResponse as Res } from "next";

import { parseCookies, destroyCookie } from "nookies";
import { firebaseAdmin } from "../../firebaseAdmin";

export default async function sessionLogoutApi(req: Req, res: Res) {
  // POSTじゃなければ、"404 Not Found"を返す
  if (req.method !== "POST") return res.status(404).send("Not Found");

  const auth = firebaseAdmin.auth();

  // Cookieに保存されているセッションIDを取得する
  const sessionId = parseCookies({ req }).session || "";

  // セッションIDから認証情報を取得する
  const decodedClaims = await auth
    .verifySessionCookie(sessionId)
    .catch(() => null)

  // 全てのセッションを無効にする
  if (decodedClaims) {
    await auth.revokeRefreshTokens(decodedClaims.sub);
  }

  // Cookieに保存されているセッションIDを削除
  destroyCookie({ res }, "session", { path: "/" });

  res.send(JSON.stringify({ status: "success" }));
}

上記の処理は、verifySessionCookie()を使ってセッション ID から認証情報を取得し、その情報をrevokeRefreshTokens()に渡して、既存のセッション ID( Cookie に保存していた値 )を無効にしています。

revokeRefreshTokens()を使うと、すべてのセクションが取り消されるため、他のセクションで新たにログインする必要が出てくることに注意してください。機密性の高い情報を扱うアプリケーションの場合は、セッション期間を短くすることをおすすめします。

実装完了 ✨

はい、以上で実装は終了です!

ログイン・ログアウトしてみて、ちゃんとリダイレクト処理が機能しているか確認できていれば問題ありません!

お疲れさまでした 🙌

おまけ: getInitialProps で実装する

上記の実装でもセッション管理が出来ていますが、getServerSideProps()はページの表示が遅くなる場合があるため、ユーザー体験を上げたいならgetInitialProps()を使った方が良いです。

なので、getInitialProps()を使った簡単なサンプルをおまけとして残しておきますので、参考にしてください 👇

./pages/api/me.ts
import type { NextApiRequest as Req, NextApiResponse as Res } from "next";

import { parseCookies } from "nookies";
import { firebaseAdmin } from "../../firebaseAdmin";

export default async function meApi(req: Req, res: Res) {
  // Cookieに保存されているセッションIDを取得する
  const sessionId = parseCookies({ req }).session;

  if (req.method !== "GET") return res.status(404).send("Not Found");
  if (!sessionId) return res.json({});

  const auth = firebaseAdmin.auth();

  // セッションIDから認証情報を取得する
  const user = await auth
    .verifySessionCookie(sessionId, true)
    .catch(() => null);

  res.json(user ? { user: { email: user.email } } : {});
}
./pages/dashboard.tsx
import type { NextPage } from "next";
import Router, { useRouter } from "next/router";
import { logout } from "../utils";

const DashboardPage: NextPage<{ email: string }> = ({ email }) => {
  // 上記と同じ実装
};

DashboardPage.getInitialProps = async ({ req, res }) => {
  const isServerSide = typeof window === "undefined";

  // バックエンドのみで動かす
  if (isServerSide && req && res) {
    const root = "http://localhost:3000";
    const options = { headers: { cookie: req.headers.cookie || "" } };

    const result = await fetch(`${root}/api/me`, options);
    const json = (await result.json()) as { user?: { email: string } };

    // 認証情報が無ければログイン画面へリダイレクトさせる
    if (!json.user) {
      res.writeHead(302, { Location: "/login" });
      res.end();
    }

    return { email: (json.user || {}).email || "" };
  }

  // フロントエンドのみで動かす
  if (!isServerSide) {
    const result = await fetch("/api/me"); // 認証情報を取得する
    const json = (await result.json()) as { user?: { email: string } };

    // 認証情報が無ければログイン画面へリダイレクトさせる
    if (!json.user) Router.push("/login");

    return { email: (json.user || {}).email || "" };
  }

  return { email: "" };
};

export default DashboardPage;

あとがき

ここまで読んでくれてありがとうございます 🙏

Next.js と Firebase を使って認証処理を実装しましたが、意外と簡単に実装できると思うので、Firebase を使っている方はぜひ参考にして下さい!

また個人的には、Firebase SDK がTree Shakingに対応したので、それを試してみるのが目的としてありました。関数型っぽく実装できるようになって、個人的には書きやすくなった印象でした。今のうちに触って慣れておきたいですね 💪

記事に間違いなどがあれば、コメントなどで教えて頂けると嬉しいです。
これが誰かの参考になれば幸いです。

それではまた 👋

GitHubで編集を提案

この記事に贈られたバッジ

Discussion

ログインするとコメントできます