こんにちは。virapture株式会社でCEOしながらラグナロク株式会社でもCKOとして働いている@mogmetです。
先日熱海の界アンジンに行って心をやすらぎにいってきました。三浦按針が旅した時代のロマンを感じることができ勉強になるホテルでした。
ところで、最近Firebaseでアップグレードがあったためそのうちの一つである多要素認証(multi factor authentication)(追加で電話番号で認証できるあれ。)について実際に使ってみたので実装方法を紹介します。
今回のサンプルはNuxt2で組んでみたので下記ソースを動かしてみていただけたら幸いです。
Firebase AuthenticationをIdentity Platformにアップグレードする
下記にアクセスしてアップグレードするプロジェクトを選択します。
とうとうAuthenticationもお金かかるようになりました。
5万MAUを超えたら一人あたり0.3~0.7円くらいかかるようです。
「次へ」ボタンを押下します。
現在使ってるプロジェクトの場合はどれくらいの金額感になるかを見せてくれます。5万までなら無料。
「次へ」ボタンを押下します。
アップグレードを完了するとwith Identity Platformというのが追加されてるのがわかります。強そう。
サインイン方法の追加
多要素認証を使うにはメールアドレスを認証している必要があるため、メールアドレスの認証をできるようにしておきます。
既にメールアドレスでのサインイン方法とメールリンクでの認証を設定されている方はスキップして大丈夫です。
sign-in methodからメール/パスワードのログイン方法を追加します。
多要素認証の設定
Authenticationコンソール画面の、「Sign-in method」のしたにあるSMS多要素認証の「変更」ボタンを押下します。
「有効にする」にチェックを入れて「保存」ボタンを押下します。
テスト用の電話番号も追加できるのですが、これをやると電話番号認証処理を実施したときに
FirebaseError: Firebase: Non-development mode Session Info given in development mode request. (auth/development-mode-mismatch).
というエラーがでて先にすすめなくなります。なので登録しないようにしておきましょう!
ちなみにまだ解決はしてなさそう。
multi factor authenticationの実装
Apple、Android、Webでの実装方法が現在展開されてます。
今回はwebの実装方法でNuxt2で実装してみます。
ちなみにviewはすっとばしてロジックだけ紹介するので、気になる方はgithubを参照してください。
アカウント登録ページの実装
メールアドレスとパスワードで登録できる画面です。
<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>
アカウント登録ページの解説
特にコメントはないです。普通の実装です。
登録後はメール認証のページに飛ばしてます
メール認証送信ページの実装
このページを読み込むと登録したメールアドレスにメール認証のメールを送ります。
<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,
})
CONTINUEを押すことでsendEmailVerification
で指定したrulに遷移します。
認証コードの設定ページの実装
いよいよ本命の多要素認証の設定として電話番号での認証を追加するページの実装を紹介します。
<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]を押すとPhoneAuthProvider
のverifyPhoneNumber
メソッドを使って対象の電話番号に認証番号を送ります。
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
メソッドを使って承認を行います。
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()
結構トラップなのですが、指定されたURLにアクセスすると多要素認証が外れちゃうので、いい感じにユーザにはお知らせするようにしてください。
多要素認証を用いたログインページの実装
いよいよ大詰めの多要素認証を用いたログインの実装について紹介します。
<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を取得できたらphoneAuthProvider
のverifyPhoneNumber
メソッドを用いて電話番号認証を行います。
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)
全ての要素を満たしていたら表示できるページの実装
最後におまけになりますが、メール登録もして、メール認証もして、多要素認証も設定できたユーザに表示するロジックについて紹介します。
<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
が増えていくようです。
まとめ
昔から多要素認証は頑張ればできるみたいだったのですが、一応公式でも多要素認証ができるようになりました!
少し実装難易度は高めですが、こちらのサンプルを参考に実装やってみていただけたら幸いです。
今回の実装内容の抜粋は下記PRにもまとめてあるので変更点のみ見ていただけたら幸いです。(ちなみにemail/confirmはゴミページなので無視してください)
最後に、ワンナイト人狼オンラインというゲームを作ってます!よかったら遊んでね!
他にもCameconやOffcha、問い合わせ対応が簡単にできるCSmartといったサービスも作ってるのでよかったら使ってね!
また、チームビルディングや技術顧問、Firebaseの設計やアドバイスといったお話も受け付けてますので御用の方は弊社までお問い合わせください。
ラグナロクでもエンジニアやデザイナーのメンバーを募集しています!!楽しくぶち上げたい人はぜひお話ししましょう!!