🔐

React(Next.js) × Firebaseで認証まわりをまるっと開発する

2022/03/13に公開
2

概要

タイトルのままですが、React(Next.js) × Firebaseの環境で、認証まわりをまるっと開発したので、記事に残します。
アラート等のコンポーネントはMaterial UIを使用しています。

やったこと

以下の4つです。

  • ユーザー登録
  • ログイン
  • ログアウト
  • アクセスコントロール
    • 未ログイン時に特定のページに入ろうとしたらアラート & 遷移
    • ログイン時にログインページなどに行ったらアラート & 遷移

前提

  • react: 17.0.2
  • next: 12.0.9
  • typescript: 4.5.5
  • firebase: 9.6.5
  • emotion: 11.0.0

また、あらかじめfirebase.tsというところで以下の設定をしています。

firebase.ts
import { initializeApp } from "firebase/app"

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_MESSAGE_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}

export const app = initializeApp(firebaseConfig)

ユーザー登録

とりあえずフォームを設けましょう。えい。

Signup.tsx
<form onSubmit={handleSubmit}>
  <div
    css={css`
      display: flex;
      justify-content: center;
      align-items: center;
    `}
  >
    <InputLabel>メールアドレス</InputLabel>
    <TextField
      name="email"
      type="email"
      size="small"
      onChange={handleChangeEmail}
      css={css`
        padding-left: 12px;
      `}
    />
  </div>
  <div
    css={css`
      display: flex;
      justify-content: flex-end;
      align-items: center;
      margin-top: 16px;
    `}
  >
    <InputLabel>パスワード</InputLabel>
    <TextField
      name="password"
      type="password"
      size="small"
      onChange={handleChangePassword}
      css={css`
        padding-left: 12px;
      `}
    />
  </div>
  <div
    css={css`
      display: flex;
      justify-content: flex-end;
      margin-top: 16px;
    `}
  >
    <Button type="submit" variant="outlined">
      登録
    </Button>
  </div>
  <div
    css={css`
      display: flex;
      justify-content: flex-end;
      margin-top: 24px;
    `}
  >
    <Link href={"/login"}>
      <a>すでに登録している人はこちら</a>
    </Link>
  </div>
</form>

InputLabel, TextField, ButtonはMaterial UIで、LinkはNextのものです。
importはよしなにお願いします。
またCSSは適当です。

handleSubmit, handleChangeEmail, handleChangePasswordが未定義ですね。用途は名前の通りなので、それぞれ実装していきましょう。

Signup.tsx
import { app } from "../src/firebase"
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"

const router = useRouter()
const auth = getAuth(app)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()
  await createUserWithEmailAndPassword(auth, email, password)
  router.push("/")
}
const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
  setEmail(e.currentTarget.value)
}
const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
  setPassword(e.currentTarget.value)
}

handleChangeEmail, handleChangePasswordでやっていることはstateのsetのみです。文字が入力される都度それぞれのstateに値を保存します。

そして、保存したemail, passwordをFirebaseにぶん投げ、新規ユーザーを登録します。
それを行っているのが、handleSubmit内のauth.createUserWithEmailAndPassword(email, password)です。

この一行でユーザー登録ができるので、Firebaseは強いですね。

ユーザー登録後は、ルートのページに遷移させます。ルートに飛ばしていますが、もちろんここはどこでも大丈夫です。

新規登録でやることは以上になります。
コード全文はこちらです。

Signup.tsx
Signup.tsx
import React, { useState } from "react"
import { useRouter } from "next/router"
import Link from "next/link"
import { Alert, Button, InputLabel, Snackbar, TextField } from "@mui/material"
import { css } from "@emotion/react"

import { getAuth, createUserWithEmailAndPassword } from "firebase/auth"
import { useAuthContext } from "../src/context/AuthContext"
import { app } from "../src/firebase"

const Signup = () => {
  const router = useRouter()
  const { user } = useAuthContext()
  const auth = getAuth(app)
  const isLoggedIn = !!user
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    await createUserWithEmailAndPassword(auth, email, password)
    router.push("/")
  }
  const handleClose = async () => {
    await router.push("/")
  }
  const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.currentTarget.value)
  }
  const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.currentTarget.value)
  }
  return (
    <div
      css={css`
        display: flex;
        justify-content: space-between;
        align-items: center;
        flex-flow: column;
      `}
    >
      <Snackbar
        open={isLoggedIn}
        anchorOrigin={{ vertical: "top", horizontal: "center" }}
        autoHideDuration={3000}
        key={"top" + "center"}
        onClose={handleClose}
      >
        <Alert onClose={handleClose} severity="warning">
          すでにログインしています
        </Alert>
      </Snackbar>
      <h2>ユーザー登録</h2>
      <form onSubmit={handleSubmit}>
        <div
          css={css`
            display: flex;
            justify-content: center;
            align-items: center;
          `}
        >
          <InputLabel>メールアドレス</InputLabel>
          <TextField
            name="email"
            type="email"
            size="small"
            onChange={handleChangeEmail}
            css={css`
              padding-left: 12px;
            `}
          />
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            align-items: center;
            margin-top: 16px;
          `}
        >
          <InputLabel>パスワード</InputLabel>
          <TextField
            name="password"
            type="password"
            size="small"
            onChange={handleChangePassword}
            css={css`
              padding-left: 12px;
            `}
          />
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            margin-top: 16px;
          `}
        >
          <Button type="submit" variant="outlined">
            登録
          </Button>
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            margin-top: 24px;
          `}
        >
          <Link href={"/login"}>
            <a>すでに登録している人はこちら</a>
          </Link>
        </div>
      </form>
    </div>
  )
}

export default Signup

下記のアクセスコントロールでやる内容も少し混ぜちゃっているので、SnackbarやuseAuthContext()など、上で説明していない内容はいったんスルーしていただけると幸いです。

また、完成形のUIはこちらになります。

なんてことないですね。次に行きましょう!

ログイン

ロジックはほぼ全くユーザー登録と同じです。
実質的にログイン処理を行うのは、ユーザー登録と同様一行だけです。
特筆することがないので、さっそくコード全文を載せてしまいます。

Login.tsx
Login.tsx
import React, { useState } from "react"
import { useRouter } from "next/router"
import Link from "next/link"
import { Alert, Button, InputLabel, Snackbar, TextField } from "@mui/material"
import { css } from "@emotion/react"

import { getAuth, signInWithEmailAndPassword } from "firebase/auth"
import { useAuthContext } from "../src/context/AuthContext"
import { app } from "../src/firebase"

const Login = () => {
  const { user } = useAuthContext()
  const isLoggedIn = !!user
  const router = useRouter()
  const auth = getAuth(app)
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    await signInWithEmailAndPassword(auth, email, password)
    router.push("/")
  }
  const handleChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.currentTarget.value)
  }
  const handleChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.currentTarget.value)
  }
  const handleClose = async () => {
    await router.push("/")
  }

  return (
    <div
      css={css`
        display: flex;
        justify-content: space-between;
        align-items: center;
        flex-flow: column;
      `}
    >
      <Snackbar
        open={isLoggedIn}
        anchorOrigin={{ vertical: "top", horizontal: "center" }}
        autoHideDuration={3000}
        key={"top" + "center"}
        onClose={handleClose}
      >
        <Alert onClose={handleClose} severity="warning">
          すでにログインしています
        </Alert>
      </Snackbar>
      <Snackbar
        open={!isLoggedIn}
        anchorOrigin={{ vertical: "top", horizontal: "center" }}
        autoHideDuration={3000}
        key={"top" + "center"}
      >
        <Alert severity="warning">ログインしてください</Alert>
      </Snackbar>
      <h2>ログイン</h2>
      <form onSubmit={handleSubmit}>
        <div
          css={css`
            display: flex;
            justify-content: center;
            align-items: center;
          `}
        >
          <InputLabel>メールアドレス</InputLabel>
          <TextField
            name="email"
            type="email"
            size="small"
            onChange={handleChangeEmail}
            css={css`
              padding-left: 12px;
            `}
          />
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            align-items: center;
            margin-top: 16px;
          `}
        >
          <InputLabel>パスワード</InputLabel>
          <TextField
            name="password"
            type="password"
            size="small"
            onChange={handleChangePassword}
            css={css`
              padding-left: 12px;
            `}
          />
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            margin-top: 16px;
          `}
        >
          <Button type="submit" variant="outlined">
            ログイン
          </Button>
        </div>
        <div
          css={css`
            display: flex;
            justify-content: flex-end;
            margin-top: 24px;
          `}
        >
          ユーザ登録は
          <Link href={"/signup"}>
            <a>こちら</a>
          </Link>
          から
        </div>
      </form>
    </div>
  )
}

export default Login

await auth.signInWithEmailAndPassword(email, password)の一行でログイン処理を行っています。メソッド名もわかりやすくていいですね。

こちらもユーザー登録同様、アクセスコントロールでやる内容が混ざっているので、内容はざっと見で問題ありません。

一応、完成形のUIはこちらになります。

ログアウト

以下のhandleLogputを、ログアウト用ボタンに仕込むだけです!

import { app } from "../firebase"
import { getAuth, signOut } from "firebase/auth"
import { useRouter } from "next/router"

const router = useRouter()
const auth = getAuth(app)
const handleLogout = async () => {
  await signOut(auth)
  await router.push("/about")
}

私の場合は、本記事では登場していない、ハンバーガーメニューの中のボタンに仕込みました。
とりあえずやることは上記の関数をonClickに入れるだけなので、こちらの全文・UIについては省略します。

それにしても、おおよその認証処理が一行で終わるの、ほんとに簡単ですごいですね。

アクセスコントロール

未ログイン時

未ログイン時、特定のページに入ろうとしたら、ログインページに強制遷移させた後に「ログインしていないからだめだよ〜」とアラートを出します。

特定と言っていますが、ほぼ全ページでこの処理は行いたいです。
なので、全てのpage componentに干渉できる、_app.tsxに何か追記しましょう。

追記をしたあとの_app.tsxのコード全文は、こんな感じです。

_app.tsx
import { css } from "@emotion/react"
import Header from "../src/components/header"
import type { AppProps } from "next/app"
import { AuthProvider } from "../src/context/AuthContext"

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <AuthProvider>
      <Header />
      <Component
        {...pageProps}
      />
    </AuthProvider>
  )
}

export default App

Headerはただのヘッダーで、ハンバーガーメニューの表示に入れているだけなのですが、AuthProviderが何奴という感じですね。

AuthProviderはざっくり、現在ログインしているかどうかを監視するためのコンポーネントです。
監視により、していたら〇〇、していなかったら××という処理をする、ということにつなげることができます。

ログインの監視は、onAuthStateChangedというメソッドにより実現可能です。
このメソッドは例えば以下のようにして使います。

onAuthStateChanged((user) => {
  if (user) {
    // ログイン時の処理
  } else {
    // 未ログイン時の処理
  }
});

ログインをしていた場合は、userにはユーザー情報が入っており、していなかったらnullが入っています。
この仕様を使って、ログイン時と未ログイン時の処理をそれぞれ実現できるというわけです。

このonAuthStateChangedを、AuthProviderの中で使います。

今回の要件は、「未ログイン時に特定のページに入ったら、ログインページに強制遷移させた後にアラートを出す」なので、どこかのページに入るたびに「ログインしているかどうか」「特定のページにいるかどうか」をチェックしたいです。

こういうのはuseEffectで実装しましょう。
というわけで、AuthContextは以下のように実装しています。

AuthContext.tsx
AuthContext.tsx
import { ReactNode, createContext, useState, useContext, useEffect } from "react"
import { getAuth, onAuthStateChanged } from "firebase/auth"
import type { User } from "firebase/auth"
import { useRouter } from "next/router"
import { app } from "../firebase"

export type UserType = User | null

export type AuthContextProps = {
  user: UserType
}

export type AuthProps = {
  children: ReactNode
}

const AuthContext = createContext<Partial<AuthContextProps>>({})

export const useAuthContext = () => {
  return useContext(AuthContext)
}

export const AuthProvider = ({ children }: AuthProps) => {
  const router = useRouter()
  const auth = getAuth(app)
  const [user, setUser] = useState<UserType>(null)
  const isAvailableForViewing =
    router.pathname === "/about" ||
    router.pathname === "/login" ||
    router.pathname === "/signup"
  const value = {
    user,
  }

  useEffect(() => {
    const authStateChanged = onAuthStateChanged(auth, async (user) => {
      setUser(user)
      !user && !isAvailableForViewing && (await router.push("/login"))
    })
    return () => {
      authStateChanged()
    }
  }, [])

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  )
}

!user && !isAvailableForViewing && (await router.push("/login"))のところで、「現在未ログインで、閲覧可能のページに入っていた場合は、ログインページに遷移させています。

また、「現在ユーザーがログインしているかどうか」という値は他のコンポーネントでも横断して使用したいので、useContextでそれを実現しています。

これで、「未ログイン時に特定のページに入ったら、ログインページに強制遷移させた後にアラートを出す」の半分はできました。

あとはログインページでアラートを出しましょう。

Login.tsx
const Login = () => {
  const { user } = useAuthContext()
  const isLoggedIn = !!user
 
 return (
   ...(中略)...
   <Snackbar
     open={!isLoggedIn}
     anchorOrigin={{ vertical: "top", horizontal: "center" }}
     autoHideDuration={3000}
     key={"top" + "center"}
   >
    <Alert severity="warning">ログインしてください</Alert>
   </Snackbar>
  ...(中略)...
  )
}

既に載っけていますが、必要な箇所だけ抜き出してLogin.tsxを再掲します。

useAuthContext().userが、一つ上でローカル全体でアクセスできるようになった、「ログインしていたらユーザー情報が入っているプロパティ」です。
それをisLoggedInとしてboolに変換したあと、falseであればログインしていないということなので、アラートとなるSnackbarを表示させています。

Material UIのAlertをSnackbarに入れているのは、Alertには表示させるか否かのpropsがないためです。公式をまねて書きました。

https://mui.com/components/snackbars/

これでなんやかんやと実装できました。
アラートのUIはこんな感じです。

ログイン時

ログインページ・新規登録ページに行ったら、「すでにログインしているよ〜」とアラートを出し、強制遷移させます。

こちらの処理も、既にLogin.tsx, Signup.tsx全文で載っけていますが、必要な箇所だけ再掲します。

Signup.tsx
const Login = () => {
  const { user } = useAuthContext()
  const isLoggedIn = !!user
  
  const handleClose = async () => {
    await router.push("/")
  }
 
 return (
   ...(中略)...
    <Snackbar
      open={isLoggedIn}
      anchorOrigin={{ vertical: "top", horizontal: "center" }}
      autoHideDuration={3000}
      key={"top" + "center"}
      onClose={handleClose}
    >
      <Alert onClose={handleClose} severity="warning">
        すでにログインしています
      </Alert>
    </Snackbar>
  ...(中略)...
  )
}

Signup.tsxの一部を載せていますが、Login.tsxにも同様のことが書かれています。

未ログイン時のアクセスコントロールでも触れた通り、useAuthContext().userが「ログインしていたらユーザー情報が入っているプロパティ」で、isLoggedInがそのboolです。

なので、ログインしている状態で新規登録ページ、ログインページに入ると、まずこのアラートが表示されます。
そして、onCloseでhandleCloseを発火させ、ルートのページに強制移動させるようにしています。

これにてアクセスコントロールも完了です!🙏

一応、ログイン時のアラートのUIも載せておきます。

所感

いろいろな開発者向けサービスを併せて新しいアプリケーションを作るとき、たまに「FirebaseはAuthだけ使用する」というケースが散見されます。
その理由が少しわかったような気がしました。Firebase Authentication、かんたんですげ〜

参考記事

https://reffect.co.jp/react/react-firebase-auth#React_Router

Discussion

sitogisitogi
import firebase from "firebase/compat/app"
import "firebase/compat/auth"
import "firebase/compat/firestore"
import "firebase/compat/storage"

compat は互換性のために残されている古いモジュールなので、今新しく作る場合は最新の v9 に対応した書き方にするのがよいと思います!

りんだりんだ

ご返信が遅れました🙇‍♂️
v8 -> v9の書き方に変更いたしました!

ご指摘いただき、ありがとうございました。

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