🐙

Firebaseで2要素認証を実装するときにChatGPTに嘘をつかれて困ったのでまとめる with reCAPTCHA

に公開

お疲れ様です、たぬきの教祖です。
「私はロボットではありません」

経緯

最近様々なWEBサービスに決済を導入しようと考えておりますが、決済を行うWEBサイトのセキュリティ要件がめちゃくちゃに高くなっており大変です。
弊社のあるプロジェクトでは決済にStripeを利用しようと考えており、「OWASP ZAP」での検査を導入したり、ログイン通知や2要素認証も導入しております。ユーザ管理にはFirebase Authentication(以下 Auth)を利用していますが、Authでの2要素認証にはreCAPTCHAの認証が必須になってます。また、Stripeの要件でもフィンガープリントを利用しろ、等言われており、reCAPTCHAはフィンガープリント的なこともしているみたいなので導入をしました。

一方、
昔(と言っても1年前くらい?)にChatGPTを使用した際には、結構嘘をつくし、情報が古かったりもしたので、AIの利用はGitHub Copilotくらいだったのですが、最近のChatGPTは非常に優秀になってきて、ネット検索もしてくれるので、最近有償版を契約して使ってます。で、意気揚々とreCAPTCHAについて聞いて、それっぽいコードが出てきたので試したのですが見事に動かず。ドツボにはまって2日ほど無駄にしました。
ああ、まだまだ嘘つくんだなお前、という感想です。

バージョン

"firebase": "11.6.0",

原因

「今回の件から私が得るべき教訓は、ChatGPTを見たら詐欺師と思えということだ。」

本題に入る前に先に反省をします。
まず、如何に進化したといってもAIはまだまだ嘘をつく、ちゃんと疑う必要がありました。
でも、ChatGPTがあまりにも自信たっぷりに「確実にこうです!」とかいうもんだから...
ChatGPTはよく「確実に~」っていうんですけど、これ言わせてるのは誰なんですか...
問題ある
そして、ちゃんと公式のドキュメントに当たるべきでした。
とはいえ、私も最初は公式のドキュメントを見に行きました、が、これが何故かすごくわかりにくい。Googleさんは結構そういうことがあります。勘弁してほしい。
2日たってようやく流石にChatGPTに問題あるな、と思い公式を見に行って、過ちに気付いた次第であります。

reCAPTCHA

reCAPTCHA とは

[AI による概要]
reCAPTCHAは、Googleが提供するウェブサイトやアプリをボットや不正行為から保護するためのセキュリティサービスです。具体的には、ユーザーが人間であるか否かを判定し、スパムや不正なアクセスをブロックします。reCAPTCHAは、さまざまなバージョン(v2, v3など)があり、それぞれ異なる方法でユーザーの行動を分析し、リスクを評価しています。

だそうです。詳しくどんな処理を行っているかはわかりませんので割愛します。

v2とv3は単純にバージョンアップ、というわけではないようで、全く別物なので使い分けます。

v2はよく見慣れていると思いますが「私はロボットではありません」にチェックをするあれです。ユーザが怪しい場合には「自転車を全て選択してください」みたいな画像のあれが出てきます。

v3はなんと、GUIを持ちません。完全に非表示で信頼度をスコアで評価します。多分フィンガープリント的なこともしてます。

さて、問題はv2の方がセキュリティ強度が高いことですね。今回はそこは本題ではないので流しますが、やはり操作を絡めたほうが精度は良いようです。

reCAPTCHAの利用方法

reCAPTCHAの利用方法は主に2つあります。私は2つとも実装しました。Stripeへのアピールとして「私はロボットではありません」ボタン(v2)を実装した利用者ページでは以下の「1」を、管理者ページへのログインの2要素認証に必要なreCAPTCHAには、UIが不要な(v3)を以下の「2」の方法で実装しました。

  1. 素直にreCAPTCHAを使用する方法です。GoogleのreCAPTCHAのページに行って、使用するドメインなんかを登録し、APIキーをもらったりします。後はフロントに実装、取得したデータをバックエンドから検証、そんな感じです。こちらはChatGPTに聞いた方法で問題ありませんでした。

  2. Firebaseから利用する方法です。ドメインの登録とか、バックエンドでの検証とか、そういった諸々をすっ飛ばせる便利な方法です。ただ、勿論Firebaseの諸々に依存します。今回嘘をつかれたのはこちらです。

問題

ChatGPTが教えてくれた実装

問題のある箇所だけ抽出しますが、以下は電話番号をアカウントに登録する際のコードです。名前でお分かりの通り、sendCodeでSMSコードを送信し、verifyCodeで入力されたSMSコードを検証(認証)します(一部簡略化)。

const sendCode = async (phoneNumber: string) => {
    const auth = getAuth();
    const user = auth.currentUser;

    verifier = new RecaptchaVerifier('recaptcha-container', { size: 'invisible' }, auth);
    await verifier.render();

    const provider = new PhoneAuthProvider(auth);
    verificationId.value = await provider.verifyPhoneNumber(phoneNumber, verifier);

    return true;
};

const verifyCode = async (code: string) => {
    const auth = getAuth();
    const user = auth.currentUser;

    const cred = PhoneAuthProvider.credential(verificationId.value, code);
    const assertion = PhoneMultiFactorGenerator.assertion(cred);
    await multiFactor(user).enroll(assertion, '携帯電話');

    return true;
};

一方、以下が恐らく動くコードです。

const sendCode = async (phoneNumber: string): Promise<boolean> => {
    const auth = getAuth();
    const user = auth.currentUser;

    verifier = new RecaptchaVerifier(auth, "recaptcha-container", {
      size: "invisible",
    });
    await verifier.render();

    const multiFactorSession = await multiFactor(user).getSession();
    const phoneInfoOptions = {
        phoneNumber: phoneNumber,
        session: multiFactorSession,
    };
    const provider = new PhoneAuthProvider(auth);
    verificationId = await provider.verifyPhoneNumber(phoneInfoOptions, verifier);

    return true;
};

const verifyCode = async (code: string): Promise<boolean> => {
    const auth = getAuth();
    const user = auth.currentUser;

    const cred = PhoneAuthProvider.credential(verificationId.value, code);
    const assertion = PhoneMultiFactorGenerator.assertion(cred);
    await multiFactor(user).enroll(assertion, '携帯電話');

    return true;
};

間違い探しですね、どこが違うでしょうか。

「verifyCode」関数は実質的に同じです。
「sendCode」関数において、まず「new RecaptchaVerifier」の引数の順序が異なります。これは恐らくバージョンの違いによるものです。
また、「provider.verifyPhoneNumber」の取る引数が異なります。ChatGPTの示した引数は「phoneNumber」、つまり電話番号ですが、ここにはセッションを含む「PhoneInfoOptions」を渡す必要があります。

なお、このコードは2要素認証の登録時のコードですが、登録後の認証時もほとんど同様です。そちらのコードはChatGPTは適切に提示しているので、正直何故ChatGPTがここで誤ったのか...結構謎です。

実装(検証)手順

ここで、一応実装手順を解説してみます。

  1. 電話番号の登録:2要素認証を用いる際、まず必要なのが電話番号の登録です。その際、実装には2つのパターンがあります。それは「2要素認証が絶対必要」な場合と「2要素認証を用いてもよい」場合です。今回実装したのは後者です。後者の場合、まず任意の方法でログインした後、2要素認証の登録画面に遷移し、そこで登録を行います。
  2. reCAPTCHA認証:SMSの送信前にreCAPTCHA認証が必要です。
  3. 初回のSMSコード送信:1で入力された電話番号にSMSを送信します。
  4. SMSコード確認:SMSコードを検証します。成功すると2要素認証の登録は完了です。
  5. ログイン時の判定:一般的な2要素認証では、例えばその端末で初めてログインする際などの特定の場合に2要素認証が必要になります。Firebaseの場合、一度電話番号を登録すると、以降はログイン時に必ずSMS認証が必要になります。ログイン時、2要素認証の登録されているユーザでは「auth/multi-factor-auth-required」というエラーが出ます。
  6. SMSコード送信:5のエラーを検知したら、SMSコードを送信します。ただし、この場合にもreCAPTCHAの認証が必要です。この時点でreCAPTCHAの認証を行ってもよいですが、ログイン前に行っていてもよいです。
  7. SMSコード検証:入力されたSMSを検証します。検証に成功すると、自動的にログイン状態になります。

注意点

  • 2要素認証を一度設定すると何らかの操作で解除しない限り、当該Firebaseプロジェクトにおけるログインでは必ず2要素認証が必要になります。
  • SMSコードはテスト環境(localhost)ではエラーになり送信できません。また恐らくHTTPSが必要です。
  • こういった検証を繰り返していると「auth/too-many-requests」エラーが出ます。ログイン試行やSMS送信のし過ぎです。環境を変える以外には待つ以外に解決策はありません。最大1時間程度待ちましょう。

Discussion