🌊

Nextjs,TypeScriptでFirebaseを使ってGoogleログイン機能を実装!

2023/04/05に公開1

firebase を使って Google ログイン機能を実装します!
使う言語は Nextjs,TypeScript です
難易度はぐぐんと上がりますが
なるべくわかりやすく解説していきます

デモサイトです
デモサイト

参考記事
https://zenn.dev/nino_cast/books/43c539eb47caab

準備

まずはプロジェクトの作成です。

ターミナル
// npmの場合
$ npx create-next-app --ts

// yarnの場合
$ yarn create next-app --typescript

続いて Firebase からプロジェクトを作成を行います。

プロジェクト作成

左上のプロジェクトを作成ボタンを押してプロジェクト名を入力してください
今回はログイン機能だけなので僕のプロジェクト名は login としました。

続いてアナリティクス設定ですが今回使わないので無効にしておきます

アナリティクス設定

そして右下のプロジェクト作成ボタンを押します。
新しいプロジェクトの準備ができましたら続行ボタンを押して firebaseTop ページにいきます。

TOP ページにいきましたら、左側にある構築のタグまたは TOP のすぐ下にある
Authentication を押します。

Authentication

始めるボタンを押して、ログインプロバイダから Google を選択します。

有効にするボタンを押してサポートメールを設定し、保存ボタンを押します。

loginProvider

続いて左のタブの歯車ボタンからプロジェクトの設定を選びます。

project

下の方にあるマイアプリから Web を選択します。

myappWeb

アプリのニックネームを入力し(今回は login としました。),
Firebase SDK の追加の部分で npm を使用するを選びます。
firebaseConfig 見えなくしていますが
それぞれ表示されるのでこれをコピーします。

firebase

Firebase の初期化

まず Firebase をインストールします。

ターミナル
// npmの場合
$ npm install firebase

// yarnの場合
$ yarn add firebase

次に初期化を行うため、lib ディレクトリーを作成し,
firebase.ts というファイルを作成します。

lib/firebase.ts
import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: 'xxx',
  authDomain: 'xxx',
  projectId: 'xxx',
  storageBucket: 'xxx',
  messagingSenderId: 'xxx',
  appId: 'xxx',
};

if (!getApps()?.length) {
  initializeApp(firebaseConfig);
}

export const auth = getAuth();
export const db = getFirestore();

1 つ 1 つ解説していきます。

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

認証に必要な要素をインポートしています。initializeApp, getApps は
firebase/app から getFirestore はfirebase/firestore
getAuth は firebase/auth からインポートします。

firebase
const firebaseConfig = {
  apiKey: 'xxx',
  authDomain: 'xxx',
  projectId: 'xxx',
  storageBucket: 'xxx',
  messagingSenderId: 'xxx',
  appId: 'xxx',
};

Firebase と連携するための情報を入力します。
先程の画像の FirebaseConfig から内容をコピーします。

/firebase
if (!getApps()?.length) {
  initializeApp(firebaseConfig);
}

このコードは Firebase がすでに初期化されているかチェックして、
初期化されていない場合初期化をする関数です。
!getApps()?.lengthは現在アプリが 0 である場合に true を返し、
初期化をするようにしています。

firebase
export const auth = getAuth();
export const db = getFirestore();

これらのコードは Firebase の認証と Firebase のデータベースを初期化して
エクスポートしています。

ログイン・ログアウト機能実装

lib ディレクトリ配下にauth.tsファイルを作成し、認証関連を入力します。

lib/auth.ts
import {
  GoogleAuthProvider,
  signInWithPopup,
  UserCredential,
  signOut,
} from 'firebase/auth';
import { auth } from "./firebase";


export const login = (): Promise<UserCredential> => {
  const provider = new GoogleAuthProvider();
  return signInWithPopup(auth, provider);
};

export const logout = (): Promise<void> => {
  return signOut(auth);
};

今回も 1 つ 1 つ解説していきます。

ts
import {
  GoogleAuthProvider,
  signInWithPopup,
  UserCredential,
  signOut,
} from 'firebase/auth';
import { auth } from "./firebase";

Google アカウントを使用した認証に必要なGoogleAuthProvider,
認証ポップアップを表示するsignInWithPopup,
認証に成功したユーザー情報を取得するUserCredential,
ユーザーをログアウトするための機能signOut
firebase/authより
先程のfirebase.tsで定義したauthをインポートします

ts
export const login = (): Promise<UserCredential> => {
  const provider = new GoogleAuthProvider();
  return signInWithPopup(auth, provider);
};

Firebase にログインするための関数 login を定義し Promise を使用して
非同期処理を行いその結果としてユーザー情報を取得する UserCredential を返します。
provider という定数に new を使用して GoogleAuthProvider オブジェクトを作成します。
signInWithPopup でポップアップウィンドウで Google ログインの処理を行います。
最後にログインが成功すると非同期処理を行っていた Promise を返します。

ts
export const logout = (): Promise<void> => {
  return signOut(auth);
};

ログアウトするための関数です。
signOut(auth)で現在認証されているユーザーをログアウトし
最後非同期処理でPromise<void>を返しログアウトを完了させます。

認証コンテキストの作成

続いてログイン状態かログアウト状態かなどの情報を保持する認証コンテキストを作成します。
まずはユーザーの型を作成します。
typesフォルダを作成し、user.tsファイルを作成します。

types/user.ts
export type User = {
  id: string;
  name: string;
};

User という型を定義し id と name を
stringで定義します。

続いてコンテキストを作成します。
contextフォルダを作成し、その中にauth.tsxファイルを作成します

context/auth.tsx
import { User } from "@/types/user";
import { createContext } from "react";

type UserContextType = User | null | undefined;

const AuthContext = createContext<UserContextType>(undefined);

1 つ 1 つ解説します。

ts
import { User } from "@/types/user";
import { createContext } from "react";

先程user.tsで設定したUser
新しいコンテキストを作成するcreateを react からインポートします。

ts
type UserContextType = User | null | undefined;

UserContextType という新しい型をユニオン型で定義しています。
ログイン中の場合はUserをログインをしていない場合はnull
ローディング中はundefinedを返しそれぞれにあった UI を返します。

ts
const AuthContext = createContext<UserContextType>(undefined);

undefinedを初期値にもった AuthContext という新しいコンテキストを
作成しています。型は先程定義したUserContextTypeを指定しています。

中身の Provider を書いて行きます。

context/auth.tsx

import { auth, db } from "@/lib/firebase";
import { User } from "@/types/user";
import { doc, getDoc, setDoc } from "@firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
import {
  ReactNode,
  createContext,
  useContext,
  useEffect,
  useState,
} from "react";

type UserContextType = User | null | undefined;

const AuthContext = createContext<UserContextType>(undefined);


// 以下を追加
export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<UserContextType>();

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
      if (firebaseUser) {
        const ref = doc(db, `users/${firebaseUser.uid}`);
        const snap = await getDoc(ref);

        if (snap.exists()) {
          const appUser = (await getDoc(ref)).data() as User;
          setUser(appUser);
        } else {
          const appUser: User = {
            id: firebaseUser.uid,
            name: firebaseUser.displayName!,
          };

          setDoc(ref, appUser).then(() => {
            setUser(appUser);
          });
        }
      } else {
        setUser(null);
      }

      return unsubscribe;
    });
  }, []);

  return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
};

export const useAuth = () => useContext(AuthContext);

tsx
export const AuthProvider = ({ children }: { children: ReactNode }) => {
  //省略
  return <AuthContext.Provider value={user}>{children}</AuthContext.Provider>;
}

まず AuthProvider は受け取った children という props を使用して
ラップされた要素全てに認証情報を提供します。
型は React のプロパティ型の1つの ReactNode を指定しています。
最後の return 文はAuthContext.Providerと呼ばれる
React のコンテキストを使用して、user 変数を設定し、
子要素がユーザー情報を提供できるようにしています。

tsx
const [user, setUser] = useState<UserContextType>();

useState を使用して user という変数を定義し、更新関数として setUser を指定しています。
型は先程作成したUserContextTypeを指定します。

tsx
useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
      if (firebaseUser) {
        const ref = doc(db, `users/${firebaseUser.uid}`);
        const snap = await getDoc(ref);

        if (snap.exists()) {
          const appUser = (await getDoc(ref)).data() as User;
          setUser(appUser);
        } else {
          const appUser: User = {
            id: firebaseUser.uid,
            name: firebaseUser.displayName!,
          };

          setDoc(ref, appUser).then(() => {
            setUser(appUser);
          });
        }
      } else {
        setUser(null);
      }

      return unsubscribe;
    });
  }, []);

useEffect を用いて認証状態が変化したときに呼ばれる関数を作成しています。

tsx
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
  //省略
},[])

関数unsubscribeにログイン、ログアウトの変化が起きたときに
コールバック関数を呼び出すonAuthStateChangedメソッドを指定します。
第 1 引数にlib/firebaseから auth を第 2 引数に現在の認証ユーザーを
表すfirebaseUserを引数で受け取ります。
useEffect の第 2 引数に[]を渡すことで 1 度だけ
更新するようにしています。

tsx
const ref = doc(db, `users/${firebaseUser.uid}`);

ログインしているユーザーの情報を保持する関数 ref として
Firestore データベース内のドキュメントを示すdoc()を使用し
users コレクション内の指定されたfirebaseUser.uidを持つ
ドキュメントを取得します。

tsx
const snap = await getDoc(ref);

snap という定数に非同期処理でドキュメントの内容を取得する
getDoc 関数を使います。
引数として先程のユーザーの情報を保持している ref をしています。

tsx
if (snap.exists()) {
  const appUser = (await getDoc(ref)).data() as User;
  setUser(appUser);
}

先程の定数と Firestore のDocumentSnapshotオブジェクトのメソッド
exists()を用いてユーザーがドキュメントに存在しているかチェックします。
もしドキュメントに存在している場合、
appUser としてUser型を定義し、
先程と同様に getDoc 関数を使いdata()で取得された
ドキュメントデータを取得、最後に state の更新関数である
setUser に渡します。

tsx
else {
  const appUser: User = {
    id: firebaseUser.uid,
    name: firebaseUser.displayName!,
  };

  setDoc(ref, appUser).then(() => {
    setUser(appUser);
  });
}

もしドキュメントに存在していない場合 Firestore に
ユーザー情報を保存する処理します。
appUser で新しいユーザーの情報をオブジェクト形式で
作成し、その後setDoc(ref,appUser)で Firestore の
users コレクション内に新しいユーザーを保存します。
保存が完了したらthen()メソッドが呼ばれ
setUser(appUser)でアプリの状態を更新します。

tsx
else {
  setUser(null);
}

ログアウトした場合にアプリのユーザーを初期化します。

tsx
return unsubscribe;

このコンポーネントが不要になったら
監視を終了させます。
これによりメモリーリークを防止できます。

tsx
export const useAuth = () => useContext(AuthContext);

最後にコンテキストのデータを受け取るためにuseContextを実装します。
必要な都度useContext(AuthContext);と書いてもいいですが、
短くするためにuseAuthを指定します。
ReactHook なので関数内で使用する必要があるので
アロー関数を使用しています。

Provider を書き終わったので、次は全体で
ユーザー情報を供給するために_app.tsxで全体を囲みます。

_app.tsx
+import { AuthProvider } from "@/context/auth";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return (
+    <AuthProvider>
      <Component {...pageProps} />
+    </AuthProvider>
  );
}

続いて Firebase でセキュリティルールを設定します。
Firestore からログインしたユーザーのデータを読み込むのと
ユーザーデータがない場合 Firestore にユーザーデータを作成する
ルールを設定します。
これの設定を行わないとログインした際に Firebase に対して
認証されていませんとエラーが出てしまいます。

Firebase の左のメニューの構築から
Firestore Database を選択します。

Database

データベースの作成ボタンを押し、
本番環境モードで開始するを選択し、右下の次へボタンを選択します。

次にロケーションの設定を指定されるので
asia-northeast1(Tokyo)を選択します。
住んでる場所が大阪に近ければ
asia-northeast2(Osaka)を選択してください。
選択しましたら有効にするを押します

ロケーション

データベースを作成したら上にあるタブのルールを選択します。
ルールを編集ボタンを押し次のコードに置き換えます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{uid} {
      allow read, write: if request.auth != null && request.auth.uid == uid;
    }
  }
}

解説していきます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

rules_vearsionはセキュリティのバージョンを定義しています。
service cloud.firestoreは Cloud Firestore サービスに対する
ルールであることを指定しています。
match /databases/{database}/documents
Firebase プロジェクト内すべてのデータベースに対して
セキュリティルールを適用することをあらわしています。

match /users/{uid} {
  allow read, write: if request.auth != null && request.auth.uid == uid;
}

usersコレクション内の読み込み(read)と書き込み(write)の
権限を設定しています。
request.auth がnullではなくかつ、request.auth.uid
uidが一致する場合に
ユーザーが自分のデータにアクセスできる様になります。

最後にindex.jsxにログインボタンとログアウトボタンを作成します。

pages/index.jsx
import { useAuth } from "@/context/auth";
import { login, logout } from "@/lib/auth";
import { useState } from "react";

export default function Home() {
  const user = useAuth();
  const [waiting, setWaiting] = useState<boolean>(false);

  const signIn = () => {
    setWaiting(true);

    login()
      .catch((error) => {
        console.error(error?.code);
      })
      .finally(() => {
        setWaiting(false);
      });
  };
  return (
    <div>
      {user === null && !waiting && <button onClick={signIn}>ログイン</button>}
      {user && <button onClick={logout}>ログアウト</button>}
    </div>
  );
}

tsx
const user = useAuth();
const [waiting, setWaiting] = useState<boolean>(false);

まず認証されたユーザーの情報をuserに保存し、
useState を使ってwaitingという state を初期値は false で作ります。
ログイン後 waiting の値が変更されるので更新関数の
setWaitingも定義します。

tsx
const signIn = () => {
  setWaiting(true);

  login()
    .catch((error) => {
      console.error(error?.code);
    })
    .finally(() => {
      setWaiting(false);
    });
};

signIn関数が実行されたときwaitingを true に設定します。
lib/auth.tsで設定したlogin関数で
非同期処理の Promise を使用しているため
catch()finally()を使って Promise が完了したあとに
実行する処理を行っています。
catch()でエラーが発生した際にエラーコードを
finally()で waiting を false に設定しています。

<div>
  {user === null && !waiting && <button onClick={signIn}>ログイン</button>}
  {user && <button onClick={logout}>ログアウト</button>}
</div>

ユーザーがログインしている場合はログアウトボタンを、
ユーザーがログインしていない、かつwaitingが false の場合
signIn関数が呼び出されログインボタンを
表示させます。

これで完成です!

さいごに

お疲れさまでした!
Firebase と Nextjs で Google ログイン機能は
とても複雑で難しかったです。
伝わりにくかった部分もあったと思いますが
ご了承ください。
最後までお読みいただきありがとうございました。

Discussion

じょうげんじょうげん

失礼します
context/auth.tsx内のuseEffect内のonAuthStateChangedが受け取るコールバック関数内で外側の自分自身をreturnしようとしていますが、これは以下の誤りだと思われます。
この記事を見て困っている方がいたので修正していただけると幸いです。

context/aurh.tsx
useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
      if (firebaseUser) {
        const ref = doc(db, `users/${firebaseUser.uid}`);
        const snap = await getDoc(ref);

        if (snap.exists()) {
          const appUser = (await getDoc(ref)).data() as User;
          setUser(appUser);
        } else {
          const appUser: User = {
            id: firebaseUser.uid,
            name: firebaseUser.displayName!,
          };

          setDoc(ref, appUser).then(() => {
            setUser(appUser);
          });
        }
      } else {
        setUser(null);
      }
    });
    return unsubscribe;
  }, []);