Firebase Authenticationでの多要素認証の新しい実装方法の紹介【2022年に追加された最新版！】

こんにちは。[virapture株式会社](https://virapture.com)でCEOしながら[ラグナロク株式会社](https://ragna-rock.com)でもCKOとして働いている[@mogmet](https://twitter.com/mogmet)です。
![mogmet.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/e526752f-63f5-ae0b-4c59-91756b7f7cf4.jpeg)
先日熱海の界アンジンに行って心をやすらぎにいってきました。三浦按針が旅した時代のロマンを感じることができ勉強になるホテルでした。

ところで、最近Firebaseでアップグレードがあったためそのうちの一つである**多要素認証**（**multi factor authentication**）（追加で電話番号で認証できるあれ。）について実際に使ってみたので実装方法を紹介します。

https://firebase.blog/posts/2022/07/new-firebase-auth-features

今回のサンプルはNuxt2で組んでみたので下記ソースを動かしてみていただけたら幸いです。

https://github.com/mogmet/firebase-mfa-nuxt2

## Firebase AuthenticationをIdentity Platformにアップグレードする

下記にアクセスしてアップグレードするプロジェクトを選択します。

https://console.firebase.google.com/u/0/project/_/authentication/upgrade

![スクリーンショット 2022-07-30 7.46.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/6b9ec96a-5a59-72c6-bc41-2a056430f082.png)


結構たくさん追加されますね。「次へ」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/783ac151-3eba-87ab-c60a-fe2ca1da5b05.png)

とうとうAuthenticationもお金かかるようになりました。
5万MAUを超えたら一人あたり0.3~0.7円くらいかかるようです。
「次へ」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/02c60feb-e623-21df-7be3-a4fad7d59714.png)

現在使ってるプロジェクトの場合はどれくらいの金額感になるかを見せてくれます。5万までなら無料。
「次へ」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/b5d8dfff-b61f-bd23-feca-d35b64e4ad2f.png)

アップグレードを完了します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/af7b825a-cb7e-2445-f3f1-8e7a312563be.png)

アップグレードを完了するとwith Identity Platformというのが追加されてるのがわかります。強そう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/8304712e-4397-4ae2-36d5-d8c64279565d.png)

## サインイン方法の追加

多要素認証を使うにはメールアドレスを認証している必要があるため、メールアドレスの認証をできるようにしておきます。
既にメールアドレスでのサインイン方法とメールリンクでの認証を設定されている方はスキップして大丈夫です。

sign-in methodからメール/パスワードのログイン方法を追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/194be2cf-a0f1-d2bb-dee5-fd21b8c5f2f2.png)

メールリンクを有効にした上で保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/087105df-171c-9472-4b6b-f2cd8a55719b.png)

無事有効化されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/64214fb3-2eb5-c9d7-544c-78691195b49a.png)

### 多要素認証の設定

Authenticationコンソール画面の、「Sign-in method」のしたにあるSMS多要素認証の「変更」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/a0cc8ff2-524d-026d-f4f8-7396f1a63b77.png)

「有効にする」にチェックを入れて「保存」ボタンを押下します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/47590d3f-afc9-0c6c-d17e-e1b64bdc4434.png)

テスト用の電話番号も追加できるのですが、これをやると電話番号認証処理を実施したときに

> FirebaseError: Firebase: Non-development mode Session Info given in development mode request. (auth/development-mode-mismatch).

というエラーがでて先にすすめなくなります。なので登録しないようにしておきましょう！

ちなみにまだ解決はしてなさそう。

https://github.com/firebase/firebase-admin-node/issues/405

無事有効になりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/e655d0a9-f404-66e5-4915-c529c0951370.png)


## multi factor authenticationの実装

[Apple](https://firebase.google.com/docs/auth/ios/multi-factor)、[Android](https://firebase.google.com/docs/auth/android/multi-factor)、[Web](https://firebase.google.com/docs/auth/web/multi-factor)での実装方法が現在展開されてます。

今回はwebの実装方法でNuxt2で実装してみます。
ちなみにviewはすっとばしてロジックだけ紹介するので、気になる方はgithubを参照してください。

https://github.com/mogmet/firebase-mfa-nuxt2


### アカウント登録ページの実装

メールアドレスとパスワードで登録できる画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/d768ec44-a597-3aa0-a7dd-fe829cf827c3.png)

```signup.vue
<script lang="ts">
import Vue from 'vue'
import { createUserWithEmailAndPassword } from 'firebase/auth'

export default Vue.extend({
  name: 'PageSignup',
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    async onClickSignup(): Promise<void> {
      try {
        await createUserWithEmailAndPassword(this.$auth, this.email, this.password)
        await this.$router.push('/email/send')
      } catch (e) {
        alert(e)
      }
    }
  }
})
</script>

```

https://github.com/mogmet/firebase-mfa-nuxt2/blob/main/pages/signup.vue

#### アカウント登録ページの解説

特にコメントはないです。普通の実装です。
登録後はメール認証のページに飛ばしてます

### メール認証送信ページの実装

このページを読み込むと登録したメールアドレスにメール認証のメールを送ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/8bbc9cdb-63c7-2885-8117-573bc9202e1d.png)


```email/send.vue
<script lang="ts">
import Vue from 'vue'
import {sendEmailVerification} from 'firebase/auth'

export default Vue.extend({
  name: 'PageSend',
  fetch() {
    this.verifyEmail()
  },
  methods: {
    async onClick(): Promise<void> {
      await this.verifyEmail()
    },
    async verifyEmail(): Promise<void> {
      try {
        if (!this.$auth.currentUser) {
          await this.$router.push('/login')
          return
        }
        await sendEmailVerification(this.$auth.currentUser, {
          url: 'http://localhost:3000/mfa/verify',
          handleCodeInApp: true,
        })
      } catch (e) {
        alert(e)
      }
    }
  }
})
</script>
```

https://github.com/mogmet/firebase-mfa-nuxt2/blob/main/pages/email/send.vue

#### メール認証送信ページの解説
urlの戻り先をsendEmailVerificationのurlで指定することで次の遷移先を指定することができます。

```ts
await sendEmailVerification(this.$auth.currentUser, {
  url: 'http://localhost:3000/mfa/verify',
  handleCodeInApp: true,
})
```

処理がうまくいくと下記のようなメールが届きます。
![スクリーンショット 2022-08-14 12.46.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/49b2f9c7-84b8-8f67-b9a5-124c13c08e9a.png)

実際にURLに遷移するとこんな感じのページに遷移します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/c498ade7-a21c-989f-bfab-b4858f1e8550.png)

CONTINUEを押すことで`sendEmailVerification`で指定したrulに遷移します。

### 認証コードの設定ページの実装

いよいよ本命の多要素認証の設定として電話番号での認証を追加するページの実装を紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/2d2f585d-3e3f-72f9-5aa5-cddf455b2f63.png)

```mfa/verify.vue
<script lang="ts">
import Vue from 'vue'
import { RecaptchaVerifier, multiFactor, PhoneAuthProvider, PhoneMultiFactorGenerator, PhoneInfoOptions } from 'firebase/auth'
import { isFirebaseError } from '~/plugins/firebase'

// mountしたあとに生成する必要があるのと、失敗時は再生成するのでletで定義
let recaptchaVerifier: RecaptchaVerifier | null = null
export default Vue.extend({
  name: 'PageMfaVerify',
  data() {
    const recaptchaId = -1
    return {
      phoneNumber: '',
      verificationCode: '',
      recaptchaId,
      verificationId: ''
    }
  },
  async mounted() {
    await this.setRecaptcha()
  },
  methods: {
    // recaptchaを描画
    async setRecaptcha(): Promise<void> {
      recaptchaVerifier = new RecaptchaVerifier("recaptcha-container", { // divとかでHTML内でrecaptchaを描画するHTMLのidを指定する
        size: "invisible"
      }, this.$auth)
      this.recaptchaId = await recaptchaVerifier.render()
    },
    // 設定した電話番号に認証コードを送る
    async onClickSendNumber(): Promise<void> {
      try {
        if (this.recaptchaId === -1 || !recaptchaVerifier) {
          await this.setRecaptcha()
          alert('Please resend')
          return
        }
        const auth = this.$auth
        if (!auth || !auth.currentUser) {
          await this.$router.push('/login')
          return
        }
        const multiFactorSession = await multiFactor(auth.currentUser).getSession()
        const phoneInfoOptions: PhoneInfoOptions = {
          phoneNumber: this.phoneNumber,
          session: multiFactorSession
        };
        this.verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier)
      } catch (error) {
        // しばらくログインしてない場合はエラーになるのでログインを再度求める
        if (isFirebaseError(error) && error.code === 'auth/requires-recent-login') {
          await this.$router.push('/login')
          return
        }
        this.setRecaptcha().then() // エラーになったらrecaptchaを再生成
        alert(error)
      }
    },
    // 認証コード検証
    async onClickVerify(): Promise<void> {
      const auth = this.$auth
      if (!auth || !auth.currentUser) {
        await this.$router.push('/login')
        return
      }
      try {
        const cred = PhoneAuthProvider.credential(this.verificationId, this.verificationCode)
        const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred)
        await multiFactor(auth.currentUser).enroll(multiFactorAssertion)
        await this.$router.push('/')
      }catch (e) {
        console.error(e)
        alert(e)
      }
    }
  }
})
</script>
```

https://github.com/mogmet/firebase-mfa-nuxt2/blob/main/pages/mfa/verify.vue

#### 認証コードの設定ページの解説

電話番号を入力して[SEND CODE]を押すと`PhoneAuthProvider`の`verifyPhoneNumber`メソッドを使って対象の電話番号に認証番号を送ります。
```ts
const multiFactorSession = await multiFactor(auth.currentUser).getSession()
const phoneInfoOptions: PhoneInfoOptions = {
  phoneNumber: this.phoneNumber,
  session: multiFactorSession
};
this.verificationId = await new PhoneAuthProvider(auth).verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier)
```
`verificationId`は認証するときに使うので保管しておきましょう。

ちなみにこのとき入力する電話番号は `+81 9012345678` のように**国際電話識別番号と国番号が記載された形で入力する必要があります**。なので実際に使うときはUIはいい感じに実装してあげるといいです。

`verifyPhoneNumber`メソッドを実施すると認証コードが入力された電話におくられるので、その番号は`multiFactor`の`enroll`メソッドを使って承認を行います。

```ts
const cred = PhoneAuthProvider.credential(this.verificationId, this.verificationCode) // verificationCodeが電話にくる認証番号
const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred)
await multiFactor(auth.currentUser).enroll(multiFactorAssertion)
```

注意点として、電話番号に認証コードを送るときには必ず毎回recaptchaの処理が必要になります。
これは処理が失敗したときとかには同じ番号を使えないので毎回生成し直す必要がある点に要注意です。
```ts
recaptchaVerifier = new RecaptchaVerifier("recaptcha-container", {
  size: "invisible"
}, this.$auth)
this.recaptchaId = await recaptchaVerifier.render()
```

無事多要素認証が設定できると下記のようなメールが届きます。
![スクリーンショット 2022-08-14 13.00.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/f84b02eb-d7be-85f5-abbe-53251d86b59d.png)

結構トラップなのですが、指定されたURLにアクセスすると多要素認証が外れちゃうので、いい感じにユーザにはお知らせするようにしてください。

### 多要素認証を用いたログインページの実装

いよいよ大詰めの多要素認証を用いたログインの実装について紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/6fe3392f-5e88-c8d8-ef4f-52790ecdbb0a.png)


```vue
<script lang="ts">
import Vue from 'vue'
import {
  signInWithEmailAndPassword,
  PhoneAuthProvider,
  MultiFactorResolver,
  PhoneMultiFactorGenerator,
  RecaptchaVerifier,getMultiFactorResolver,MultiFactorError
} from 'firebase/auth'
import {FirebaseError} from "firebase/app";
import {isFirebaseError} from "~/plugins/firebase";

let recaptchaVerifier: RecaptchaVerifier | null = null
let resolver: MultiFactorResolver | null = null
export default Vue.extend({
  name: 'PageLogin',
  data() {
    const recaptchaId = -1
    return {
      email: '',
      password: '',
      verificationCode: '',
      recaptchaId,
      verificationId: '',
    }
  },
  mounted() {
    this.setRecaptcha()
  },
  methods: {
    // recaptchaを描画
    async setRecaptcha(): Promise<void> {
      recaptchaVerifier = new RecaptchaVerifier("recaptcha-container", {
        size: "invisible"
      }, this.$auth)
      this.recaptchaId = await recaptchaVerifier.render()
    },
    // 通常ログイン
    async onClickLogin(): Promise<void> {
      try {
        const result = await signInWithEmailAndPassword(this.$auth, this.email, this.password)
        if (!result.user.emailVerified) {
          await this.$router.push('/email/send')
          return
        }
        await this.$router.push('/')
      } catch (error) {
        if (!isFirebaseError(error) || error.code !== 'auth/multi-factor-auth-required') {
          alert(error)
          return
        }
        await this.verifyPhoneNumber(error)
      }
    },
    // ログイン時に他要素認証を求められたら多要素認証を実行する
    async verifyPhoneNumber(error: FirebaseError) {
      if (!this.$auth) {
        return
      }
      resolver = getMultiFactorResolver(this.$auth, error as MultiFactorError)
      if (!resolver) {
        alert(error)
        return
      }
      if (!recaptchaVerifier) {
        this.setRecaptcha().then()
        alert('Please resend')
        return
      }
      try {
        const selectedIndex = 0 // ホントはhintsを見て、多要素認証を選択させるように作るこむといい
        // Ask user which second factor to use.
        if (resolver.hints[selectedIndex].factorId !== PhoneMultiFactorGenerator.FACTOR_ID) {
          return
        }
        const phoneInfoOptions = {
          multiFactorHint: resolver.hints[selectedIndex],
          session: resolver.session
        };
        const auth = this.$auth
        const phoneAuthProvider = new PhoneAuthProvider(auth);
        // Send SMS verification code
        this.verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier)
      } catch (error) {
        alert(error)
      }
    },
    // 認証番号を検証して問題なければ正式にログインさせる
    async verifyLogin() {
      if (!resolver) {
        alert('再ログインしてください')
        return
      }
      try {
        // Ask user for the SMS verification code. Then:
        const cred = PhoneAuthProvider.credential(this.verificationId, this.verificationCode)
        const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred)
        // Complete sign-in.
        await resolver.resolveSignIn(multiFactorAssertion)
        await this.$router.push('/')
      } catch (error) {
        this.verificationId = ''
        alert(error)
      }
    }
  }
})
</script>
```

https://github.com/mogmet/firebase-mfa-nuxt2/blob/main/pages/login.vue

#### 多要素認証を用いたログインページの解説

実装の勘所としてはerror.codeに**auth/multi-factor-auth-required**が帰ってきたら多要素認証を実施します。

```ts
} catch (error) {
  if (!isFirebaseError(error) || error.code !== 'auth/multi-factor-auth-required') {
    alert(error)
    return
  }
  await this.verifyPhoneNumber(error)
}
```

そしてここからがトラップなのですが、[マニュアル](https://firebase.google.com/docs/auth/web/multi-factor#signing_users_in_with_a_second_factor)には下記のようにerror.resolverを使えとありますが。。。

```js
    .catch(function (error) {
        if (error.code == 'auth/multi-factor-auth-required') {
            const resolver = error.resolver;
```

こちら**存在しません！！！！！！！！！！！！！！！！**

じゃぁどうやるのかというと**getMultiFactorResolver**というメソッドがあるのでこちらにerrorを渡してあげることでresolverを取得することができます。
```ts
resolver = getMultiFactorResolver(this.$auth, error as MultiFactorError)
```
まじでトラップです。。。取得方法見つけた自分を褒めてやりたい・・・

無事resolverを取得できたら`phoneAuthProvider`の`verifyPhoneNumber`メソッドを用いて電話番号認証を行います。
```ts
const phoneAuthProvider = new PhoneAuthProvider(auth);
// Send SMS verification code
this.verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier)
```

その後、認証コードが電話番号に届くのでそのコードを用いて検証し問題なければ処理終了です。
```ts
// Ask user for the SMS verification code. Then:
const cred = PhoneAuthProvider.credential(this.verificationId, this.verificationCode)
const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred)
// Complete sign-in.
await resolver.resolveSignIn(multiFactorAssertion)
```

### 全ての要素を満たしていたら表示できるページの実装

最後におまけになりますが、メール登録もして、メール認証もして、多要素認証も設定できたユーザに表示するロジックについて紹介します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/4b4ff559-1b38-9934-0eff-8f111750f206.png)

```index.vue
<script lang="ts">
import Vue from 'vue'
import { createUserWithEmailAndPassword, onAuthStateChanged, User } from 'firebase/auth'

export default Vue.extend({
  name: 'PageIndex',
  mounted() {
    onAuthStateChanged(this.$auth, (user: User | null) => {
      if (!user) {
        this.$router.push('/login')
        return
      }
      if (!user.emailVerified) {
        this.$router.push('/email/confirm')
        return
      }
      // 多要素認証を設定してない場合は設定画面に飛ばす。
      const reloadUserInfo = (user as any).reloadUserInfo
      if (!reloadUserInfo) {
        // 登録直後なので値は入ってない。しょうがないので許容してあげる。
        return
      }
      if (!reloadUserInfo.mfaInfo || !reloadUserInfo.mfaInfo.length) {
        this.$router.push('/mfa/verify')
      }
    })
  },
  data () {
    return {
      email: '',
      password: ''
    }
  },
  methods: {
    async onClickSignup(): Promise<void> {
      try {
        await createUserWithEmailAndPassword(this.$auth, this.email, this.password)
        await this.$router.push('/email/send')
      } catch (e) {
        alert(e)
      }
    }
  }
})
</script>
```

https://github.com/mogmet/firebase-mfa-nuxt2/blob/main/pages/index.vue

#### 全ての要素を満たしていたら表示できるページの解説

onAuthStateChangedを呼び出すことによって今のセッションのユーザ情報を取得できます。
```ts
onAuthStateChanged(this.$auth, (user: User | null) => {
```

登録してない場合はuserがnullなのでログインページへ。

```ts
if (!user) {
  this.$router.push('/login')
  return
}
```

email認証を行ってない場合は`emailVerified`がfalseなのでメール認証ページへ

```ts
if (!user.emailVerified) {
  this.$router.push('/email/confirm')
  return
}
```

多要素認証を設定してない場合は多要素認証画面に飛ばします。
```ts
const reloadUserInfo = (user as any).reloadUserInfo
if (!reloadUserInfo) {
  // 登録直後なので値は入ってない。しょうがないので許容してあげる。
  return
}
if (!reloadUserInfo.mfaInfo || !reloadUserInfo.mfaInfo.length) {
  this.$router.push('/mfa/verify')
}
```

`reloadUserInfo`という情報は型定義されてないのですが、ログ出力したらでてきたので、その中にある情報を使って他要素認証されているか判定します。
ちなみに`reloadUserInfo`は多用書認証登録直後はnullとして入っているので初回処理だけ注意してください。

`reloadUserInfo`にある`mfaInfo`を用いて多要素認証されているかどうかを判定しています。
他要素認証されていればその数だけ`mfaInfo`が増えていくようです。
![スクリーンショット 2022-08-14 13.26.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42234/ab3d864f-aa97-e640-9176-ba3472f25950.png)

## まとめ

昔から多要素認証は頑張ればできるみたいだったのですが、一応公式でも多要素認証ができるようになりました！
少し実装難易度は高めですが、こちらのサンプルを参考に実装やってみていただけたら幸いです。

今回の実装内容の抜粋は下記PRにもまとめてあるので変更点のみ見ていただけたら幸いです。（ちなみにemail/confirmはゴミページなので無視してください）

https://github.com/mogmet/firebase-mfa-nuxt2/pull/1

最後に、[ワンナイト人狼オンライン](https://online.1nite-jinro.com/link/blog)というゲームを作ってます！よかったら遊んでね！

https://online.1nite-jinro.com/link/blog

他にも[Camecon](https://camecon.me/)や[Offcha](https://offcha.com/)、問い合わせ対応が簡単にできる[CSmart](https://csmart.jp/lp)といったサービスも作ってるのでよかったら使ってね！

https://camecon.me/

https://offcha.com/

https://csmart.jp/lp

また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。

https://virapture.com/

ラグナロクでもエンジニアやデザイナーのメンバーを募集しています！！楽しくぶち上げたい人はぜひお話ししましょう！！

https://ragna-rock.com