blog.ryota-ka.me

reCAPTCHA Enterpriseをアプリケーションに導入する

HERP Hireには利用企業の求人ページや応募フォームを作成できる機能があり,スパムによる応募を防ぐためreCAPTCHAを導入している.HERPでは昨今,求人ページおよび応募フォームのリプレイスを進めているため,新たに開発されたコンポーネントにもreCAPTCHAを導入する必要性が生じた.それに際して,以前から利用していたreCAPTCHA v3ではなくreCAPTCHA Enterpriseを導入することになった.

reCAPTCHA Enterpriseの導入にあたっては非自明に感じられる箇所が多く,いくつか苦労した点があった.そのため,同様の実装を行う必要に迫られた開発者が同じ轍を踏まぬよう,大まかな手順を記しておくことにした.

前提として,今回の事例では,バックエンド(BFF)はNode.jsにより実行され,Amazon Web Services上にデプロイされている.

いくつかの選択#

バージョンの選択#

reCAPTCHAを導入する際には,従来の製品であるreCAPTCHA v3を利用するか,あるいは後発の製品であるreCAPTCHA Enterpriseを利用するかを最初に選ぶ必要がある.

後者はその名の通り,企業における利用を想定して開発されており,基本的なサポートや99.9%のSLAが含まれている.また,Google Cloud Platformの一製品として提供されているため,公式に提供されているライブラリが利用できる,TerraformGoogle Cloud Platform Providerを通じた管理を実現できる*1Cloud Audit Logsによる監査ログの保存・確認が行える,といった数々の利点がある.

以上を踏まえると,個人での開発でない限りは,素直にreCAPTCHA Enterpriseを選択するのがよいだろう.

セットアップ方法の選択#

バックエンドのデプロイ先や,Google Cloudに対する認証方法によって,異なるセットアップ方法を選択することになる.

バックエンドをGoogle Cloudにデプロイする場合は,Google Cloudのサービス アカウントがそのまま利用できるため,話は簡単である*2.他方,Google Cloud以外にデプロイする場合はその限りではない.

以下では簡単のため,API キーを利用したセットアップ方法に沿って解説を進める*3.実際の導入時には,セキュリティリスクを軽減するため,Workload Identity 連携を設定した上で,短い有効期限を持つトークンを都度発行する形を取るのが望ましい.

キータイプの選択#

Google Cloudのプロジェクトを作成したら,プロジェクト内にreCAPTCHA Enterpriseのサイトキーsite key を発行する.サイトキーは英数字からなる40字の文字列であり,例として6Lcm3XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX5mfXといった形式を持つ.

サイトキーの発行に際して,スコアベースのサイトキーscore-based site key)とチェックボックスのサイトキーcheckbox site key)のいずれかを選択する必要がある.ユーザのインタラクションを必要としない前者が推奨されているため,特別な理由がない限りはそちらを選ぶことになるだろう.

詳細な比較は以下のドキュメントに記載されている.

アプリケーションに設定する値#

アプリケーションは実行時に以下の値を知っている必要があるため,環境変数などを経由して参照できるようにしておく.以降,これらの値は,丸括弧内に示した変数によって参照できるものとする.

  • バックエンド
    • 作成したGoogle Cloudのプロジェクト ID(PROJECT_ID
    • サイトキー(SITE_KEY
    • API キー(API_KEY
  • フロントエンド
    • サイトキー(SITE_KEY

処理の流れ#

アプリケーションが行う処理の流れを以下のシーケンス図に示す.

sequenceDiagram participant フロントエンド participant バックエンド participant reCAPTCHA フロントエンド->>reCAPTCHA: トークンの発行リクエスト reCAPTCHA->>フロントエンド: トークン フロントエンド->>バックエンド: リクエスト バックエンド->>reCAPTCHA: 評価の作成 reCAPTCHA->>バックエンド: 評価 バックエンド->>バックエンド: 評価の解釈 バックエンド->>フロントエンド: レスポンス

まずフロントエンドは,reCAPTCHAにリクエストを送信してトークンを取得する.バックエンドにリクエストを送る際にこのトークンを同時に送信する.次にバックエンドは,受け取ったトークンをreCAPTCHAに送信することで評価assessment)を作成する.作成された評価を解釈することで,リクエストが正当なものであるか否かを判定する.

トークンの発行#

以下のヘルプページを参考に,必要な<script>要素をフロントエンドに埋め込む.

すると,windowオブジェクトにgrecaptchaというプロパティが定義される.TypeScriptを利用している場合は,@types/grecaptchaパケッジを導入すれば型情報が利用可能になる.

grecaptcha.enterprise.execute()関数を実行することでトークンが取得できる.この際に,ユーザが行った操作を表すアクション名action nameを指定する必要がある.ここでは例として,「応募フォームの送信」という意味合いでSUBMIT_FORMという値を指定している.

await new Promise<void>((resolve) => {
  grecaptcha.enterprise.ready(() => {
    resolve();
  });
});

const token: string = await grecaptcha.enterprise.execute(SITE_KEY, {
  action: 'SUBMIT_FORM',
});

// TODO トークンを含んだリクエストの送信

発行したトークンは,この後バックエンドが評価を作成するために必要になるので,バックエンドへのリクエストに含めて送信する.

評価の作成#

バックエンドは,受け取ったトークンをreCAPTCHA EnterpriseのAPIエンドポイントに送信し,評価を作成する.

Google CloudのAPIを利用するにあたっての認証にはgoogle-auth-libraryというライブラリを使うことになっている.Google Cloudは多様な認証方式をサポートしているため,適切なクライアントの初期化方法を選択する必要があるが,API キーを利用する場合は以下のように行うことができる.

import { auth } from 'google-auth-library';

const authClient = auth.fromAPIKey(API_KEY);

reCAPTCHA EnterpriseのAPIクライアントは,@google-cloud/recaptcha-enterpriseパケッジと,上記で作成したauthClientを利用して以下のように作成する.

import { v1 } from '@google-cloud/recaptcha-enterprise';

const client = new v1.RecaptchaEnterpriseServiceClient({ authClient });

APIクライアントの.createAssessment()メソッドを呼び出すことでリクエストを送信することができる.このメソッドの引数として渡すオブジェクトの型情報は,Google社内で管理されているProtocol Buffers 3のコードから生成されているようだが,驚くべきことに,レスポンスとして返却されるオブジェクトと同一の型が使われている.当然ながら,リクエストとレスポンスではそれぞれ異なるフィールドに値が入る.それにもかかわらず同じ型が流用されているため,型情報やそれを元にしたコード補完などを頼りにすることはできない.実際にどのプロパティを指定する必要があるかを知るためには,念入りにドキュメントを参照する必要がある.

const projectPath = client.projectPath(PROJECT_ID);

const [response] = await client.createAssessment({
  assessment: {
    event: {
      siteKey: SITE_KEY,
      token, // フロントエンドから受け取ったトークン
    },
  },
  parent: projectPath,
});

reCAPTCHAからレスポンスを受け取ったら,まずは送信したトークン自体が正当なものであったかを確認する.そのためにはresponse.tokenProperties.validプロパティを参照すればよい.Protocol Buffers 3ではメッセージ型のフィールドは基本的にすべてオプショナルであるため,TypeScriptを利用している場合は,オブジェクトのすべてのプロパティについて値がnullまたはundefinedではないことの検証を明示的に行う必要がある.

if (response.tokenProperties === null || response.tokenProperties === undefined) {
  throw new Error('.tokenProperties is either null or undefined');
}

if (response.tokenProperties.valid === null || response.tokenProperties.valid === undefined) {
  throw new Error('.tokenProperties.valid is either null or undefined');
}

if (!response.tokenProperties.valid) {
  logger.error('Failed to create a reCAPTCHA assessment due to an invalid token', {
    name: response.name,
    reason: response.tokenProperties.invalidReason,
  });
}

評価の解釈#

続いて,作成した評価を解釈interpret)する.

まずは,フロントエンドでトークンを発行する際に指定されたアクションが,元々バックエンドで想定していたものと一致しているかを確認する.もし一致しなかった場合は,攻撃者が不正なリクエストを送信してきた蓋然性が高いと判断できる.

const EXPECTED_ACTION = 'SUBMIT_FORM';

if (response.tokenProperties.action === null || response.tokenProperties.action === undefined) {
  throw new Error('.tokenProperties.action is either null or undefined');
}

if (response.tokenProperties.action !== EXPECTED_ACTION) {
  logger.info('Got mismatched action', {
    actual: response.tokenProperties.action,
    expected: EXPECTED_ACTION,
  });

  return false;
}

次に,評価のスコアを解釈する.評価には,0.0から1.0までの範囲を持つスコアが付けられている.

reCAPTCHA Enterprise には、0.0 から 1.0 までの範囲のスコアを持つ 11 のレベルがあります。スコア 1.0 は、インタラクションのリスクが低く、正当である可能性が非常に高いことを示します。一方で 0.0 は、インタラクションのリスクが高く、不正行為の可能性があることを示します。

あらかじめ閾値を定めておき,評価のスコアが閾値を上回った場合には処理を継続し,下回った場合には,疑わしいリクエストとみなして操作を拒否したり,追加での検証*4を行ったりといった実装をすることになるだろう.reCAPTCHA v3の場合と異なり,reCAPTCHA Enterpriseのドキュメントには,スコアの閾値としてどのような値を使うべきかは明示されていない.そのため,各開発者が各々の要件に合うように適切な閾値を設定する必要がある.

ところで,ドキュメントには,

11 のレベルのうち、デフォルトで使用できるスコアレベルは、0.1、0.3、0.7、0.9 の 4 つのみです。

との記載があり,スコアが0.1刻みで表されることが示唆されている.しかし,スコアは内部的にはProtocol Buffersの32ビット浮動小数点数で表現されており*5,またそれを元にレスポンスボディのJSONが生成されていると推察されるため,実際には0.9ではなく0.8999999761581421という値が返却される.このため,閾値として0.9を指定してはいけない.

const THRESHOLD = 0.85;

if (response.riskAnalysis === null || response.riskAnalysis === undefined) {
  throw new Error('.riskAnalysis is either null or undefined');
}

if (response.riskAnalysis.score === null || response.riskAnalysis.score === undefined) {
  throw new Error('.riskAnalysis.score is either null or undefined');
}

const { reasons, score } = response.riskAnalysis;

if (score < THRESHOLD) {
  logger.info('The assessment score is below the threshold', {
    reasons,
    score,
    threshold: THRESHOLD,
  });

  return false;
}

logger.info('The assessment score is above or equal to the threshold', {
  reasons,
  score,
  threshold: THRESHOLD,
});

return true;

おわりに#

本稿ではreCAPTCHA Enterpriseの導入手順を大まかに解説した.評価へのアノテーション付けなど,解説を割愛した部分もあるため,より詳細な情報は公式のドキュメントを参照されたい.

導入に際してはProtocol Buffersに起因する問題に遭遇することが多く,かなり難のある開発者体験を強いられたが,この記事が先達となり,読者による実装の一助となれば幸いである.

採用情報#

6年前に創業したHERPは本日をもって7期目を迎えた.HERPでは,この記事に書かれているような内容もフロントエンドの一環と捉え,精力的にアプリケーション開発を行ってくれるWebフロントエンドエンジニアを募集している.

脚注#

*1: Terraformを用いた管理を行う場合,プロジェクト(google_project)や,後述するサイトキー(google_recaptcha_enterprise_key)といったリソースを作成することになる.

*2: 特にGKEを利用している場合は,Workload Identityを使用することで,Kubernetesのservice accountに対して簡単かつ安全に権限を付与することができるだろう.

*3: Terraformを用いた管理を行う場合,対応するリソースはgoogle_apikeys_keyである.

*4: ドキュメントでは例として,アカウントへのログインに際して二要素認証を求めるといった例が挙げられている.

*5: 実装