Back

為 Laravel 整合 Google reCAPTCHA Enterprise

Google reCAPTCHA 是一個人類行為驗證機制,用於阻止爬蟲或類似的機器行為。

  • v1 (2007):基於驗證碼
  • v2 (2014):I’m not a robot 勾選框
  • v3 (2018):對當前用戶進行評分
  • Enterprise (2020):與 v3 類似,但加入更多功能(如密碼洩露檢測)

就目前為止,除了 Google 官方的 SDK 之外,幾乎找不到針對 reCAPTCHA enterprise 實作的 PHP 套件(大多都是 reCAPTCHA v2 及 v3)。

目標

實作

申請 reCAPTCHA Enterprise

註:如果完全沒有使用過 Google Cloud Platform,需要建立一個 Google 帳號並且建立一個 GCP Project 才能進行以下步驟

  1. 在 Google Cloud Platform 上選擇 安全性 > reCAPTCHA Enterprise
  2. 選擇「建立金鑰」
    • 名稱可自行決定
    • 如果是測試用,可選擇「停用網域驗證」及「這是測試金鑰」
  3. 複製「金鑰 ID」,這個就是後續步驟中的 sitekey

建立前端頁面

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<html>
<head>
    <script src="https://www.google.com/recaptcha/enterprise.js?render={SITE_KEY}"></script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script>
        grecaptcha.enterprise.ready(async function () {
            let token = await grecaptcha.enterprise.execute('{SITE_KEY}', {action: 'testpage'});
            axios.defaults.headers.common['g-recaptcha-token'] = token;
        });
    </script>
</head>
</html>

需特別注意以上範例中的 {SITE_KEY},請填入上個步驟提供的「金鑰 ID」。

這邊另外使用 axios 函式庫,將每一個請求都代入 g-recaptcha-token HTTP Header。

Laravel 實作

config 設計

config/services.php 中加入以下內容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
return [
    // ...

    'google' => [
        'projectId' => env('GOOGLE_CLOUD_PROJECT'),
        'credentials' => env('GOOGLE_APPLICATION_CREDENTIALS'),

        'recaptcha_enterprise' => [
            'enabled' => env('GOOGLE_CLOUD_RECAPTCHA_ENTERPRISE_ENABLED', true),
            'site_key' => env('GOOGLE_CLOUD_RECAPTCHA_ENTERPRISE_SITE_KEY'),
        ],

    ]
];

這時就可以利用 .env 或環境變數操作這些值,甚至還可以整合多種不同的 GCP 服務(例如 Google Cloud Storage)。

註:GOOGLE_APPLICATION_CREDENTIALS 的值是 GCP Service Account 的憑證(通常是一個 JSON 檔),可以是路徑也可以是檔案內容。

Service Provider

php artisan make:provider GoogleServiceProvider 建立一個 Laravel Service Provider

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<?php

namespace App\Providers;

use Illuminate\Support\Arr;
use Illuminate\Foundation\Application;
use Illuminate\Support\ServiceProvider;
use Google\Cloud\RecaptchaEnterprise\V1\Event;
use Google\Cloud\RecaptchaEnterprise\V1\RecaptchaEnterpriseServiceClient;

class GoogleServiceProvider extends ServiceProvider
{
    public function register()
    {
        if (config('services.google.recaptcha_enterprise.enabled')) {
            $this->registerRecaptchaEnterprise();
        }
    }

    public function boot()
    {
        //
    }

    protected function registerRecaptchaEnterprise()
    {
        $this->app->singleton(
            RecaptchaEnterpriseServiceClient::class,
            fn (Application $app) => new RecaptchaEnterpriseServiceClient(Arr::only($app['config']->get('services.google'), ['credentials', 'projectId']))
        );
        $this->app->singleton(
            Event::class,
            fn (Application $app) => (new Event())->setSiteKey($app['config']->get('services.google.recaptcha_enterprise.site_key')),
        );
    }
}

利用 $this->app->singleton() 註冊 RecaptchaEnterpriseServiceClientEvent,方便讓 Laravel 的 Dependency Injection 取用。

註:其實可以在這個 Service Provider 中註冊多個不同的 GCP 服務,只要用各種 Feature Toggle 就可以決定這些服務是否啟動。

建構 Validation Rule

為了驗證 reCAPTCHA token,採用 Laravel Validator 的 Custom Validation Rule

php artisan make:rule GoogleRecaptcha 建立 Custom Validation Rule:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace App\Rules;

use Illuminate\Contracts\Validation\Rule;
use Google\Cloud\RecaptchaEnterprise\V1\Event;
use Google\Cloud\RecaptchaEnterprise\V1\Assessment;
use Google\Cloud\RecaptchaEnterprise\V1\TokenProperties\InvalidReason;
use Google\Cloud\RecaptchaEnterprise\V1\RecaptchaEnterpriseServiceClient;

class GoogleRecaptcha implements Rule
{
    public string $errorMessage = '';
    protected RecaptchaEnterpriseServiceClient $client;
    protected string $project;

    /**
     * Create a new rule instance.
     *
     * @return void
     */
    public function __construct()
    {
        $this->client = app(RecaptchaEnterpriseServiceClient::class);
        $this->project = $this->client->projectName(config('services.google.projectId'));
    }

    /**
     * Determine if the validation rule passes.
     */
    public function passes($attribute, $token)
    {
        $result = $this->client->createAssessment(
            $this->project,
            new Assessment(['event' => app(Event::class)->setToken($token)]),
        );

        if (!$result->getTokenProperties()->getValid()) {
            $this->errorMessage = sprintf(
                'The :attribute is invalid, because of "%s".',
                InvalidReason::name($result->getTokenProperties()->getInvalidReason())
            );
            return false;
        }

        return true;
    }

    /**
     * Get the validation error message.
     *
     * @return string
     */
    public function message()
    {
        return $this->errorMessage;
    }
}

註:這段程式改寫自 Create Assessment

如此一來,便可以利用 Validator::make(['g-recaptcha-token' => 'foo-bar'], ['g-recaptcha-token' => 'required', 'string', new GoogleRecaptcha()]) 驗證用戶傳過來的 token 是否合法。

建立 Middleware

有別與大部份的套件,我希望可以在每一次的 API 請求中都驗證用戶是否合法(reCAPTCHA Enterprise 的免費額度是 100 萬次創建評估操作,應該根據自己的實際需求進行設計),於是採用 Laravel Middleware 驗證。

php artisan make:middleware VerifyGoogleRecaptcha 建立驗證用的 Middleware:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Rules\GoogleRecaptcha;
use Illuminate\Support\Facades\Validator;

class VerifyGoogleRecaptcha
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next)
    {
        $this->validateGoogleRecaptcha($request);

        return $next($request);
    }

    protected function validateGoogleRecaptcha(Request $request)
    {
        if (! config('services.google.recaptcha_enterprise.enabled')) {
            return;
        }

        Validator::validate($request->header(), [
            'g-recaptcha-token.0' => ['bail', 'required', 'string', new GoogleRecaptcha()],
        ]);
    }
}

撰寫測試

正如同我們所熟知的,自動化測試是現代軟體開發中必不可少的一環。

在這個案例中,應該被測試的標的有兩個:GoogleRecaptcha Validation Rule 及 VerifyGoogleRecaptcha Middleware。

另外,我們一般不希望自動化測試時會去存取 API,所以還需要 mock 這個 Google reCAPTCHA Enterprise SDK,得益於 Laravel 的測試,我們可以很輕鬆地做到這些事。

測試 Validation Rule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public function test_passes()
{
    $this->mock(RecaptchaEnterpriseServiceClient::class, function (MockInterface $m) {
        $assessment = Mockery::mock(Assessment::class);
        $assessment->shouldReceive('getTokenProperties->getValid')->andReturnTrue();

        $m->shouldReceive('projectName')
            ->with(config('services.google.projectId'))
            ->once()
            ->andReturn('projects/foo-bar');
        $m->shouldReceive('createAssessment')
            ->once()
            ->andReturn($assessment);
    });

    $this->assertTrue((new GoogleRecaptcha())->passes('g-recaptcha-token', 'foobar'));
}

public function test_failed_because_of_expired()
{
    $this->mock(RecaptchaEnterpriseSerivceClient::class, function (MockInterface $m) {
        $assessment = Mockery::mock(Assessment::class);
        $assessment->shouldReceive('getTokenProperties->getValid')->andReturnFalse();
        $assessment->shouldReceive('getTokenProperties->getInvalidReason')->andReturn(InvalidReason::EXPIRED);

        $m->shouldReceive('projectName')
            ->with(config('services.google.projectId'))
            ->once()
            ->andReturn('projects/foo-bar');
        $m->shouldReceive('createAssessment')
            ->once()
            ->andReturn($assessment);
    });

    $rule = new GoogleRecaptcha();

    $this->assertTrue($rule->passes('g-recaptcha-token', ''));
    $this->assertSame($rule->message(), 'The :attribute is invalid, because of "EXPIRED".');
}

測試 Middleware

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public function test_missing_recaptcha_header()
{
    $this->expectExcpetion(ValidatoinException::class);
    
    $middleware = new VerifyGoogleRecaptcha();
    $middleware->handle(
        Request::create('/'),
        fn() => null, // do nothing
    );
}

Laravel Middleware 其實只是一個單純的 Class,其中含有 handle(Request $request, Closure $next) 的 method,只要知道它的特性就能夠很容易地去測試。

結論

嚴格來說,在基本上使用上 reCAPTCHA v3 與 Enterprise 版的差異並不大,這篇筆記主要是記錄一些在實作上的思路與 Custom Validation Rule 及 Middleware 在 Unit Test 上的實作手段。

另一方面,以這個設計為基礎還能有以下的實作手段:

  • 在 Laravel 之前(如 Nginx)驗證 reCAPTCHA token,這可以降低 Laravel 的實作複雜度與處理效率
  • 抽樣分析,不是每一個請求都會建立評估,這可以有效降低 reCAPTCHA 的應用成本
    • 如何平衡抽樣數與風險
    • 如何建立合適的抽樣模型
  • 搭配 Cloudflare 或類似的 CDN 服務,在進入應用程式之前先過濾一輪
Built with Hugo
Theme Stack designed by Jimmy