【Vue.js】【Firebase】メール/パスワード認証の要点・ハマった所

ふせんUIを使ったタスク管理とチャットを融合したチームのタスク管理コミュニケーションツール「<a href="https://qnqtree.com/about" target="_blank">QnQTree</a>」を開発しているあどにゃーです。
今回サービスにFirebaseを用いたメール・パスワード認証を組み込んだので、ハマった点についてまとめていきたいと思います。ハマりポイントがメインなので正常系通れば良いという初心者向けというよりは、security rule含めて最低限サービスで使えるレベルを実装する人向けです。
※Firebase v9記述です。（v8と関数名は変わりません）

# パスワード認証の想定されるケース
<b>・新規ユーザの初回登録（Sign up)</b>
<b>・登録済みユーザがSign inできず、パスワードをリセット</b>
<b>・登録済みユーザが通常のSing in</b>
<b>・Sing in済みユーザがパスワードを変更</b>

パスワード認証を実装する場合、大きくわけて上記4つのケースを網羅する必要があります。
それぞれについて説明していきます。

# 新規ユーザの初回登録（Sign up)

初回はFirebase Authenticationにユーザアカウントがないので、まずはメールとパスワードを入力してアカウントを作ることになります。
アカウントを作ってそのまま使えるようにしてしまうと、他人のメアドでSing upする「なりすまし」を防げません。
そのため、本人のメールアドレス確認を行うメール認証のステップを追加します。

パスワード認証でアカウントが作成された時のauth.currentUserの情報はメール認証前のverifiedEmail=falseの状態です。
verifiedEmail=falseのUserはセキュリティルールでcreateやupdateの機能をブロックして、メール認証完了したタイミングで機能を開放するNo4~8を実装しなければいけません。
このあたりを書いている記事を見かけませんでしたので書いて見ます:relaxed:

#### password認証で作られるauth.currentUser
email: xxx@xx.jp
verifiedEmail: <b>false</b>
provideData[0].providerId : password
provideId: firebase
#### google認証で作られるauth.currentUser
email: xxx@gmail.com
verifiedEmail: <b>true</b>
provideData[0].providerId : google.com
provideId: firebase

新規ユーザの初回登録の実装flowは下記になります。
★をつけているのは、他の記事に載っていないハマりポイントです。

### 実装フロー
<b>1. メールアドレス・パスワード入力</b>
<b>2. すでに登録されたメアドか確認（fetchSignInMethodsForEmail）</b>
<b>3. 新規ユーザーアカウント作成（createUserWithEmailAndPassword）</b>
<b>4. 本人確認メールの送信(sendEmailVerification)</b>
<b>5. 本人確認できるまではすべての機能をロック(firestore.rules)</b>　★
<b>6. 本人確認メールのリンクをクリックして本人確認(applyActionCode) </b>
<b>7. auth.currentUser.reload()で本人のauth.ccurentUserを更新 ★</b>
<b>8. auth.currentUser.getIdToken(true)でrulesのauth.token.email_verifiedを更新　★</b>

#### No1~4の実装 認証メールの送信まで
```Javascript
passwordLogin: async function (email, password) {
      // すでにSINGUP済かチェック
      try {
        const signInMethods = await fetchSignInMethodsForEmail(auth, email)
        // すでにサインアップ済ならTrue
        this.isCreatedUser = (signInMethods.length !== 0)
      } catch (error) {
        console.error(error)
        this.alertMsg = '情報の取得に失敗しました'
        return
      }
      // すでにSIGNUP済み
      if (this.isCreatedUser) {
        // 通常のSIGN INなので後述
      } else {
      // SINGUP前
        console.log('新規ユーザのSign UP')
        try {
          // サインアップします
          const userCredential = await createUserWithEmailAndPassword(auth, email, password)
          let user = userCredential.user
          user.displayName = email.substr(0, email.indexOf('@'))
          // 独自のsingUp関数でFirestoreにもuser情報を保存
          await signUp(user)
          // 認証確認用のメールを送信
          await sendEmailVerification(auth.currentUser)
          // 別ページに移動
          Router.push({ path: '/editprofile' })
        } catch (error) {
          console.log(error)
          this.alertMsg = 'アカウント作成に失敗しました'
        }
      }
    }

```

認証メールをクリックして戻ってきた後のNo5~8の実装は下記となります。
sendEmailVerificationで認証メールを飛ばすことができ、認証メールにはqueryにmode,oobCodeが含まれています。
modeはresetPassword, recoverEmail, verifyEmailのいずれかの値が入っており、ユーザがどの条件でURLから飛んできたかを示します。
oobCodeはリクエストの検証するためのワンタイムコードです。この検証コードを使ってメール認証します。

#### ハマりポイントは２箇所
<b> 1つ目：applyActionCode(auth, actionCode)でメール認証を適応しても、auth.currentUser.emailVerifiedはFalseのままであるという点です。auth.currentUser.reload()を行って、authの情報を更新する必要があります。</b>

<b>２つ目：firestore.rulesのセキュリティルールauth.token.email_verifiedはauth.currentUser.getIdToken(true)でIdTokenを更新してあげないとしないとFalseのままです。</b>

この２つに触れている記事がほとんどなく、実装をする上でかなりハマりました:cold_sweat:

#### No5~8の実装　　認証メールの適応

```Javascript
    async verifyEmail () {
      this.modalMsg = ''
      const currentUser = auth.currentUser
      this.isEmailVerified = currentUser.emailVerified
      // すでに認証済
      if (this.isEmailVerified) {
        this.modalMsg = '認証が完了しました'
        Router.push({ path: '/editprofile' })
      }
      // 認証前の場合は認証処理を行う
      const actionCode = this.$route.query.oobCode
      if (actionCode && !currentUser.emailVerified) {
        // actionCodeを用いてメール認証を行う
        try {
          await applyActionCode(auth, actionCode)
          // メール認証を行ってもauthをreloadしないとUser情報が更新されないのでreloadする
          await auth.currentUser.reload()
          // firestore.rules側も更新されていないのでgetIdTokenでrefleshする
          await auth.currentUser.getIdToken(true)
        } catch (error) {
          // 確認メールを一定時間クリックしないと無効化されてエラーになる
          this.modalMsg = 'メール認証に失敗しました'
          console.log(error)
        }
        try {
          const applyUser = auth.currentUser
          await getMyUserData(this.loginUserId)
          this.isEmailVerified = applyUser.emailVerified
          if (this.isEmailVerified) {
            this.modalMsg = '認証が完了しました'
            Router.push({ path: '/editprofile' })
          } else {
            this.modalMsg = 'メール認証が完了していません'
          }
        } catch (error) {
          this.modalMsg = 'メール認証に失敗しました'
          console.log(error)
        }
      }
    },
```

セキュリティルールは実装している機能によりますが、firestoreのcreateやupdateをrequest.auth.token.email_verified == trueでないと行えないように設定します。
#### security rules
```Javascript

 allow create: if request.auth.uid != null
                    && request.auth.token.firebase.sign_in_provider != 'anonymous'
                    && request.auth.token.email_verified == true

```
# 登録済みユーザがSign inできず、パスワードをリセット
実装flowは下記になります。こちらは特にハマるポイントはありませんでした。

### 実装フロー
<b>1. パスワードのリセット　sendPasswordResetEmail(auth, this.email)</b>
<b>2. 再発行メールのクリック</b>
<b>3. リセットコードの確認　verifyPasswordResetCode(auth, actionCode)</b>
<b>4. パスワードの再発行　confirmPasswordReset(auth, actionCode, this.password)</b>
<b>5. サインイン　signInWithEmailAndPassword(auth, this.email, this.password)</b>

```Javascript
async sendPasswordReset (email) {
      this.alertMsg = ''
      try {
        await sendPasswordResetEmail(auth, email)
        this.alertMsg = '再発行メールを送信しました。メールから再認証してください'
      } catch (error) {
        console.error(error)
        this.alertMsg = '発行に失敗しました'
      }
    },
    async confirmPasswordResetUser () {
      const actionCode = this.$route.query.oobCode
      if (actionCode && this.$route.query.mode === 'resetPassword') {
        try {
          await verifyPasswordResetCode(auth, actionCode)
          // 成功したので別画面を出す
          this.isResetPassword = true
        } catch (error) {
          // 確認メールを一定時間クリックしないと無効化されてエラーになる
          console.log(error)
        }
      }
    },
resetPasswordLogin: async function (email, password) {
      this.alertMsg = ''
      // すでにSINGUP済かチェック
      try {
        const signInMethods = await fetchSignInMethodsForEmail(auth, email)
        // すでにサインアップ済ならTrue
        this.isCreatedUser = (signInMethods.length !== 0)
      } catch (error) {
        console.error(error)
        this.alertMsg = '情報の取得に失敗しました'
        return
      }
      // SINGUP前なら新規ユーザなのでパスワード再発行処理ではない
      if (!this.isCreatedUser) {
        this.alertMsg = '新規登録を行ってください'
        this.isResetPassword = false
        return
      }

      // すでにSIGNUP済み && 新規パスワードがある　&& 再発行認証コードがある
      console.log('パスワード再発行処理')
      try {
        const actionCode = this.$route.query.oobCode
        await confirmPasswordReset(auth, actionCode, password)
      } catch (error) {
        console.error(error)
        this.alertMsg = '再発行メールの有効期限が切れています'
        this.isResetPassword = false
      }
      // パスワードのresetが完了したのでSING IN
      try {
        const userCredential = await signInWithEmailAndPassword(auth, email, password)
        const user = userCredential.user
        if (!user.emailVerified) {
          // メアド確認終わってない
          this.alertMsg = 'メール確認が完了していません'
          return
        } else {
          // メアド認証が完了している
          Router.push({ path: '/' })
          this.isResetPassword = false
        }
      } catch (error) {
        console.log(error)
        this.alertMsg = '予期しないエラーが発生しました'
        this.isResetPassword = false
      }
    },
```

# 登録済みユーザが通常のSing in
実装flowは下記になります。こちら実は新規登録のFlowに混ざっています。
もう一度コードを記載すると下記になります。

### 実装フロー
1. メールアドレス・パスワード入力
2. すでに登録されたメアドか確認（fetchSignInMethodsForEmail）
3. 新規ユーザーのSIgnIn (signInWithEmailAndPassword)

```Javascript
passwordLogin: async function (email, password) {
      // すでにSINGUP済かチェック
      try {
        const signInMethods = await fetchSignInMethodsForEmail(auth, email)
        // すでにサインアップ済ならTrue
        this.isCreatedUser = (signInMethods.length !== 0)
      } catch (error) {
        console.error(error)
        this.alertMsg = '情報の取得に失敗しました'
        return
      }
      // すでにSIGNUP済み
      if (this.isCreatedUser) {
        console.log('既存ユーザのSign IN')
        try {
          const userCredential = await signInWithEmailAndPassword(auth, email, password)
          const user = userCredential.user
          if (!user.emailVerified) {
            // メアド確認終わってない
            this.alertMsg = 'メール確認が完了していません'
            return
          } else {
            // メアド認証が完了している
            Router.push({ path: '/' })
          }
        } catch (error) {
          console.log(error)
          this.alertMsg = 'パスワードが間違っています'
        }
      } else {
      // SINGUP前
      }
    }

```

# Sing in済みユーザがパスワードを変更
実装フローは下記となります。こちらも★がハマりポイントです。
Sign inしてしばらく期間が経過しているようなアカウントでは、クレデンシャル情報が古くなっており、再認証しないとupdatePassword()を使うことができないケースがあるようです。
ですので、必ず再認証処理を入れてください。

### 実装フロー

1. クレデンシャルの取得　EmailAuthProvider.credential(email, oldPassword)　★
2. 再認証 reauthenticateWithCredential(user, authCredential)　★
3. パスワードの更新 updatePassword(user, newPassword)

```JavaScript
  async updateNewPassword () {
      this.alertPasswordMsg = ''
      if (this.newPassword !== this.confirmedPassword) {
        this.alertPasswordMsg = '確認passwordが一致しません'
        return
      }
      const currentUser = auth.currentUser
      const authCredential = EmailAuthProvider.credential(currentUser.email, this.oldPassword)
      // 認証情報の更新(ログインしっぱなしで最近ログインしていないと無効化されている)
      try {
        await reauthenticateWithCredential(currentUser, authCredential)
      } catch (error) {
        console.error(error)
        this.alertPasswordMsg = '現在のpasswordが間違っています'
        return
      }
      // パスワードの更新
      try {
        await updatePassword(currentUser, this.newPassword)
        this.alertPasswordMsg = '更新に成功しました。新しいパスワードをお使いいただけます'
        this.oldPassword = ''
        this.newPassword = ''
        this.confirmedPassword = ''
      } catch (error) {
        console.log(error)
        this.alertPasswordMsg = '更新に失敗しました'
      }
    },

```
# まとめ
下記チームのタスク管理サービスで実装してるので良かったらログインしてみてください。
非エンジニアの方だとSing upとSing inの違いがわからない人も多いので、
そのあたりを意識しないでLoginできるようにつくっています。
https://qnqtree.com/about