PHPでGoogle reCAPTCHA Enterpriseを実装してみる

# はじめに
この記事は以下の人向けの資料となります。
- 新しくウェブサイトを構築している方
- Google reCAPTCHAのクラシック版を使っている方

クラシック版管理画面

https://www.google.com/recaptcha/about

クラシック版についての説明記事

https://qiita.com/s_satoh/items/a949f52fa89ac368aad5#recaptcha-classicrecaptcha-lite

より
>もともと「reCAPTCHA」と呼ばれていたサービスです。v2 / v3のバージョンで機能が違います。
昔から（2020年以前まで？）無料でreCAPTCHAを使っている場合は、たぶんこれです。

のようです。

最近自社サービスに「Google reCAPTCHA Enterprise版」を実装したのでそのやり方を書きます。

この記事で取り上げているのはv2版（難易度中）です。
v3版（難易度高）とはViewの実装が少し異なるかもしれません。
サーバー側のトークン検証に関してはv2とv3で違いはない認識です。

※当記事では、トークンの有効性のチェックのみでリスク検証については紹介をしておりません。

# 前提
Google Cloud Platformのお支払い方法登録を事前に完了させておく必要があります。

# 実装手順の紹介
## APIキーの設定編
### 手順１．Google Cloud Platformのコンソールを開く

![スクリーンショット 2024-12-05 9.31.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/c31c63e0-a157-caaf-9af0-c5418179ce3d.png)

### 手順２．検索窓から「APIとサービス」を選択

![スクリーンショット 2024-12-05 9.32.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/d42169cd-2377-b73f-0bda-76cf891204a9.png)

### 手順３．左メニューから「認証情報」を選択します

![スクリーンショット 2024-12-05 9.34.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/229706b8-798d-bb62-35c3-f7918ed566c8.png)


### 手順４．「認証方法を作成」を選択

![スクリーンショット 2024-12-05 9.39.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/99f69983-2015-72a2-d956-d52ac3f56aff.png)

※赤い四角の部分は自社サービスに関わる部分なのでマスキングしてあります。

### 手順５．「APIキー」を選択

![スクリーンショット 2024-12-05 10.36.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/81db8e6d-8471-86f3-ca34-cce887f90371.png)


### 手順６．APIキーの設定をして保存

■必須
- 名前
- APIの制限
    - reCAPTCHA Enterprise API

■任意
- アプリケーションの制限の設定

![スクリーンショット 2024-12-05 10.37.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/29c5957d-7c0c-5696-fb11-9bf2a7eb432a.png)


APIキーの設定編は以上で終了です。

続いて、Google reCAPTCHA Enterprise編に移ります。

## Goolge reCAPTCHA Enterpriseの設定編
### 手順１．検索窓から「reCAPTCHA」を選択

![スクリーンショット 2024-12-05 10.45.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/ef7de9f3-b82a-7fe6-e5f0-39dd3c6e808a.png)


### 手順２．「キーを作成」を選択

![スクリーンショット 2024-12-05 10.47.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/d5e6840b-5eb4-21c7-f4f3-02865caeb897.png)


### 手順３．reCAPTCHAの設定を保存する

■必須
- 表示名
- プラットフォームの種類を選択
- ドメインリスト
- キーのタイプ
    - チェックボックスによる本人確認を使用する
        - 難易度中 or 難易度高

![スクリーンショット 2024-12-05 10.49.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/8a99bb42-199f-5544-6fd4-0fe3c5551159.png)


Goolge reCAPTCHA Enterpriseの設定編は以上で終了です。

続いて、実装編に移ります。

## 実装編
### 手順１．システム開発を開く

![スクリーンショット 2024-12-05 10.56.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/1985fdf0-425a-1d89-61e2-e8723fe970e6.png)


### 手順２．「ウェブサイトに reCAPTCHA を追加する」の指示に従ってHTMLにスクリプトを埋め込む

■下記をhead部に記載します

※QUERY STRINGとして「hl」を指定することができます。
hl=jaとした場合、日本語
hl=enとした場合、英語

```php:サンプル（通常）
<script src="https://www.google.com/recaptcha/enterprise.js" async></script>
```

```php:サンプル（QUERY STRING付き）
<script src="https://www.google.com/recaptcha/enterprise.js?hl=ja" async></script>
```


■reCAPTCHAを表示させたいところに、以下を記載します

※サンプルとしては下記が記載してありますが、カスタマイズすることもできます

＜サンプル版＞
```php:サンプル
<div class="g-recaptcha" data-sitekey="YOUR SITE KEY" data-action="LOGIN"></div>
```

＜カスタマイズ版＞
```php:カスタマイズ
<div class="g-recaptcha" data-callback="callbackGoogleRecaptcha" data-expired-callback="callbackGoogleRecaptchaExpired" data-sitekey="YOUR SITE KEY"></div>

<input type="hidden" id="google_recaptcha_code" value="" />
```


### 手順３．callbackを定義（任意）

Google reCAPTCHAの認証が成功するまで送信するボタンを押させたくないので、callbackを定義しています。

```js:カスタマイズ
$(document).ready(function(){
    // TODO 送信するボタンを押せないように制御します
});

/**
 * Google reCAPTCHAでチェックがついた場合のコールバックです
 *
 * @param code
 */
function callbackGoogleRecaptcha(code) {
    if (code !== '') {
        // TODO トークンを取得できたら、送信するボタンを押せるようにします。
        
        // トークンをhiddenでおいておく。これはフラグでもいい。
        $('#google_recaptcha_code').val(code);
    }
}

/**
 * Google reCAPTCHAでタイムアウトした場合のコールバックです
 */
function callbackGoogleRecaptchaExpired() {
    // TODO 送信するボタンを押せないように制御します

    // トークンを削除する
    $('#google_recaptcha_code').val('');
}

/**
 * Google reCAPTCHAで次へボタンをクリックしたときの処理です
 * onClickに使用します
 */
function clickNextButton() {
    // トークンがない場合は送信をしない
    if ($('#google_recaptcha_code').val() === '') return;

    // 送信を発火させます
    form.submit();
}
```

上記JavaScriptによる制御のサンプル

■未認証時

![スクリーンショット 2024-12-05 17.51.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/44ed57f1-8485-0ad5-568d-efa1b8586345.png)

■認証完了後

![スクリーンショット 2024-12-05 17.51.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/9b148b55-8cbc-58ea-b78f-1c7707f29e14.png)

■時間経過後

![スクリーンショット 2024-12-05 17.52.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/ed6a4e1e-d528-1db6-bedd-4096a106be64.png)



### 手順４．サーバーで検証をする

![スクリーンショット 2024-12-05 11.20.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/877917/9952551c-e869-d826-540a-f661bfe26baf.png)

今回は「REST API」による検証となります。

■検証プログラム

```php:Google_recaptcha

class Google_recaptcha
{
    private const ENTERPRISE_BASE_URL = 'https://recaptchaenterprise.googleapis.com/v1/projects';

    public function __construct(private array $options)
    {
    }

    /**
     * @return string
     */
    private function get_enterprise_project_url(): string
    {
        return sprintf('%s/%s', self::ENTERPRISE_BASE_URL, $this->options['google_recaptcha_project_name']);
    }

    /**
     * 利用方法が共通な可能性があるので、メソッドとして切り出す
     *
     * @param string $request
     * @param string $method
     *
     * @return array
     */
    private function call_google_recaptcha_enterprise(string $request, string $method): array
    {
        $enterprise_project_url = $this->get_enterprise_project_url();
        $request_url = sprintf('%s/%s', $enterprise_project_url, $method);

        $query = [
            'key' => $this->options['google_recaptcha_api_key'],
        ];

        $url = sprintf('%s?%s', $request_url, http_build_query($query));

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $request);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
            'Content-Length: ' . strlen($request),
        ]);

        // 処理タイムアウト
        curl_setopt($ch, CURLOPT_TIMEOUT, 15);

        $output = curl_exec($ch);
        curl_close($ch);

        // レスポンスが返ってこないときは空配列とする
        if (empty($output)) {
            return [];
        }

        return json_decode($output, true);
    }

    /**
     * @param string $token
     *
     * @return array
     */
    public function assessments(string $token): array
    {
        $body = [
            'event' => [
                'token' => $token,
                'expectedAction' => '',
                'siteKey' => $this->options['google_recaptcha_site_key'],
            ]
        ];

        $jsonBody = json_encode($body);

        return $this->call_google_recaptcha_enterprise($jsonBody, 'assessments');
    }

    /**
     * @param string $token
     *
     * @return bool
     */
    public function validate(string $token): bool
    {
        $response = $this->assessments($token);

        // レスポンスが返ってこないときはエラー
        if (empty($response)) {
            return false;
        }

        // トークンの妥当性はこのプロパティで判断
        return $response['tokenProperties']['valid'] ?? false;

    }
}
```

検証するときは

```php
// フレームワークごとにリクエストの取得方法は変更する
$token = $_POST['g-recaptcha-response'] ?? '';

// Google_recaptchaをインスタンス化する
$googleRecaptcha = new Google_recaptcha([
    'google_recaptcha_api_key' => getenv('GOOGLE_RECAPTCHA_API_KEY'),
    'google_recaptcha_site_key' => getenv('GOOGLE_RECAPTCHA_SITE_KEY'),
    'google_recaptcha_project_name' => getenv('GOOGLE_RECAPTCHA_PROJECT_NAME'),
]);

// 成功:true 失敗:false
$result = googleRecaptcha->validate($token);
```

とします。

※何も手を加えていなければ、Google reCAPTCHAのトークンは「g-recaptcha-response」という名前でリクエストがされるかなと思われます。

### 手順５．Configを定義

envファイルを作成
```yml:.env
GOOGLE_RECAPTCHA_API_KEY=YOUR API KEY=fugafuga
GOOGLE_RECAPTCHA_SITE_KEY=YOUR SITE KEY=hogehoge
GOOGLE_RECAPTCHA_PROJECT_NAME=YOUR PROJECT_NAME=hogefuga
```

完成

# 2024.12.03現在返ってくるレスポンス
OK時のレスポンス
```json
{
  "name": "projects/xxxxxxxxxxxx/assessments/yyyyyyyyyyyyyy",
  "event": {
    "token": "aaaaaaaaaaaaaaaaaaaaaaaaaaa",
    "siteKey": "hogehoge",
    "userAgent": "",
    "userIpAddress": "",
    "expectedAction": "",
    "hashedAccountId": "",
    "express": false,
    "requestedUri": "",
    "wafTokenAssessment": false,
    "ja3": "",
    "headers": [],
    "firewallPolicyEvaluation": false,
    "fraudPrevention": "FRAUD_PREVENTION_UNSPECIFIED"
  },
  "riskAnalysis": {
    "score": 1,
    "reasons": [],
    "extendedVerdictReasons": [],
    "challenge": "CHALLENGE_UNSPECIFIED"
  },
  "tokenProperties": {
    "valid": true,
    "invalidReason": "INVALID_REASON_UNSPECIFIED",
    "hostname": "localhost",
    "androidPackageName": "",
    "iosBundleId": "",
    "action": "",
    "createTime": "2024-12-03T04:52:23.582Z"
  },
  "accountDefenderAssessment": {
    "labels": []
  }
}
```

NGレスポンス
```json
{
  "name": "projects/xxxxxxxxxxxx/assessments/yyyyyyyyyyyyyy",
  "event": {
    "token": "bbbbbbbbbbbbbbbbbbbbbbbbbbb",
    "siteKey": "hogehoge",
    "userAgent": "",
    "userIpAddress": "",
    "expectedAction": "",
    "hashedAccountId": "",
    "express": false,
    "requestedUri": "",
    "wafTokenAssessment": false,
    "ja3": "",
    "headers": [],
    "firewallPolicyEvaluation": false,
    "fraudPrevention": "FRAUD_PREVENTION_UNSPECIFIED"
  },
  "riskAnalysis": {
    "score": 0,
    "reasons": [],
    "extendedVerdictReasons": [],
    "challenge": "CHALLENGE_UNSPECIFIED"
  },
  "tokenProperties": {
    "valid": false,
    "invalidReason": "MALFORMED",
    "hostname": "",
    "androidPackageName": "",
    "iosBundleId": "",
    "action": "",
    "createTime": "1970-01-01T00:00:00Z"
  }
}
```

# さいごに

今回は、Goolge reCAPTCHA Enterpriseの実装をしてみました。

トークンの検証方法は
他にも
- C#
- Go
- Java
- Node.js
- PHP
- Python
- Ruby

の言語が紹介されているのですが、

こちらは以下の理由により個人的におすすめしません。

REST APIはライブラリに依存しないため容易に実装ができます。

■理由
- どのライブラリのバージョンを使った実装サンプルなのか不明
- ライブラリ依存関係の解決が大変

以上、参考にしてもらえればと思います。