Got Some \W+ech?

Could be Japanese. Could be English. Android, セキュリティ, 機械学習などをメインに、たまにポエムったり雑感記載したりします。

Androidアドベンドカレンダー、Firebase Authenticationで電話認証する #Android #Authentication

Android アドベントカレンダー 20日目の記事です。

qiita.com

とある同人誌に掲載する予定だったのですが、風邪でぶっ倒れて書けなかった最終章をこちらに書きます。 内容は、Firebase Authenticationを使った電話認証のあれこれです。以降、「Firebase Authenticationの電話認証」を電話認証として略させて頂きます。 また、本稿ではFirebaseの仕組み上、みんなだいすき2要素認証ではなく2段階認証と呼称させていただきます。 詳しくはこちらをご覧ください。

blogs.manageengine.jp

本稿は次の内容でお送りします。 - ベーシックな電話認証 - 既存アカウントとのヒモ付け - 電話認証を使ったなんちゃって2段階認証 - SMS Retriever APIを使った半自動2段階認証

では、やってまいりましょう。

ベーシックな電話認証

いきなりで申し訳ないのですが、電話認証を実装したアプリはエミュレータ上では実行できません。いや、実行はできますが、電話認証周りの機能は動きません。クラッシュとかではなく単純に動きません。実機をご用意ください。

まずは電話番号をFirebaseに登録する必要があります。その場合、1) その電話番号が本当に実在するものか、2) 登録要求をリクエストしたユーザーのものであるか確認をとる必要があります。コードでお話すると、まず PhoneAuthProvider.getInstance().verifyPhoneNumber で電話番号の存在を確認し、そして、その後SMS経由で送られた確認コードをユーザーから受理・検証することで、登録要求リクエストの真正性を確認します。

1) はFirebaseの PhoneAuthProcider.getInstance().verifyPhoneNumber を使って出来ます。

   // 該当の電話番号に確認コードを送った結果を受け取るコールバック
        mCallbacks = object : PhoneAuthProvider.OnVerificationStateChangedCallbacks() {
            // 後でつかいます
            override fun onVerificationCompleted(p0: PhoneAuthCredential?) {
                Toast.makeText(this@EmailPasswordActivity, ": Verification Completed", Toast.LENGTH_SHORT).show()
            }

            // 後でつかいます
            override fun onVerificationFailed(p0: FirebaseException?) {
                Toast.makeText(this@EmailPasswordActivity, p0?.message, Toast.LENGTH_SHORT).show()
            }

            override fun onCodeSent(p0: String?, p1: PhoneAuthProvider.ForceResendingToken?) {
                mSMSVerificationID = p0.toString()
            }

            override fun onCodeAutoRetrievalTimeOut(p0: String?) {
                mSMSVerificationID = p0.toString()
            }
        }


    private fun registerPhoneNumber(phoneNumber: String) {
        PhoneAuthProvider.getInstance().verifyPhoneNumber(
                phoneNumber, // E.164フォーマットである必要があります。このフォーマットは[+][国コード][エリアコード]になります。例えば080-1234-5678が電話番号なら、E.164フォーマットで+818012345678です。
                10, // 10秒でタイムアウトする
                TimeUnit.SECONDS,
                this@EmailPasswordActivity,
                mCallbacks
        )
    }

2) は真正性確認になります。ユーザーが取得した確認コードとmCallback内で取得したSMS確認IDを組合せてたクレデンシャルがまずは作成されます。アプリはそれをサーバーに送信し、サーバーはそれを検証します。

val auth = FirebaseAuth.getInstance()
val credential = PhoneAuthProvider.getCredential(mSMSVerificationID, verificationCode)
auth.signInWithCredential(credential).addOnCompleteListener { task ->
    if (task.isSuccessful) {
        auth.currentUser?.let { updateResult(it) }
    } else {
        Log.w("Phone", "signInWithPhoneAuthCredential:failure", task.exception)
        Toast.makeText(this, "Authentication failed.", Toast.LENGTH_SHORT).show()
    }
}

タスクが成功した場合、その時点で電話認証をしたユーザーはFirebaseに登録されていることになります。次の画像をご参照ください。

f:id:kengoscal:20171217011643p:plain

とてもハンディですね。

既存アカウントとのヒモ付け

でも、これでは既存アカウントに紐付いてません。単純に、新しい別のアカウントが生成されただけです。既存アカウントに紐付かせるために、電話認証で登録したアカウントを削除したうえで紐付かせたいアカウントにリンクさせてみましょう。下記の様にログイン済みのユーザーにクレデンシャル(SMS確認IDとSMSに送られた確認コード)をリンクさせればおkです実装すればおkです。

val auth = FirebaseAuth.getInstance()
val smsCredential = PhoneAuthProvider.getCredential(mSMSVerificationID, findViewById<TextView>(R.id.mfa_code).text.toString())
auth.currentUser?.linkWithCredential(smsCredential)?.addOnCompleteListener { task -> // auth.currentUserはFirebase Authenticationですでにサインインしているホスト
     if (task.isSuccessful) {
          mAuth.currentUser?.let { updateResult(it) }
     } else {
          Toast.makeText(this, task.exception?.message, Toast.LENGTH_LONG).show()
     }
}

ひも付きました。

f:id:kengoscal:20171217015652p:plain

次は2段階認証の話になります。

電話認証を使ったなんちゃって2段階認証

上記の通り、複数の認証プロバイダ(メールアドレス、電話番号)を1つのアカウントに紐付けることができました。そこでメアドとSMSを使った2段階認証を設定してみたいと思います。 注意: 今のFirebaseの仕組み上、メアド・パスワードの認証が成功した時点でサインインした状態になってしまいます。従って、一度サインアウトして更に電話認証する流れになります。なので、本章はなんちゃってとなっています。

流れとしては、signInWithEmailAndPasswordでサインインしたユーザーの情報から電話番号を取得し、確認コードを送ります。これはFirebaseのContinuationインターフェースを使って実現できます。ContinuationインターフェースではTaskの結果を受取り、任意の処理を実行・出力する実装ができます。

// 認証結果を元に、電話番号へ確認コードを送る
class Send2FAVerificationCodeToSMS(
        private val executor: Executor,
        private val callback: PhoneAuthProvider.OnVerificationStateChangedCallbacks) : Continuation<AuthResult, Unit> {
     override fun then(task: Task<AuthResult>) {
         if (!task.isSuccessful || task.result.user.phoneNumber.isNullOrEmpty()) {
             return
         }
         return PhoneAuthProvider.getInstance().verifyPhoneNumber(
                task.result.user.phoneNumber.toString(), // Needs to be E.164 format. eg. +81805541xxxx(Japan), [+][country code][subscribed number with area code]
                10,
                TimeUnit.SECONDS,
                executor,
                callback
        )
    }
}

// 何となく別スレッドで処理したかった
class ThreadPerTaskExecutor : Executor {
    override fun execute(r: Runnable) {
        Thread(r).start()
    }
}

// 上記をsignInWithEmailAndPasswordのタスク終了後に実行する
mAuth.signInWithEmailAndPassword(email, password)                    
                .continueWith(Send2FAVerificationCodeToSMS(ThreadPerTaskExecutor(), mCallbacks))
                .addOnCompleteListener { task ->
                    signOut() //signInWithEmailAndPasswordが成功した時点で、サインイン済みになってしまっているのでサインアウトを実施
                    if (!task.isSuccessful) {
                        Toast.makeText(this, task.exception?.message, Toast.LENGTH_LONG).show()
                        return@addOnCompleteListener
                    } else {
                        Toast.makeText(this, "Verification Code has been sent", Toast.LENGTH_LONG).show() 
                    }
                }

手元の携帯電話に確認コードが送られてきました。前章で紹介したとおり、このコードと確認IDを組合せたクレデンシャルを引数にするsignInWithCredential を呼び出せば、メールアドレス認証と電話認証を組合せた2段階認証の出来上がりです。

SMS Retriever APIを使った半自動2SV認証

ところで皆さん、2SVするとき電話番号入力するのめんどくさくありませんか?次のような手順を見て頂ければ、半分くらいのかたには同意頂けるかと思います。

  1. 自分の電話番号を入力
  2. サーバからの認証コードを待つ
  3. SMSまたは電話アプリを立ち上げる
  4. 認証コードを入力

このステップ2からステップ4までを自動化してくれる仕組み、それがGoogleがだしているSMS Retriever APIです。 当該APIを使うことで、サーバからSMSを受け取ったAndroid端末(のGoogle Play Service)がブロードキャストし、特定のアプリがそれを受信・SMSをパースできるようになります。 パースさえできれば、その後処理でサーバに認証コードを送るだけなので、ユーザー体験を損なわない自動的な2段階認証を提供できるわけです。

以下、実装を軽くみていきます。 注意: なお、筆者はスマホ(SIM Free機)と電話を分けて運用しているので、本章は動作確認できていません。

SMS Retriever APIの呼び出し

SmsRetrieverClient インスタンスを生成し、startSmsRetriever()を呼びます 立ち上がったAPIもといタスクは認証コードを含んだSMSが届くのを待ちます。5分立つとタイムアウトしますが、待ち時間は変更できます。

class PhoneNumberVerifier : Service() {

    private var smsRetrieverClient: SmsRetrieverClient? = null

    override fun onCreate() {
        smsRetrieverClient = SmsRetriever.getClient(this)
    }

    // このサービスは `PhoneAuthProvider.getInstance().verifyPhoneNumber` の実行直後に呼び出される
    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
        super.onStartCommand(intent, flags, startId)

        val action = intent.action
        if (ACTION_VERIFY == action) {
            val task = smsRetrieverClient!!.startSmsRetriever()
            task.addOnCompleteListener {task ->
                if (task.isSuccessful) {
                     // 何か. smsRetriever開始成功のお知らせ
                } else {
                     // 何か. smsRetriever開始失敗
                }
            }
}

SMSから認証コードの取り出し

認証コードが端末に届いた際、Google Playサービスが、SmsRetriever.SMS_RETRIEVED_ACTIONを指定した明示的なブロードキャストを発信します。このブロードキャスト内に認証メッセージが届くわけです。従って、アプリ内ではBroadcastReceiverで本メッセージを抽出します。

class SmsBrReceiver : BroadcastReceiver() {

        override fun onReceive(context: Context, intent: Intent?) {
            if (intent == null) {
                return
            }

            if (SmsRetriever.SMS_RETRIEVED_ACTION == intent.action) {
                val extras = intent.extras
                val status = extras!!.get(SmsRetriever.EXTRA_STATUS) as Status
                when (status.statusCode) {
                    CommonStatusCodes.SUCCESS -> {
                        val smsMessage = extras.get(SmsRetriever.EXTRA_SMS_MESSAGE) as String
                        Log.d(TAG, "Retrieved sms code: " + smsMessage)
                    }
                    CommonStatusCodes.TIMEOUT -> doTimeout()
                    else -> {
                    }
                }
            }
        }
}

ここで取得したsmsMessage = 確認コードを、前述のsignInWithCredentialにいれてFirebaseに送信することで、半自動2SVが完了します。

//mSMSVerificationIDはPhoneAuthProvider.OnVerificationStateChangedCallbacks()のonCodeSentから取得
val credential = PhoneAuthProvider.getCredential(mSMSVerificationID, code) 
mAuth.signInWithCredential(credential)

以上です。

終わりに

以上で、Firebase Authenticationの電話認証周りの紹介をさせていただきました。認証システムは設定するには非常に多くの考慮が必要ですので、Firebase Authenticationを利用すれば、ベストではないにせよ2段階認証までハンディに実装できるようになります。