Google reCAPTCHAの実装方法【v2非表示 ver】【Laravel】

## Google reCAPTCHAを実装する
Bot攻撃による大量申し込みを受けてreCAPTCHAを導入することになったのでその手順を記します。  

reCAPTCHAの実装方法がよく分からない..という方は参考になるかもしれません。  

v2とv3がありますが、なるべくユーザーによる申し込みを弾かないように、かつ最低限のBot対策はできるようにしたかったので、
v2の非表示バージョンで実装をしました。  

v2のチェックボックスやv3の方法は今回扱っていません。

分かりやすくするためにPHPフレームワークのLaravelを例にとって説明していますが、他のフレームワークやいわゆるVanilla PHPでも同じように実装できるかと思います。  

環境はLAMPを想定しています。

## Google reCAPTCHA Invisibleの設定手順
### 管理画面での設定
まずはサイトごとのAPIキーを発行しないといけないので管理画面で設定していきます。  

Google reCAPTCHAのサイトへアクセス。
https://www.google.com/recaptcha/intro/v3.html

![Image201905211254.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245411/b0c3d15f-6670-c7b7-c87b-2082451baea4.png)

Admin consoleへ移動したら、新しくサイトを追加します、

![Image201905211256.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245411/ed1be1b6-d239-7974-b4da-8b2b9180496e.png)

「ラベル」は好きな名前で大丈夫です。  

「セキュリティ設定」はレベルが3段階あり、
2～3のレベルで設定してみたところ、ユーザーによる申し込みも画像認証で弾かれてしまうことがあったので、
「ユーザーの負担が最小」である1段階のレベルで設定をすることに決めました。

![grc2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/245411/864d8a14-ba66-86a5-15f4-204c05ff007b.png)

上記で確定すると、APIのサイトキー・シークレットキーが発行されます。  

次に、Laravel上で設定作業を行っていきます。

### JSタグの設置
まずAPIのJSファイルを読み込みます。
テンプレートファイルの`<head>`内に挿入。

```diff:resources/views/form/input.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>FooBar Form</title>
+       <script src="https://www.google.com/recaptcha/api.js" async defer></script>
    </head>
    <body>
        // contents

        <form action="entry/input/" method="post" id="form">
            // form inputs
        </form>
    </body>
</html>
```

### フロント側のタグ・スクリプト設定
#### configを設定
APIキーはバージョン管理しないほうがよいので`.env`で設定します。
`APP_RECAPTCHA_SITE_KEY`は先ほど管理画面で発行したサイトのキー、
`APP_RECAPTCHA_SECRET_KEY`は先ほど発行したシークレットキーです。

```diff:.env
+ APP_RECAPTCHA_SITE_KEY=your_site_key
+ APP_RECAPTCHA_SECRET_KEY=your_secret_key
```

設定したファイルを読み込みます。
APIのエンドポイントもここに設定しておきます。

```diff:config/app.php
    /*
    |--------------------------------------------------------------------------
    | reCAPTCHA Settings
    |--------------------------------------------------------------------------
    |*/
+    'recaptcha' => [
+        'api_url' => 'https://www.google.com/recaptcha/api/siteverify',
+        'site_key' => env('APP_RECAPTCHA_SITE_KEY'),
+        'secret_key' => env('APP_RECAPTCHA_SECRET_KEY'),
+    ],

];
```

#### reCAPTCHAレンダリング対象となるdivタグ設置
reCAPTCHAのレンダリングに必要となるので設定します。
`data-sitekey`属性にサイトキー、`data-callback`属性にsubmit時のコールバック関数が入ります。

```diff:resources/views/form/input.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>FooBar Form</title>
        <script src="https://www.google.com/recaptcha/api.js" async defer></script>
    </head>
    <body>
        // contents

        <form action="./entry/input" method="post" id="form">
+                <div class="g-recaptcha"
+                        data-sitekey="{{ config('app.recaptcha.site_key') }}"
+                        data-callback="onGrecaptchaSubmit"
+                        data-size="invisible"></div>
            // form inputs
        </form>
    </body>
</html>
```

#### reCAPTCHAを呼び出すためのAPI設定
コールバック関数をグローバルで定義しておきます。

また、自分の場合はJSによるバリデーションをかけていて、
reCAPTCHAのAPIを発火させるタイミングは「submitボタンが押されたとき」ではなく「バリデーションが通ったとき」にしたいので、  

reCAPTCHAにより提供されている`grecaptcha.execute()`関数を使用して、任意のタイミングでreCAPTCHAを実行させます。

```diff:resources/js/form-efo.js

+ window.onGrecaptchaSubmit = function(request) {
+    $('form').submit();
+ };

$(() => {
    // 省略・・・

    if (validated()) {
      // 省略・・・
-     $('form').submit();
+     grecaptcha.execute();
    }
    // ・・・
});
```

ここまででフロント側の設定は完了です。  

ローカル環境で見てみると右下にreCAPTCHAのアイコンが出ているかと思います。  

普通に人間が入力してsubmitするとそのまま進めますが、
自動入力などを行うと画像認証画面が出てきて引っかかるようになります。

### バックエンド側のreCAPTCHA認証実装
クライアント側だけですと不正に突破される可能性もあるので、バックエンド側でも認証のチェックを行います。  

簡単に言いますとクライアント側でreCAPTCHAのAPIが発火した時点でトークンが発行され、
フォームのsubmit時に`g-recaptcha-response`というキーでトークンがPOSTされるので、そのトークンをバックエンド側でチェックします。

#### ミドルウェア作成
いろいろ方法はありますが、今回はミドルウェアを利用します。
`curl`を使っていますが`Guzzle`などのライブラリを使用してもいいかと思います。  

発行したシークレットキー・クライアント側で発行されたトークン・IPアドレスをもとに、不正なsubmitでないかをチェックします。  

APIについて詳しく知りたい方は公式のドキュメント参照してみてください。
https://developers.google.com/recaptcha/docs/verify

```php:app/Http/Middleware/CheckRecaptcha.php
<?php

namespace App\Http\Middleware;

use Closure;

class CheckRecaptcha
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($request->input('g-recaptcha-response')){
            $secret_key = config('app.recaptcha.secret_key');
            $url = config('app.recaptcha.api_url');
            $token = $request->input('g-recaptcha-response');
    
            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => http_build_query([
                    'secret' => $secret_key,
                    'response' => $token,
                    'remoteip' => $request->server->get('REMOTE_ADDR'),
                ]),
            ]);
            $_response = curl_exec($ch);
            curl_close($ch);

            $response = json_decode($_response);

            if (isset($response->success) && $response->success) {
                return $next($request);
            }
            else {
                return redirect('error'); // エラーページへリダイレクト
            }
        }
        return redirect('error'); // エラーページへリダイレクト
    }
}
```

ここからはLaravelだけの話なので、他のフレームワークでしたら飛ばしてオッケーです。

Kernelにミドルウェアを登録し、

```diff:app/Http/Kernel.php
    /**
     * The application's route middleware.
     *
     * These middleware may be assigned to groups or used individually.
     *
     * @var array
     */
    protected $routeMiddleware = [
        // 省略・・・
+       'recaptcha' => \App\Http\Middleware\CheckRecaptcha::class,
    ];
```

申し込みフォームのルーティングにミドルウェアを割り当てます。

```diff:routes/web.php
- Route::post('/entry/input', [\App\Http\Controllers\EntryInputController::class, 'input'])->name('entry.input');
+ Route::post('/entry/input', [\App\Http\Controllers\EntryInputController::class, 'input'])->name('entry.input')
+     ->middleware('recaptcha');
```

これでreCAPTCHAの認証設定は完了です。

#### 補足：ログを出力する設定
バックエンドでAPI認証する際に、レスポンスの内容を出力したいということがあるかもしれません。

自分の場合は下記のようにログ出力の設定を行いました。
認証エラーが発生した場合ステータスコードと、エラー内容が分かるのでデバッグがやりやすくなるかと思います。

```diff:app/Http/Middleware/CheckRecaptcha.php
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        if ($request->input('g-recaptcha-response')){
            $secret_key = config('app.recaptcha.secret_key');
            $url = config('app.recaptcha.api_url');
            $token = $request->input('g-recaptcha-response');
    
            $ch = curl_init();
            curl_setopt_array($ch, [
                CURLOPT_URL => $url,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST => true,
                CURLOPT_POSTFIELDS => http_build_query([
                    'secret' => $secret_key,
                    'response' => $token,
                    'remoteip' => $request->server->get('REMOTE_ADDR'),
                ]),
            ]);
            $_response = curl_exec($ch);
+           $_error = curl_error($ch);
            curl_close($ch);

            $response = json_decode($_response);
+           Log::info(
+               $_response ? [
+                   'recaptcha-result' => $response->success ? $response->success : $response['error-codes']
+               ] : ['recaptcha-error' => $_error]
+           );
+          
            return is_null($response) ? false : $response->success;
        }
        return false;
    }
```

## Google reCAPTCHAの実装はLaravelだと簡単 !
今回はv2非表示のセキュリティレベル1で設定しましたが、Selenium製ロボットも弾いてくれますし、ユーザーが申し込みできないといったことも今のところはありません。  

比較的簡単な実装でBot対策できますので、是非やってみてください。

--- 
※参考ページ
https://developers.google.com/recaptcha/docs/invisible
日本語版が無かったので今回まとめてみました。