typescriptプロジェクトにreCAPTCHA v2 & v3を導入

reCAPTCHAはスパムやBOTなどからサイトを保護するためのもので、v2とv3があります。今回は自分がtypescriptで作っているフロントエンドアプリ(Chrome拡張)をBOTアクセスから保護する目的でreCAPTCHAのv2とv3の両方を同じ画面に導入しました。

f:id:rinoguchi:20200801143807p:plain

v2とv3

  • v2はWEBサイトでみかけるやつで「この中から車が写っている画像を選んでください」みたいな感じで、ユーザ操作の結果でBOTアクセスなのかどうか(True/False)を返してくれます。

  • v3は、ユーザフリクション(本来の目的を妨げるような事象。)は一切なく、スコア(1が正常で、0.0に近づくほどBOTの可能性が高い)を返してくれます。

処理の流れ

  1. DBにアクセスする3つのアクション(Chrome拡張起動、保存ボタン、削除ボタン)の際にv3を使ってスコアを計算する
    • フロントエンド(Chrome拡張のポップアップページないのJS)でtokenを取得
    • サーバサイド(Cloud Function)でtokenを受け取りシークレットキーを使ってreCAPCHA APIを呼び出してスコアを取得
  2. v3スコアが閾値よりも小さい場合はBOTの可能性が高いと判断し、v2を使って画像選択画面を表示してユーザに画像を選択してもらう
  3. v2でBOTと判断されたら処理を中断する

reCAPTCHA v3の導入

キーの作成

まずはこちらからキーを作成します。サイトキーとシークレットキーの二つが作られます。
サイトキーはフロントエンドで、シークレットキーはバックエンドで使用します。

バックエンド実装

まずは、サーバサイド(Cloud Function)でtokenを受け取りシークレットキーを使ってreCAPCHA APIを呼び出してスコアを取得する処理を実装します。この処理は、フロントエンドでも実装可能なのですが、どうしてもシークレットキーが露出してしまうめ、バックエンドで実装せざるをえません。

自分が作っているフロントエンドアプリには、バックエンドサーバは存在しないので、Cloud Functions for Firebaseを利用することにしました。こちらをベースに作業を進めていきます。

Cloud Functions for Firebaseを使える状態にする

プロジェクト作成

こちらからFirebaseのプロジェクトを作成します

Firebaseのクライアントツールをインストール

npm install -g firebase-tools
firebase login # ブラウザが起動するので、対象googleアカウントを選択し、パーミッションを与える

プロジェクトのCloud Functions関係のソース一式を初期化

以下のコマンドで、firebase.jsonやfunctionsフォルダが作成されます。

cd {path_to_project_dir}
firebase init functions

firebase.jsonを実際のフォルダパス(defaultだとfunctions)に修正します。

{
  "functions": {
    "predeploy": "npm --prefix functions run build"
  }
}

必要なライブラリを開発用としてinstallします。

npm install firebase-functions --save-dev
npm install firebase-admin --save-dev
npm install axios --save-dev  # HTTPアクセスに利用

試しにhelloWorld関数をデプロイ

試しにhelloWorld関数をデプロイしてみます。
index.tsにhelloWorld関数がコメントアウトされた状態で出力されているので、コメントアウトを外します。

import * as functions from 'firebase-functions'

export const helloWorld = functions.https.onRequest((request, response) => {
  functions.logger.info('Hello logs!', { structuredData: true })
  response.send('Hello from Firebase!')
})

デプロイを実行します。

firebase deploy --only functions

typescriptがインストールされてない場合、sh: tsc: command not foundというエラーが発生しますが、以下で解決できます。

npm install -g typescript
vi ~/.bash_profile
> + export PATH=`npm bin -g`:$PATH
source ~/.bash_profile

さらに、無課金のSparkプランじゃCloud Functionsは使えないから、従量課金制のBlazeプランに変更してね、というエラーメッセージが出てデプロイできませんでした(涙)

Error: Cloud Functions deployment requires the pay-as-you-go (Blaze) billing plan. To upgrade your project, visit the following URL:

しょうがないので、ここからプロジェクトの概要ページを開き、使用量と請求額ページでプランをSpark=>Blazeに変更しました。

ここまでやって、もう一度firebase deploy --only functionsを実行すると無事にhelloWorld関数がFirebaesコンソール画面に関数が追加されました。

reCAPTCHAのsiteverify APIを呼び出す処理を書く

サンプルのhelloWorldは無事にデプロイできたので、次はreCAPTCHAのsiteverify APIを呼び出す処理を記載していきます。

まずは、コードに露出させたくないので環境構成変数としてシークレットキーを設定します。

# 設定
firebase functions:config:set recaptcha.v3.secret="xxxxxxxxxxxxxxxx"

# 確認
firebase functions:config:get

index.tsを以下のように書き換えます。普通にpostパラメータを{ secret = secret }のように記載すると、'error-codes': [ 'missing-input-response', 'missing-input-secret' ]というエラーが出てしまいました。パラメータはクエリストリングで渡す必要があるようです。

/* eslint-disable @typescript-eslint/no-explicit-any */
import * as functions from 'firebase-functions'
import axios, { AxiosResponse } from 'axios'

export const callSiteVerify3 = functions.https.onCall(async (data: any) => {
  const secret: string = functions.config().recaptcha.v3.secret // 環境構成変数からシークレットキーを取得
  const response: AxiosResponse<any> = await axios.post(
    `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${data.token}`,
    {}
  )
  return response.data
})

あとは、デプロイを実行するだけです。Firebaseのコンソール画面で関数が追加されていれば成功です。

firebase deploy --only functions

フロントエンド実装

次に、フロントエンド(Chrome拡張のポップアップページないのJS)でtokenを取得して、先ほどCloud Functionsで作った関数(API)を呼び出す処理を書きます。

ライブラリ追加

まずライブラリをインストールします。

npm install recaptcha-v3
npm install firebase

firebaseの初期化

次にfirebase.tsを作ってFirebaseを初期化します。 このコードは、Firebaseのコンソール画面のプロジェクト設定のセクションからそのままコピペできます。

import * as firebase from 'firebase'

const firebaseConfig: { [key: string]: string } = {
  apiKey: 'xxxxx',
  authDomain: 'xxxxx',
  databaseURL: 'xxxxx',
  projectId: 'xxxxx',
  storageBucket: 'xxxxx',
  messagingSenderId: 'xxxxx',
  appId: 'xxxxx',
}

firebase.initializeApp(firebaseConfig)

export default firebase

Cloud Functionsの関数呼び出し

あとは、tokenを取得して、Cloud Functionsで作ったAPIを呼び出すだけです。

import { load } from 'recaptcha-v3'
import firebase from './firebase'

// reCAPTCHA のsiteverify APIのレスポンスを格納するための入れ物
interface CallSiteVerifyResponse {
  success: boolean
  challenge_ts: string
  hostname: string
  score: number
  action: string
  'error-codes': []
}

const recaptcha = await load('xxxxxxxxxx') // 事前に作ったreCAPTCHA v3のサイトキー
const token = await recaptcha.execute('xxxxx') // 任意のアクション名
const func = firebase
  .app()
  .functions('asia-northeast1') // Functionsを実行するロケーション。デフォルトはus-central1
  .httpsCallable('callSiteVerifyV3')
const response: CallSiteVerifyResponse = await func({ token: token }).then(
  async (response): Promise<CallSiteVerifyResponse> => {
    return response.data as CallSiteVerifyResponse
  }
)

if (response.success === false) {
  throw new Error(`Recaptcha error: ${response['error-codes']}`)
}
console.log(`reCAPCHA v3 score: ${response.score}`)
// -> reCAPCHA v3 score: 0.9

無事スコアを取得することができました。

当初は取得したスコアが閾値(とりあえず0.5としました)以下の場合、BOTアクセスと判断してService is not avairable.としようかと考えていたのですが、ググってみるとスコアが原因不明に低下することがあるようなので、なんの救済措置もなくBOTアクセスと判断するとBOTではないサービス利用者を締め出してしまう可能性がありそうです。

それも怖いので、まずはreCAPTCHA v3で一定のスコアを下回ったら、reCAPTCHA v2で画像を選択してもらい、それでBOTアクセスかどうかを最終的に判断することにしました。

reCAPTCHA v2の導入

reCAPTCHA v2はてっきりサーバサイドの実装は不要なのかと思ってたのですが、v3と同様に必要でした。。基本的にはv3と同じような実装をしてあげればOKです。

キーの作成

v3と同様にこちらからキーを作成します。
reCAPTCHA v2 は3種類のパターンがあるようです。

  1. 「私はロボットではありません」チェックボックス
    • => チェックボックスをクリックさせてリクエストを検証し、ロボットの可能性がある場合と判断されたらチャレンジが実行される(=画像選択画面が表示される)
  2. 非表示 reCAPTCHA バッジ
    • => バックグラウンドでリクエストを検証し、ロボットの可能性があると判断されたらチャレンジが実行される(=画像選択画面が表示される)
  3. reCAPTCHA Android

今回は、v3でBOTの可能性が高い場合に画像選択させるのが目的なのですが、わざわざチェックボックスを表示させるよりは直接画像選択させた方がシンプルだと思い、「2. 非表示 reCAPTCHA バッジ」を利用することにしました。

設定を進めるとサイトキーとシークレットキーが表示されます。

また、v3で既にBOTの可能性が高いと判断された後なので、セキュリティの設定ではセキュリティ重視を選択することにしました。

バックエンドの実装

ここは使うシークレットキーが違うだけで、v3と全く同じです。

環境構成変数を設定して

firebase functions:config:set recaptcha.v3.secret="xxxxxxxxxxxxxxxx"

index.tsにv2用の関数を追加して

export const callSiteVerifyV2 = functions.https.onCall(async (data: any) => {
  const secret: string = functions.config().recaptcha.v2.secret // 環境構成変数からシークレットキーを取得
  const response: AxiosResponse<any> = await axios.post(
    `https://www.google.com/recaptcha/api/siteverify?secret=${secret}&response=${data.token}`,
    {}
  )
  return response.data
})

デプロイを実行するだけです。

firebase deploy --only functions

フロントエンドの実装

ここはWeb上に欲しい情報がほとんど見つからず、だいぶ苦労しました。

候補1: recaptcha-v2 => ボツ

まず、普通にv3と同じように recaptcha-v2 を利用すれば良いかと思ったのですが、このライブラリ5年前から更新されていません。
利用方法を見ると以下のような感じで、普通にシークレットキーを指定する必要があり、キーが露出してしまいます。怖いので利用をやめました。

var recaptcha = new Recaptcha(PUBLIC_KEY, PRIVATE_KEY);

候補2: grecaptcha => ボツ

次に、grecaptchaというライブラリを発見したのですが、こちらも利用方法を見ると以下のようにシークレットキーが露出するので、利用をやめました。

const client = new Grecaptcha('secret')

候補3: CDN利用 => 採用

結局ライブラリの利用をやめて、CDNを利用することにしました。

今回はv3でダメだったらv2をチャレンジするという形なので、明示的にreCAPTHCAウィジェットを表示する必要があるので、公式サイトではこちらの手順がベースになります。

CDNからスクリプト読み込み

まずは、htmlファイルでCDNからスクリプトを読み込みます。

公式サイトでは、以下のようにapi.jsを読み込み、recaptchaの準備が完了する(グローバル変数grecaptchaが生成される)のを待ってからonloadCallbackで本来やりたい処理を書くようなことを期待されているのですが、ここにvueやreactの初期化処理を書くのはだいぶ嫌な感じです。。

<div id="recaptcha-v2-container"></div>
<script type="text/javascript">
  var onloadCallback = function() {
    alert("grecaptcha is ready!"); // ここで本来やりたい処理を書く
  };
</script>
<script src="https://www.google.com/recaptcha/api.js?onload=onloadCallback&render=explicit" async defer></script>

なので、onloadコールバックを利用するのはやめて以下のようにしました。ちなみに、divタグはウィジェットレンダリング先です。

<div id="recaptcha-v2-container"></div>
<script src="https://www.google.com/recaptcha/api.js?render=explicit" defer></script>

型の解決

typescriptで実装しているので、そのままだとimport するとCannot Find Moduleエラーが発生してしまいますので、index.d.tsを作成して型定義をしておきます

declare module 'grecaptcha'

ちなみに、@types/grecaptchaというものがあって、これを利用する方が良さそうなのですが、定義内容は完璧なのにexportされてないのでそのままでは利用することができません。 試しに、npm install @types/grecaptcha --save-devして、node_modules/@types/grecaptcha/index.d.tsの最後の行に

export default grecaptcha

を追加してあげると普通にimportして利用できました。
とはいえ、node_modulesに手を入れるのも違うと思うし、exportされてないものをimportする方法も自分には分からないので、こちらは断念しました。

grecaptchaをバンドル対象外に

また、バンドル時にCDNからスクリプトを取り込むようwebpack.config.jsに除外設定を追加します。
これによりgrecaptchaモジュールに関しては、グローバル変数grecaptchaを利用してくれる形になります。

  externals: {
    grecaptcha: 'grecaptcha',
  }

firebaseの初期化

これは v3と同じ なので省略

Cloud Functionsの関数呼び出し

あとは、tokenを取得して、Cloud Functionsで作ったAPIを呼び出します。 ポイントがいくつかあるので、列挙しておきます。

  • grecaptcha.readyでgrecaptchaオブジェクトの準備が完了する(=グローバル変数のgrecapthcaができる)のを待ち合わせる
  • grecaptcha.renderでreRECAPTCHAウィジェットレンダリングして、widgetIdを取得する
    • 第一引数はコンテナで、ウィジェットレンダリングする対象のHTMLエレメントもしくはIDを指定する
    • 第二引数には、こちらのパラメータを指定する。今回はsiteKeysizecallbackをしている。
  • grecaptcha.execute()に取得したwidgetIdを渡して検証を行う
    • widgetIdを指定せずに実行することも可能。その場合画面ないの最初のウィジェットが利用される
    • 今回はv3とv2を同一の画面で同時に実行しているため、widgetIdを指定せずに実行するとv3のwidgetが利用されて、サイトキーとシークレットキーがマッチせずエラーとなった
import grecaptcha from 'grecaptcha'

// reCAPTCHA のsiteverify APIのレスポンスを格納するための入れ物 ※v3と共通
interface CallSiteVerifyResponse {
  success: boolean
  challenge_ts: string
  hostname: string
  score: number
  action: string
  'error-codes': []
}

grecaptcha.ready((): void => {
  const widgetId: number = grecaptcha.render('recaptcha-v2-container', {
    sitekey: 'xxxxx', // ここでサイトキーを指定する
    size: 'invisible', // チェックボックスは非表示
    callback: async (token: string) => {
      const func = firebase
        .app()
        .functions('asia-northeast1')
        .httpsCallable('callSiteVerifyV2') // v2用のCloud Functionsの関数

      const response: CallSiteVerifyResponse = await func({
        token: token,
      }).then(
        async (response): Promise<CallSiteVerifyResponse> => {
          return response.data as CallSiteVerifyResponse
        }
      )

      if (response.success) {
        console.log('V2 check succeeded.')
        return
      }

      throw new Error(`BOT access detected. response: ${JSON.stringify(response)}`)
    },
  })

  grecaptcha.execute(widgetId)
  // -> V2 check succeeded.
  // or 
  // -> Uncaught Error: BOT access detected. 
})

これで、無事にreCAPTCHA v2での検証を行うことができました。

さいごに

それなりに苦労しましたが、typescriptで作ったフロントエンドアプリにreCAPTCHA v2 と v3を両方同時に導入することができました。
特にv2の方はtypescriptのプロジェクトに導入するケースが全くWEB上でまったく見つからなかったので、この記事が誰かの助けになればいいなと思います。