[Next.js] 学習記録アプリ Firebase認証・DB実装

前編・後編に渡ってお送りしてきた、Next.jsとFirebaseによる、学習記録アプリ、今回は番外編として、Firebase Admin SDKを活用してみましたので、ご紹介したいと思います。

Firebase Admin SDKは、管理者レベルの操作をサーバーサイドから行うためのライブラリです。サーバー側でトークンベースのセッション管理・検証を行ったり、カスタム認証を実装したりすることが出来るものです。
GitHub上にコードを公開してますので、よろしければ参考にしていただければと思います。
前編・後編のコード:https://github.com/amaya690221/pub-next-learnings-firebase
本記事のコード:https://github.com/amaya690221/pub-next-learnings-firebase-admin

はじめに

本記事は、前編・後編に渡ってお送りしてきた、Next.jsとFirebaseによる、学習記録アプリをベースに、Firebase Admin SDKを活用したサーバーサイドのセッション・トークン管理を実装するものです。
ここでは、クライアントがログイン後、Firebaseよりトークンを取得し、そのトークンをサーバー側で検証・デコードを行い、その結果を持って、サーバーコンポーネントよりFirebaseとのトランザクションを処理します。下記の処理イメージです。

Next.js-Firebase処理イメージ

このトークン検証・デコードとFirebaseとの連携処理について、Firebase Admin SDKで実装を行っていきます。

なお、本記事のコードは、前編・後編で作成したものをベースにしてます。各記事は下記です。

前編・後編のソースコードは、こちらです。
https://github.com/amaya690221/pub-next-learnings-firebase

また、本アプリの構成は以下の通りです。
緑の箇所が、今回新規追加となるもの、エンジ色の箇所が、更新となるものです。

基本的には、FirestoreDBの処理について、Admin SDKを実装していきます。クライアントで取得したトークンを検証し、正当であれば、Firestoreとのトランザクションを行います。
ユーザー認証関係は、(クライアントサイドの実装が簡潔なのもあり)そのままにしておくつもりでしたが、パスワード更新機能のみ、Admin SDKを利用してみました。

他、上記アプリ構成には入ってませんが、環境変数定義ファイルの.env.localもAdmin SDK用のパラメータを追加しています。

1. Admin SDKの環境準備

前提となる環境は、前編・後編で作成した環境となります。その環境に変更を加えていきます。 最初に、FirebaseプロジェクトでのAdmin SDKの設定や、Next.js環境でのAdmin SDKの環境構築を行っていきます。

1.1 Admin SDKの設定

まずは、ターミナルでプロジェクトディレクトリに移動し、Firebase Admin SDKのパッケージをインストールします。

npm install firebase-admin --save

続いて、Firebase Admin SDKの利用には秘密鍵が必要となりますので、秘密鍵を生成します。 Firebaseのサイトでコンソールに移動します。

対象のプロジェクトを選択肢、左上の歯車アイコンから、プロジェクトの設定を選択します。

プロジェクトの設定画面の、「サービスアカウト」タブから、Firebase Admin SDKを選択し、「新しい秘密鍵を生成」をクリックします。

下記警告がでます。「キーを生成」をクリックします。

そうすると、キー情報が記載されたjsonファイルがダウンロードされますのでプロジェクトルート直下に格納します。

長いファイル名のjsonファイルです。
また、このファイルが、Git Hubプッシュ対象外となるように、プロジェクトルート直下の.gitignoreファイルに対象外の条件を追加します。

# /.gitigonre

# env files (can opt-in for committing if needed)
.env*
learning-firebase*.json #追加

次に取得した秘密鍵情報等を、.env.localにコピー・追加します。

# /.env.local
NEXT_PUBLIC_FIREBASE_API_KEY="YOUR-apiKey"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="YOUR-authDomain"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="YOUR-projectId"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="YOUR-storageBucket"
NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID="YOUR-messagingSenderId"
NEXT_PUBLIC_FIREBASE_APP_ID="YOUR-appId"
#追加
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-で始まる長いYOUR-secretKey"
FIREBASE_CLIENT_EMAIL="YOUR-clientEmail"

FIREBASE_PRIVATE_KEYと、FIREBASE_CLIENT_EMAILを追加します。値は、ダウンロードしたjsonファイルに記載のあるものを貼り付けます。

以上で、Firebase Admin SDKの下ごしらえは完了です。

1.2 関連ユーティリティの作成

続いて、Admin SDKを利用するユーティリティコンポーネントを作成します。 まずは、Firebase Admin SDKを初期化、利用するためのサーバコンポーネントを作成します。

/app/api 配下に新たにutilsフォルダを作成します。そのフォルダ配下にfirebaseAdmin.tsと言うファイルを作成します。

firebaseAdmin.tsに以下のコードを記載します。下記はコード全文です。

// /app/utils/firebaseAdmin.ts
//Firebase Admin SDKの初期化及び、Firebase Auth インスタンスを取得するコンポーネント

import { initializeApp, cert, getApps } from "firebase-admin/app";//Firebase Admin SDKのインポート
import { getAuth } from "firebase-admin/auth";//Firebase Admin SDKのインポート

const privateKey = process.env.FIREBASE_PRIVATE_KEY;//秘密鍵の格納
if (!privateKey) {//秘密鍵が無ければエラー処理
  throw new Error("FIREBASE_PRIVATE_KEY is not defined");
}

export const firebaseAdmin = //Firebase Admin SDKの初期化処理
  getApps()[0] ?? //Firebase Admin SDKが初期化されていいればそれを利用、初期化されていなければ初期化処理
  initializeApp({
    //Firebase Admin SDKを初期化
    credential: cert({//以下内容を持って、初期化
      projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: privateKey.replace(/\\n/g, "\n"), // 改行文字を適切に処理
    }),
  });

export const auth = getAuth();//Firebase Authenticationを初期化し、サービス利用が出来るようにする

処理内容としては、コード中に記載したコメント通りです。Firebase Admin SDKが初期済であれば、それを利用し、されていない場合は、初期化処理します。getApps()[0] ?? の箇所です。 「??」と言う演算子を利用していますが、これは、Null 合体演算子と呼ばれ、論理演算子の一種です。この演算子は左辺が null または undefined の場合に右の値を返し、それ以外の場合に左の値を返します。

続いて、トークンの検証・デコードを行うユーティリティーファイルを作成します。 同じ、/app/utils配下に、authRequest.tsを言うファイルを作成します。

authRequest.tsに以下コードを記載します。下記はコード全文です。

// /app/api/utils/authRequest.ts
//トークン取得、検証、デコードを行うコンポーネント

import { NextRequest, NextResponse } from "next/server"; //next/serverから、request, responseに関する型定義インポート
import { auth, firebaseAdmin } from "./firebaseAdmin"; //先に作成したfirebaseAdmin.tsのインポート
import { DecodedIdToken } from "firebase-admin/auth"; //Firebase Admin SDKのインポート

export async function authenticateRequest(
  request: NextRequest
): Promise<DecodedIdToken | null> {
  //呼び出し側で型エラーが出た為、返り値、decodedTokenの型を明示
  const authHeader = request.headers.get("Authorization"); //クライアントから送信されるヘッダの中の"Authorization"を格納

  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    //authHeader(Authorization)が空もしくは、"Bearer "で始まっていない場合は、
    NextResponse.json(
      { success: false, error: "認証できません: トークンが無いか不正です" }, //トークン不正としてエラーを返す
      { status: 401 }
    );
    return null;
  }

  const token = authHeader.split(" ")[1]; // " "を区切り文字として、その後ろのデータをトークンとして取得(Bearer xxxx形式の為、冒頭の"Bearer "を排除)

  if (!firebaseAdmin) {
    //firebaseAdminが存在しなければ、firebaseAdmin初期化されていないとエラーをレスポンス
    throw new Error("Firebase Admin is not initialized");
  }

  try {
    const decodedToken = await auth.verifyIdToken(token); //firebaseAdminのauthメソッドのverifyIdTokenで、トークンを検証しデコード、それをdecodedTokenとして取得
    return decodedToken; // 検証成功時、トークンに含まれるユーザー情報を返す
  } catch (error) {//エラー時は、エラーメッセージと共にエラーをレスポンス
    console.error("Token verification error:", error);
    NextResponse.json(
      { success: false, error: "Invalid token" },
      { status: 401 }
    );
    return null;
  }
}

内容は、コード中に記載したコメント通りです。
クライアントからのリクエストヘッダーに、”Authorization”として、トークンが含まれていますので、それを抽出します。
“Authorization”が存在しない場合、もしくは、トークンは、”Bearer “で始まる形になっていますので、そうでは無い場合は、トークン不正としてエラーを返してます。

問題なければ、”Bearer “に続く後ろの部分がトークンとなりますので、その部分を抽出し、FirebaseAdminSDK(先に作成したfirebaseAdmin.tsで定義したauth)のverifyIdTokenにより、トークンの検証・デコードを実施します。
問題なければ、デコードしたトークンを返します。エラー時はエラーメッセージと共にエラーを返します。

次に、クライアントサイドでトークンを取得するコンポーネントを作成します。/app/utils/フォルダ配下に、新たにgetToken.tsを作成します。

作成した、getToken.tsに以下コードを記載します。下記はコード全文です。

// /app/utils/getToken.ts
//クライアント側でセッショントークンを取得する処理

import { getIdToken, User } from "firebase/auth";//Firebase SDKのインポート
import { auth } from "./firebase";//Firebaseクライアントから認証機能のインポート

export const getToken = async() => {
  //Firebase SDKのgetIdTokenメソッドでトークン取得
  const token = await getIdToken(auth.currentUser as User);
  return token; //トークンをリターン
}

処理内容はコメント記載通りです。 FirebaseSDKのgetIdTokenメソッドにより、認証ユーザーのトークンを取得しています。

2. DB処理への適用

Friebase Admin DSK の準備が出来たので、実際の処理に実装していきます。
まず、DB処理に適用します。 具体的には、トークン取得、デコード・検証のプロセスを追加します。
手順としては、まずはサーバーコンポーネントのデータ取得、登録、更新、削除の各機能に適用し、最後にクライアントコンポーネントを変更します。

2.1 データ取得

最初はDBデータ取得処理についてです。

データ取得処理を行うサーバーコンポーネント、/app/api/records/read/route.ts を変更します。
以下、変更後のコード全文です。

//  /app/api/records/read/route.ts
import { NextRequest, NextResponse } from "next/server";//NextRequest追加
import { collection, getDocs, query, where } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";
import { authenticateRequest } from "../../utils/authRequest";//authenticateRequest追加

// **データ取得**
export async function GET(request: NextRequest) {
  //NextRequestに変更

  try {
    // 変更後、ここから
    // トークンの検証を実施
    const decodedToken = await authenticateRequest(request);
    if (!decodedToken) {
      return NextResponse.json(
        { success: false, error: "認証できません: トークンが不正です" },
        { status: 401 }
      );
    }
    console.log("decodedToken: ", decodedToken);
    const email = decodedToken.email; // トークンからemailを取得
    //変更後、ここまで

    //変更前ここから
    //   const email = new URL(request.url).searchParams.get("email"); //URLにパラメータとして付与されたemailを抽出、emailをキーにデータを取得
    //   if (!email) {
    //     //emailが存在しなければ、400エラーを返す
    //     return NextResponse.json(
    //       { success: false, error: "Email is required" },
    //       { status: 400 }
    //     );
    //   }
    //変更前ここまで

    const studiesRef = collection(db, collectionName); 
    const q = query(studiesRef, where("email", "==", email)); 
    const snapshot = await getDocs(q);

    const data: StudyData[] = snapshot.docs.map(
      (doc) => ({ id: doc.id, ...doc.data() } as StudyData)
    );

    console.log("GET:", data);
    return NextResponse.json({ success: true, data }, { status: 200 });
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

冒頭のインポート箇所を追加しています。
また、コメントで、変更前ここから〜変更前ここまで と記載した箇所を、変更後ここから〜変更後ここまで と記載したものに変更します。

それと、export async function GET(request: NextRequest)の箇所も、型エラーが発生したのもあり、request: Request を request: NextRequestに変更しています。

変更後の箇所について、解説します。

//  /app/api/records/read/route.ts

const decodedToken = await authenticateRequest(request); 
//デコードされたトークンをauthRequest.tsのauthenticateRequestから取得
if (!decodedToken) {
  //デコードされたトークンが取得できなければ、エラー処理
  return NextResponse.json(
    { success: false, error: "認証できません: トークンが不正です" },
    { status: 401 }
  );
}
console.log("decodedToken: ", decodedToken);
const email = decodedToken.email; // 取得したトークンからemailを抽出

元々は、FirestoreDBのデータ取得にあたり、emailをURLリクエストのパラメータとして付与する形でしたが、変更後は、デコードしたトークンから、emailを抽出する形にしています。
これにより、クライアントコンポーネントからのリクエスト時はURLパラメータを付与する必要は無くなります。

2.2 データ新規登録

続いて、新規登録処理の変更を行います。
データの新規登録処理を行うサーバーコンポーネント、/app/api/records/create/route.ts を変更します。以下、変更後のコード全文です。

//  /app/api/records/create/route.ts
import { NextRequest, NextResponse } from "next/server"; //NextRequest追加
import { addDoc, collection } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";
import { authenticateRequest } from "../../utils/authRequest"; //authenticateRequest追加

// **データ追加**
export async function POST(request: NextRequest) {
  //NextRequestに変更
  const body: StudyData = await request.json();

  if (!body.email || !body.title || body.time === undefined) {
    return NextResponse.json({ error: "Invalid data" }, { status: 400 });
  }

  try {
    //追加ここから
    // トークンの検証を実施
    const decodedToken = await authenticateRequest(request);
    if (!decodedToken) {
      return NextResponse.json(
        { success: false, error: "認証できません: トークンが不正です" },
        { status: 401 }
      );
    }
    //追加ここまで

    const studiesRef = collection(db, collectionName);
    const docRef = await addDoc(studiesRef, body);
    return NextResponse.json({ success: true });
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

冒頭のインポート箇所、追加しています。 また、データ取得時と同様に、export async function GET(request: NextRequest)の箇所を、request: Request を request: NextRequestに変更しています。
そして、コメントで、追加ここから〜追加ここまで と記載した箇所を、追加します。 トークン検証・デコードのプロセスが追加となります。処理内容としては、以下の通りです。

//  /app/api/records/create/route.ts

// トークンの検証を実施
const decodedToken = await authenticateRequest(request);
//デコードされたトークンをauthRequest.tsのauthenticateRequestから取得
if (!decodedToken) {
  //デコードされたトークンが取得できなければ、エラー処理
  return NextResponse.json(
    { success: false, error: "認証できません: トークンが不正です" },
    { status: 401 }
  );
}

2.3 データ更新

続いて、DBデータ更新処理の変更です。データの更新処理を行うサーバーコンポーネント、/app/api/records/update/route.ts を変更します。
内容としては、新規登録時と同様に、トークン検証・デコードのプロセスが追加となります。伴い、関連コンポーネントのインポートが追加となります。 以下、変更後のコード全文です。

//  /app/api/records/update/route.ts
import { NextRequest, NextResponse } from "next/server";//NextRequest追加
import { doc, updateDoc } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";
import { authenticateRequest } from "../../utils/authRequest";//authenticateRequest追加

// **データ更新**
export async function PUT(request: NextRequest) {
  //NextRequestに変更
  const body: StudyData = await request.json();

  if (!body.id || !body.email || !body.title || body.time === undefined) {
    return NextResponse.json({ error: "Invalid data" }, { status: 400 });
  }

  try {
    //追加ここから
    // トークンの検証を実施
    const decodedToken = await authenticateRequest(request);
    if (!decodedToken) {
      return NextResponse.json(
        { success: false, error: "認証できません: トークンが不正です" },
        { status: 401 }
      );
    }
		//追加ここまで

    const docRef = doc(db, collectionName, body.id);
    await updateDoc(docRef, { title: body.title, time: body.time });
    return NextResponse.json({ success: true });
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

2.4 データ削除

最後に、DBデータの削除処理です。データの削除処理を行うサーバーコンポーネント、/app/api/records/delete/route.ts を変更します。
データ削除についても、変更するのは、トークン検証・デコードのプロセスの追加です。 以下、変更後のコード全文です。

//  /app/api/records/delete/route.ts
import { NextRequest, NextResponse } from "next/server"; //NextRequest追加
import { deleteDoc, doc } from "firebase/firestore";
import { collectionName, db } from "@/app/utils/firebase";
import { StudyData } from "@/app/utils/studyData";
import { authenticateRequest } from "../../utils/authRequest"; //authenticateRequest追加

// **データ削除**
export async function DELETE(request: NextRequest) {
  //NextRequestに変更
  const body: StudyData = await request.json();

  if (!body.id) {
    return NextResponse.json({ error: "ID is required" }, { status: 400 });
  }

  try {
    //追加ここから
    // トークンの検証を実施
    const decodedToken = await authenticateRequest(request);
    if (!decodedToken) {
      return NextResponse.json(
        { success: false, error: "認証できません: トークンが不正です" },
        { status: 401 }
      );
    }
    //追加ここまで

    const docRef = doc(db, collectionName, body.id);
    await deleteDoc(docRef);
    return NextResponse.json({ success: true });
  } catch (error: unknown) {
    console.error("Error fetching studies:", error);
    return NextResponse.json(
      {
        success: false,
        error: (error as Error).message || "Unknown error occurred",
      },
      { status: 500 }
    );
  }
}

2.5 クライアントコンポーネント

サーバーコンポーネントの変更が完了しましたので、クライアントコンポーネントを変更していきます。変更するのは、サーバーコンポーネントとのトランザクションを担っている、/app/components/Records.tsxです。 以下、変更後のコード全文です。

// /app/components/Records.tsx

"use client";
import { useEffect, useRef, useState } from "react";
import {
  AlertDialog,
  AlertDialogBody,
  AlertDialogCloseButton,
  AlertDialogContent,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogOverlay,
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Stack,
  Table,
  TableContainer,
  Tbody,
  Td,
  Th,
  Thead,
  Tr,
  useDisclosure,
  useToast,
  Spinner,
} from "@chakra-ui/react";
import { User } from "firebase/auth";
import { StudyData } from "../utils/studyData";
import { useRouter } from "next/navigation";
import { auth } from "../utils/firebase";
import Edit from "./Edit";
import Delete from "./Delete";
import NewEntry from "./NewEntry";
import { getToken } from "../utils/getToken"; //追加

const Records = () => {
  const [email, setEmail] = useState("");
  const [learnings, setLearnings] = useState<StudyData[]>([]);
  const { isOpen, onOpen, onClose } = useDisclosure();
  const cancelRef = useRef(null);
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter();
  const toast = useToast();
  const [token, setToken] = useState(""); //追加、トークン用ステート

  useEffect(() => {
    const authUser = auth.onAuthStateChanged(async (user) => {
      setUser(user);
      if (user) {
        setEmail(user.email as string);
        //追加、トークンの取得を実施
        const currentToken = await getToken();
        setToken(currentToken);
      } else {
        router.push("/user/login");
      }
    });
    return () => {
      authUser();
    };
  }, []);

  /** Firestoreデータ取得 **/
  const fetchDb = async () => {
    //変更、引数は無しに
    setLoading(true);
    console.log("token:", token);
    console.log("currentUser:", auth.currentUser);
    try {
      const res = await fetch("/api/records/read", {
        method: "GET",
        headers: {
          //追加、ヘッダにトークン情報を付与
          Authorization: `Bearer ${token}`,
        },
      });
      const data = await res.json();

      if (res.ok && data.success) {
        console.log("fetchStudies:", email, data);
        setLearnings(data.data);
      } else {
        console.error("fetchStudiesError", email, data);
        throw new Error(data.error || "Failed to fetch studies.");
      }
    } catch (err: unknown) {
      console.error("Error in fetchStudies:", err);
      toast({
        title: "データ取得に失敗しました",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ新規登録 **/
  const entryDb = async (study: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/create", {
        method: "POST",
        body: JSON.stringify({
          title: study.title,
          time: study.time,
          email: email,
        }),
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`, //追加、ヘッダにトークン情報を付与
        },
      });
      const data = await res.json();
      console.log(data);
      if (data.success) {
        await fetchDb(); // await追加, 引数は無しで
        toast({
          title: "データ登録が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ登録に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ更新 **/
  const updateDb = async (learnings: StudyData) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/update", {
        method: "PUT",
        body: JSON.stringify(learnings),
        headers: {
          "Content-Type": "application/json", //追加、ヘッダにトークン情報を付与
          Authorization: `Bearer ${token}`,
        },
      });
      const data = await res.json();
      if (data.success) {
        await fetchDb(); // await追加, 引数は無しで
        toast({
          title: "データ更新が完了しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "データ更新に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestoreデータ削除 **/
  const deleteDb = async (id: string) => {
    setLoading(true);
    try {
      const res = await fetch("/api/records/delete", {
        method: "DELETE",
        body: JSON.stringify({ id }),
        headers: {
          "Content-Type": "application/json", //追加、ヘッダにトークン情報を付与
          Authorization: `Bearer ${token}`,
        },
      });
      const data = await res.json();
      if (data.success) {
        await fetchDb(); // await追加, 引数は無しで
        toast({
          title: "データを削除しました",
          position: "top",
          status: "success",
          duration: 2000,
          isClosable: true,
        });
      } else {
        throw new Error(data.error);
      }
    } catch (err: unknown) {
      toast({
        title: "デー削除に失敗しました",
        description: `${err}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** Firestore確認 **/
  useEffect(() => {
    if (email) {
      fetchDb(); // 引数は無しで
      console.log("useEffectFirestore:", email, user);
    }
  }, [user]);

  /**ログアウト処理 **/
  const handleLogout = async () => {
    setLoading(true);
    try {
      const usertLogout = await auth.signOut();
      console.log("User Logout:", usertLogout);
      toast({
        title: "ログアウトしました",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/user/login");
    } catch (error) {
      console.error("Error during logout:", error);
      toast({
        title: "ログアウトに失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 4000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  /** 学習時間合計 **/
  const calculateTotalTime = () => {
    return learnings.reduce((total, learning) => total + learning.time, 0);
  };
  
    return (
    <>
      <Flex alignItems="center" justify="center" p={5}>
        <Card size={{ base: "sm", md: "lg" }}>
          <Box textAlign="center" mb={2} mt={10}>
            ようこそ!{email} さん
          </Box>
          <Heading size="md" textAlign="center">
            Learning Records
          </Heading>
          <CardBody>
            {/*学習記録表示 */}
            <Box textAlign="center">
              学習記録
              {loading && (
                <Box p={10}>
                  <Spinner />
                </Box>
              )}
              <TableContainer>
                <Table variant="simple" size={{ base: "sm", md: "lg" }}>
                  <Thead>
                    <Tr>
                      <Th>学習内容</Th>
                      <Th>時間()</Th>
                      <Th></Th>
                      <Th></Th>
                    </Tr>
                  </Thead>
                  <Tbody>
                    {learnings.map((learning, index) => (
                      <Tr key={index}>
                        <Td>{learning.title}</Td>
                        <Td>{learning.time}</Td>
                        <Td>
                          <Edit
                            learning={learning}
                            updateDb={updateDb}
                            loading={loading}
                          />
                        </Td>
                        <Td>
                          <Delete
                            learning={learning}
                            deleteDb={deleteDb}
                            loading={loading}
                          />
                        </Td>
                      </Tr>
                    ))}
                  </Tbody>
                </Table>
              </TableContainer>
            </Box>
            {!loading && (
              <Box p={5}>
                <div>合計学習時間:{calculateTotalTime()}</div>
              </Box>
            )}

            {/*データ新規登録*/}
            <Box p={25}>
              <NewEntry
                learnings={learnings}
                loading={loading}
                updateDb={updateDb}
                entryDb={entryDb}
              />
            </Box>

            {/* ログアウト*/}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button width="100%" variant="outline" onClick={onOpen}>
                  ログアウト
                </Button>
                <AlertDialog
                  motionPreset="slideInBottom"
                  leastDestructiveRef={cancelRef}
                  onClose={onClose}
                  isOpen={isOpen}
                  isCentered
                >
                  <AlertDialogOverlay />
                  <AlertDialogContent>
                    <AlertDialogHeader>ログアウト</AlertDialogHeader>
                    <AlertDialogCloseButton />
                    <AlertDialogBody>ログアウトしますか?</AlertDialogBody>
                    <AlertDialogFooter>
                      <Button ref={cancelRef} onClick={onClose}>
                        Cancel
                      </Button>
                      <Button
                        isLoading={loading}
                        loadingText="Loading"
                        spinnerPlacement="start"
                        colorScheme="red"
                        ml={3}
                        onClick={handleLogout}
                      >
                        ログアウト
                      </Button>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </Stack>
            </Box>

            {/*パスワード更新 */}
            <Box px={25} mb={4}>
              <Stack spacing={3}>
                <Button
                  width="100%"
                  variant="outline"
                  onClick={() => router.push("/user/updatePass")}
                >
                  パスワード更新
                </Button>
              </Stack>
            </Box>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};

export default Records;

追加や変更と記載をしている箇所が、更新箇所となります。 冒頭インポート箇所で、トーク取得のユーティリティ、getTokenをインポートしてます。

// /app/components/Records.tsx

import { getToken } from "../utils/getToken";//追加


続いて、トークン管理用のステート、tokenを新たに追加しています。

// /app/components/Records.tsx

const [token, setToken] = useState(""); //追加、トークン用ステート


次にuseEffectの処理で、トークンを取得し、そのトークンをseTokenにてtokenステートに格納しています。

// /app/components/Records.tsx

useEffect(() => {
  const authUser = auth.onAuthStateChanged(async (user) => {
    setUser(user);
    if (user) {
      setEmail(user.email as string);
      //追加、トークンの取得を実施
      const currentToken = await getToken();
      setToken(currentToken); //トークンをステートに反映
    } else {
      router.push("/user/login");
    }
  });
  return () => {
    authUser();
  };
}, []);


データの取得処理、fetchDbについてです。

// /app/components/Records.tsx

/** Firestoreデータ取得 **/
const fetchDb = async () => {
  //変更、引数は無しに
  setLoading(true);
  console.log("token:", token);
  console.log("currentUser:", auth.currentUser);
  try {
    const res = await fetch("/api/records/read", {
      method: "GET",
      headers: {
        //追加、ヘッダにトークン情報を付与
        Authorization: `Bearer ${token}`,
      },
    });
    const data = await res.json();

    if (res.ok && data.success) {
      console.log("fetchStudies:", email, data);
      setLearnings(data.data);
    } else {
      console.error("fetchStudiesError", email, data);
      throw new Error(data.error || "Failed to fetch studies.");
    }
  } catch (err: unknown) {
    console.error("Error in fetchStudies:", err);
    toast({
      title: "データ取得に失敗しました",
      position: "top",
      status: "error",
      duration: 2000,
      isClosable: true,
    });
  } finally {
    setLoading(false);
  }
};

これまで、emailを引数として渡してましたが、サーバーサイドでデコードしたトークンからemailを抽出する形に変更しましたので、引数無しに変更しています。 また、トランザクションのヘッダーにトークン情報を付与するように追加しています。

以降の、entryDb, updateDb, deleteDbについても同様、トランザクションのヘッダーにトークン情報付与を追加しています。
また、それぞれ、DBデータの再取得でfetchDbを実行してますが、引数を無しにしてます。及びawaitにしないとうまく処理されないケースがありましたので、そうしてます。

// /app/components/Records.tsx
await fetchDb(); // await追加, 引数は無しで

JSXの箇所は特に変更ありません。

実際にアプリを動かして挙動を確認してみます。/app/api/records/read/route.tsにコンソールログにdecodedTokenを出力するようにしていますので、確認します。

//  /app/api/records/read/route.ts
console.log("decodedToken: ", decodedToken);


ターミナルにて、npm run devコマンドで開発サーバーを起動します。

npm run dev

> next-learnings-firebase-admin@0.1.0 dev
> next dev

    Next.js 15.1.3
   - Local:        http://localhost:3000
   - Network:      http://192.168.3.23:3000
   - Environments: .env.local

起動されたアドレス(http://localhost:3000)にアクセスします。
アプリの画面が表示されますので、ログインしていなければログインします。
ログインされていれば、Home画面に遷移します。そのタイミングでFirestoreDBのデータ取得が行われますので、ターミナル上のログを確認します(サーバーコンポーネントの処理なので、ブラウザのコンソールには表示されず、ターミナル側に出力されます)。
下図にようにデコードされたトークン情報が出力されます。

3. 認証関係

続いては、ユーザー認証関係の機能をAdmin SDKで実装してみたいと思います。
冒頭に記載した通り、ユーザー認証・セッション関係はクライアントサイドのSDKで簡単に実装出来るようになっており、Admin SDKを利用してサーバーサイドで実装するには、カスタムトークンによる制御等の独自のセッション管理が必要になってきます。システムの構造・ポリシー上、そういうケースが必要な場合には有用ですが、そうでない場合は、クライアントサイドでの実装が合理的です。

こちらの章では、パスワード更新機能のみ、Admin SDKで実装してみましたので、ご紹介したいと思います。

3.1 パスワード更新

それでは、パスワード更新のサーバーコンポーネントを作成します。/app/apiフォルダ配下に、新たにuserフォルダを作成し、更にupdatePassを作成します。 その中に、ファイルroute.tsを作成します。
フルパスで記載すると、/app/api/user/updatePass/route.tsです。

route.tsに以下内容を記載します。下記は、コード全文です。

// /app/api/user/updatePass/route.ts

import { NextRequest, NextResponse } from "next/server"; //next/serverよりリクエスト、レスポンスのインポート
import { authenticateRequest } from "../../utils/authRequest"; //トークン検証、デコード機能をインポート
import { auth } from "../../utils/firebaseAdmin"; //Firebase AuthenticationのインスタンスをfirebaseAdmin.tsからインポート

export async function POST(request: NextRequest) {
  try {
    // トークンの検証を実施
    const decodedToken = await authenticateRequest(request);

    if (!decodedToken) {
      //デコードしたトークンが存在しない場合、エラー処理
      return NextResponse.json(
        { success: false, error: "認証できません: トークンが不正です" },
        { status: 401 }
      );
    }

    const uid = decodedToken.uid; //トークンよりuidを抽出
    const body = await request.json();//クライアントからのリクエストをJSON形式でbodyに格納
    const { password } = body;//bodyからpasswordを抽出

    if (!password) {
      //passwordが存在しなければ
      return NextResponse.json(//エラーをリターン
        { error: "Password are required" },
        { status: 400 }
      );
    }

    const userCredential = await auth.updateUser(uid, {
      //Admin SDKのupdateUserメソッドで該当uidのパスワードを更新
      password: password,
    });

    return NextResponse.json({//処理に成功すれば、成功メッセージをリターン
      message: "User Register successful",
      user: userCredential.email,
    });
  } catch (error: unknown) {//エラー発生の場合は、エラーをリターン
    return NextResponse.json(
      { error: (error as Error).message || "Unknown error occurred" },
      { status: 500 }
    );
  }
}

コード中に処理内容をコメント記載してます。トークン取得、検証・デコードの上、デコードしたトークンより、uidを抽出しています。 Admin SDKのupdateUserにより、該当するuidのパスワードをpasswordに更新処理をしています。

クライアントサイドでのパスワード更新時は、ユーザー再認証の上、現在のパスワード情報も必要でしたが、Admin SDKで処理を行う場合は、現在のパスワードは必要ありません。
つまり、それだけ強力な権限を持つメソッドとなりますので、利用にあたっては、サーバー側で十分なセキュリティ確保の仕組みが必要になると言う事になります。

3.2 クライアントの変更

サーバーコンポーネントを作成しましたので、それを利用するようクライアントコンポーネントを変更します。 対象ファイルは、/app/user/updatePass/page.tsx です。
変更後の内容を以下に記載します。コード全文です。

// /app/user/updatepass/page.tsx

"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  useToast,
} from "@chakra-ui/react";
import { RiLockPasswordFill } from "react-icons/ri";
import {
  // reauthenticateWithCredential,削除
  // updatePassword,削除
  User,
} from "firebase/auth";
// import { EmailAuthProvider } from "firebase/auth/web-extension";削除
import { auth } from "@/app/utils/firebase";
import { getToken } from "@/app/utils/getToken";

const UpdatePass = () => {
  const [formState, setFormState] = useState({
    password: "",
    passwordConf: "",
    // currentPassword: "",削除
  });
  const [loading, setLoading] = useState(false);
  const [user, setUser] = useState<User | null>(null);
  const router = useRouter();
  const toast = useToast();
  const [token, setToken] = useState(""); //追加:トークン用ステート

  useEffect(() => {
    const authUser = auth.onAuthStateChanged(async (user) => {
      //変更:async/awaitに
      setUser(user);
      const currentToken = await getToken(); //追加:トークンの取得を実施
      setToken(currentToken);
    });
    return () => {
      authUser();
    };
  }, []);

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormState((prevState) => ({
      ...prevState,
      [name]: value,
    }));
  };

  const handleUpdatePassword = async (e: React.FormEvent) => {
    e.preventDefault();
    if (formState.password !== formState.passwordConf) {
      toast({
        title: "パスワードが一致しません",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    } else if (formState.password.length < 6) {
      toast({
        title: "パスワードは6文字以上にしてください",
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
      return;
    }
    try {
      setLoading(true);

      //削除ここから
      /*       パスワードの更新はユーザの再認証が必要
      if (user) {
        // 再認証のために、ユーザーの認証情報を取得
        const credential = EmailAuthProvider.credential(
          user.email!,
          formState.currentPassword 
        );
        console.log("パスワード更新", user);
        await reauthenticateWithCredential(user, credential);
      await updatePassword(user, formState.password); */
      //削除ここまで

      // 追加ここから
      const password = formState.password;
      const response = await fetch("/api/user/updatePass", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({ password }),
      });

      const result = await response.json();

      if (!response.ok) {
        throw new Error(result.error || "Unknown error");
      }
      console.log("User Signuped:", result);
      // 追加ここまで

      toast({
        title: "パスワード更新が完了しました。",
        position: "top",
        status: "success",
        duration: 2000,
        isClosable: true,
      });
      router.push("/");
    } catch (error: unknown) {
      toast({
        title: "パスワード更新に失敗しました",
        description: `${error}`,
        position: "top",
        status: "error",
        duration: 2000,
        isClosable: true,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <Flex justifyContent="center" boxSize="fit-content" mx="auto" p={5}>
        <Card size={{ base: "sm", md: "lg" }} p={4}>
          <Heading size="md" textAlign="center">
            パスワード更新
          </Heading>
          <CardBody>
            <form onSubmit={handleUpdatePassword}>
              {/*  削除
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="現在のパスワードを入力"
                  name="currentPassword"
                  value={formState.currentPassword}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup> */}
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="新パスワードを入力"
                  name="password"
                  value={formState.password}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <InputGroup>
                <InputLeftElement pointerEvents="none">
                  <RiLockPasswordFill color="gray" />
                </InputLeftElement>
                <Input
                  type="password"
                  placeholder="新パスワードを入力(確認)"
                  name="passwordConf"
                  value={formState.passwordConf}
                  required
                  mb={2}
                  onChange={handleInputChange}
                />
              </InputGroup>
              <Box mt={4} mb={2} textAlign="center">
                <Button
                  isLoading={loading}
                  loadingText="Loading"
                  spinnerPlacement="start"
                  type="submit"
                  colorScheme="green"
                >
                  パスワードを更新する
                </Button>
                <Button
                  colorScheme="gray"
                  onClick={() => router.push("/")}
                  mx={2}
                >
                  戻る
                </Button>
              </Box>
            </form>
          </CardBody>
        </Card>
      </Flex>
    </>
  );
};
export default UpdatePass;

コード中に追加・削除となる箇所を記載しています。
冒頭インポートの箇所は、不要となるインポートを削除します。

// /app/user/updatepass/page.tsx

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
  Box,
  Button,
  Card,
  CardBody,
  Flex,
  Heading,
  Input,
  InputGroup,
  InputLeftElement,
  useToast,
} from "@chakra-ui/react";
import { RiLockPasswordFill } from "react-icons/ri";
import {
  // reauthenticateWithCredential,削除
  // updatePassword,削除
  User,
} from "firebase/auth";
// import { EmailAuthProvider } from "firebase/auth/web-extension";削除
import { auth } from "@/app/utils/firebase";
import { getToken } from "@/app/utils/getToken";


次にフック関係です。

// /app/user/updatepass/page.tsx

const [formState, setFormState] = useState({
  password: "",
  passwordConf: "",
  // currentPassword: "",削除
});
const [loading, setLoading] = useState(false);
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
const toast = useToast();
const [token, setToken] = useState(""); //追加:トークン用ステート

formStateについては、currentPasswordを削除しています。これはサーバーサイドのパスワード更新処理の場合は、現在のパスワード情報は不要なためです。 Admin SDKの機能としては、そうですが、実際の実装においてはセキュリティ面考慮して現在のパスワード検証も入れた方がベターだとは思います。
他、新たなステートとして、トークン用ステート、token, setToken を追加しています。

続いて、handleUpdatePasswordです。

// /app/user/updatepass/page.tsx

const handleUpdatePassword = async (e: React.FormEvent) => {
.
.
.
//削除ここから
/*       パスワードの更新はユーザの再認証が必要
      if (user) {
        // 再認証のために、ユーザーの認証情報を取得
        const credential = EmailAuthProvider.credential(
          user.email!,
          formState.currentPassword 
        );
        console.log("パスワード更新", user);
        await reauthenticateWithCredential(user, credential);
      await updatePassword(user, formState.password); */
//削除ここまで

// 追加ここから
const password = formState.password; //パスワードをセット
const response = await fetch("/api/user/updatePass", {
  //サーバーコンポーネントに対してリクエスト
  method: "POST", //メソッドはPOST
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`, //ヘッダーにトークンを付与
  },
  body: JSON.stringify({ password }), //リクエストのbodyにJSON形式でパスワードをセット
});

const result = await response.json();

if (!response.ok) {//エラーの場合は、エラー処理
  throw new Error(result.error || "Unknown error");
}
console.log("User Signuped:", result);
// 追加ここまで

削除ここから〜削除ここまで の箇所を削除し、追加ここから〜追加ここまで の箇所と入れ替えます。
これまでクライアントサイドで、ユーザー再認証の上、クライアント側のFirebase SDK、reauthenticateWithCredentialメソッドでパスワード更新処理を行っていましたが、追加部分の通り、作成したサーバーコンポーネントに対しリクエストを投げています。
ヘッダーにトークンを付与し、リクエストのbodyに更新パスワードを格納しています。

最後、JSXの箇所です。

// /app/user/updatepass/page.tsx

<form onSubmit={handleUpdatePassword}>
{/*  削除
<InputGroup>
  <InputLeftElement pointerEvents="none">
    <RiLockPasswordFill color="gray" />
  </InputLeftElement>
  <Input
    type="password"
    placeholder="現在のパスワードを入力"
    name="currentPassword"
    value={formState.currentPassword}
    required
    mb={2}
    onChange={handleInputChange}
  />
</InputGroup> */}

現在のパスワード入力は不要となりましたので、その部分をカットしています。 ここではこうしてますが、実アプリでの実装においては、セキュリティを考慮して、現在のパスワードチェックはあった方がベターだとは思います。

この実装により、パスワード更新は下図のような動きとなります。

アプリの動き

ここまでで、Firebase Admin SDKの実装は完了です。
拙い内容ですが、どなたかの参考になれば幸いです。
なお、本記事の完成後のソースコードはこちらです。
https://github.com/amaya690221/pub-next-learnings-firebase-admin


[Next.js] 学習記録アプリ Firebase認証・DB実装

ソースコード


Firebase認証・DB連携:React編

No responses yet

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

AD




TWITTER


アーカイブ
OTHER ISSUE
PVアクセスランキング にほんブログ村