23
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

こんにちは。virapture株式会社でCEOしながらラグナロク株式会社でもCKOとして働いている@mogmetです。
mogmet.jpg
先日熱海の界アンジンに行って心をやすらぎにいってきました。三浦按針が旅した時代のロマンを感じることができ勉強になるホテルでした。

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

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

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

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

スクリーンショット 2022-07-30 7.46.58.png

結構たくさん追加されますね。「次へ」ボタンを押下します。
image.png

とうとうAuthenticationもお金かかるようになりました。
5万MAUを超えたら一人あたり0.3~0.7円くらいかかるようです。
「次へ」ボタンを押下します。
image.png

現在使ってるプロジェクトの場合はどれくらいの金額感になるかを見せてくれます。5万までなら無料。
「次へ」ボタンを押下します。
image.png

アップグレードを完了します。
image.png

アップグレードを完了するとwith Identity Platformというのが追加されてるのがわかります。強そう。
image.png

サインイン方法の追加

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

sign-in methodからメール/パスワードのログイン方法を追加します。
image.png

メールリンクを有効にした上で保存します。
image.png

無事有効化されました。
image.png

多要素認証の設定

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

image.png

「有効にする」にチェックを入れて「保存」ボタンを押下します。
image.png

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

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

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

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

無事有効になりました。
image.png

multi factor authenticationの実装

AppleAndroidWebでの実装方法が現在展開されてます。

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

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

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

image.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>

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

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

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

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

image.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>

メール認証送信ページの解説

urlの戻り先をsendEmailVerificationのurlで指定することで次の遷移先を指定することができます。

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

処理がうまくいくと下記のようなメールが届きます。
スクリーンショット 2022-08-14 12.46.17.png

実際にURLに遷移するとこんな感じのページに遷移します。
image.png

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

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

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

image.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>

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

電話番号を入力して[SEND CODE]を押すとPhoneAuthProviderverifyPhoneNumberメソッドを使って対象の電話番号に認証番号を送ります。

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メソッドを実施すると認証コードが入力された電話におくられるので、その番号はmultiFactorenrollメソッドを使って承認を行います。

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

注意点として、電話番号に認証コードを送るときには必ず毎回recaptchaの処理が必要になります。
これは処理が失敗したときとかには同じ番号を使えないので毎回生成し直す必要がある点に要注意です。

recaptchaVerifier = new RecaptchaVerifier("recaptcha-container", {
  size: "invisible"
}, this.$auth)
this.recaptchaId = await recaptchaVerifier.render()

無事多要素認証が設定できると下記のようなメールが届きます。
スクリーンショット 2022-08-14 13.00.23.png

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

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

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

image.png

<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>

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

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

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

そしてここからがトラップなのですが、マニュアルには下記のようにerror.resolverを使えとありますが。。。

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

こちら存在しません!!!!!!!!!!!!!!!!

じゃぁどうやるのかというとgetMultiFactorResolverというメソッドがあるのでこちらにerrorを渡してあげることでresolverを取得することができます。

resolver = getMultiFactorResolver(this.$auth, error as MultiFactorError)

まじでトラップです。。。取得方法見つけた自分を褒めてやりたい・・・

無事resolverを取得できたらphoneAuthProviderverifyPhoneNumberメソッドを用いて電話番号認証を行います。

const phoneAuthProvider = new PhoneAuthProvider(auth);
// Send SMS verification code
this.verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier)

その後、認証コードが電話番号に届くのでそのコードを用いて検証し問題なければ処理終了です。

// 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

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>

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

onAuthStateChangedを呼び出すことによって今のセッションのユーザ情報を取得できます。

onAuthStateChanged(this.$auth, (user: User | null) => {

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

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

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

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')
}

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

reloadUserInfoにあるmfaInfoを用いて多要素認証されているかどうかを判定しています。
他要素認証されていればその数だけmfaInfoが増えていくようです。
スクリーンショット 2022-08-14 13.26.30.png

まとめ

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

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

最後に、ワンナイト人狼オンラインというゲームを作ってます!よかったら遊んでね!

他にもCameconOffcha、問い合わせ対応が簡単にできるCSmartといったサービスも作ってるのでよかったら使ってね!

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

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

23
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?