LaravelとFilamentで管理画面を開発している場合、ログインページを自動化された不正アクセスから保護したいと思うことがあるでしょう。
その方法の一つが、Cloudflare Turnstileです。従来のCAPTCHAのように、信号機やバス、自転車などを選択させることなく、ボット対策を実装できます。
FilamentにTurnstileを導入するために、専用のパッケージは必ずしも必要ありません。Laravel、Livewire、そしてCloudflareのSiteverify APIを使って、自分で実装することもできます。
今回は、Cloudflareのテストキーを使った準備から始め、Laravel側の検証サービスの作成、Filamentのログイン画面へのウィジェット追加、そして動作確認まで、順番に進めていきます。
それでは始めましょう!
まずは必要なものを準備しましょう
今回のチュートリアルでは、以下の環境を使用します。
- Laravel
- Filament 5
- Livewire(Filamentに含まれています)
- Cloudflare Turnstile
LaravelアプリケーションにFilamentがインストールされ、管理画面がすでに動作していることを前提とします。
Cloudflareには、開発やテスト用のキーが用意されています。実は、最初からCloudflareのアカウントを作成したり、実際のドメインを登録したりする必要はありません。
まずは、こちらのテストキーを使ってみましょう。
Cloudflare Turnstileのテストキー
Cloudflareは、さまざまな検証結果を再現できるダミーのSite KeyとSecret Keyを提供しています。
Site Key
| キー | 動作 |
|---|---|
1x00000000000000000000AA |
常に成功(表示型) |
2x00000000000000000000AB |
常に失敗(表示型) |
1x00000000000000000000BB |
常に成功(非表示型) |
2x00000000000000000000BB |
常に失敗(非表示型) |
3x00000000000000000000FF |
インタラクティブチャレンジを表示 |
Secret Key
| キー | 動作 |
|---|---|
1x0000000000000000000000000000000AA |
常に成功 |
2x0000000000000000000000000000000AA |
常に失敗 |
3x0000000000000000000000000000000AA |
使用済みトークンエラーを返す |
今回は、常に成功する組み合わせを使用します。
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
後ほど、失敗するケースをテストするときに値を変更します。
テストキーの詳細は、Cloudflareの公式ドキュメントでも確認できます。
まずはLaravelの設定から
Laravelプロジェクトの .env ファイルを開き、先ほどの2つの環境変数を追加してください。
次に、config/services.php を開きます。
外部サービスの設定をまとめて管理できるファイルなので、今回のTurnstileの設定もここに追加しましょう。
'turnstile' => [
'site_key' => env('TURNSTILE_SITE_KEY'),
'secret_key' => env('TURNSTILE_SECRET_KEY'),
],
これで初期設定は完了です。
アプリケーション内のどこからでも、次のようにキーを取得できます。
config('services.turnstile.site_key');
config('services.turnstile.secret_key');
なお、Secret KeyはLaravelのサーバー側だけで使用します。ブラウザに公開しないように注意してください。
ウィジェットを追加する前に、検証サービスを作りましょう
Turnstileの実装には、大きく分けて2つの処理があります。
1つ目はブラウザ側でトークンを生成する処理です。2つ目は、そのトークンをCloudflareに送信して正当性を確認する処理です。
まずは、後者から実装していきましょう。
次のファイルを作成してください。
app/Services/TurnstileVerifier.php
以下のコードを追加します。
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\ConnectionException;
class TurnstileVerifier
{
protected string $verifyUrl =
'https://challenges.cloudflare.com/turnstile/v0/siteverify';
public function verify(?string $token): bool
{
if (blank($token) || strlen($token) > 2048) {
return false;
}
$secretKey = config('services.turnstile.secret_key');
if (blank($secretKey)) {
return false;
}
try {
$response = Http::asForm()
->timeout(10)
->post($this->verifyUrl, [
'secret' => $secretKey,
'response' => $token,
'remoteip' => request()->ip(),
]);
return $response->successful()
&& $response->json('success') === true;
} catch (ConnectionException $exception) {
report($exception);
return false;
}
}
}
少しだけ処理の内容を確認しておきましょう。
verify() が呼び出されると、最初にトークンが存在するかどうかを確認します。また、Cloudflareのトークンは最大2,048文字なので、その長さもチェックしています。
次に、Laravelの設定からSecret Keyを取得し、CloudflareのSiteverify APIにPOSTリクエストを送信します。
Cloudflareからは success という値を含むJSONレスポンスが返ってきます。この値が true であれば検証成功、それ以外の場合は失敗として扱います。
タイムアウトと接続エラーの処理も追加しました。Cloudflareに接続できない場合、検証を行わずにログインを許可することは避けたいからです。
これで、Laravelのどこからでも呼び出せる検証サービスができました。
次はFilamentのログイン画面にTurnstileを追加しましょう
ここからはFilament側の実装です。
今回は、Filamentが標準で提供しているLoginクラスを拡張します。認証システム全体を作り直す必要はありません。
次のファイルを作成してください。
app/Filament/Pages/Auth/Login.php
以下のコードを追加します。
<?php
namespace App\Filament\Pages\Auth;
use App\Services\TurnstileVerifier;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Http\Responses\Auth\Contracts\LoginResponse;
use Filament\Schemas\Schema;
use Filament\Schemas\Components\View;
class Login extends BaseLogin
{
public ?string $turnstileToken = null;
public function form(Schema $schema): Schema
{
return $schema
->components([
$this->getEmailFormComponent(),
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
View::make(
'filament.schemas.components.turnstile'
),
]);
}
public function authenticate(): ?LoginResponse
{
$this->resetErrorBag('turnstileToken');
// 既存の多要素認証(MFA)の認証プロセス中に、CAPTCHAをスキップする。
$requiresTurnstile = blank(
$this->userUndertakingMultiFactorAuthentication
);
if ($requiresTurnstile) {
$verified = app(TurnstileVerifier::class)
->verify($this->turnstileToken);
if (! $verified) {
$this->addError(
'turnstileToken',
'Please complete the security verification.'
);
$this->turnstileToken = null;
$this->dispatch('turnstile-reset');
return null;
}
}
try {
return parent::authenticate();
} finally {
if ($requiresTurnstile) {
$this->turnstileToken = null;
$this->dispatch('turnstile-reset');
}
}
}
}
ここでは、いくつかの処理を追加しています。
まず、Filament標準のLoginクラスを継承しています。メールアドレスやパスワードの認証処理を自分で書き直す必要はありません。
次に、form() メソッドをオーバーライドし、ログインフォームに独自のBladeビューを追加しています。
そして、今回の重要な部分が authenticate() です。
Filament標準の認証処理を呼び出す前に、Turnstileのトークンを検証します。
検証に失敗した場合は、ログイン処理を中断してエラーメッセージを表示します。
成功した場合は、Filament本来の認証処理に進みます。
また、既存の多要素認証(MFA)のチャレンジ中は、Turnstileの再検証をスキップするようにしています。最初のログイン処理ですでに使用したトークンを、次の認証ステップで再利用することはできないためです。
認証を試みた後はトークンをクリアし、ウィジェットもリセットします。次のログイン試行では新しいトークンを取得する必要があるためです。
Turnstile用のBladeビューを作成しましょう
LoginクラスからBladeビューを呼び出すように設定しましたので、次はそのビューを作成します。
以下のディレクトリが存在しない場合は作成してください。
resources/views/filament/schemas/components/
その中に、次のファイルを作成します。
turnstile.blade.php
以下のコードを追加してください。
<div>
<div wire:ignore>
<div id="turnstile-container"></div>
</div>
<input
type="hidden"
id="turnstile-token"
wire:model="turnstileToken"
>
@error('turnstileToken')
<p class="text-sm text-danger-600 mt-2">
{{ $message }}
</p>
@enderror
</div>
@once
<script>
window.turnstileLoginWidget = null;
window.renderTurnstileLogin = function () {
const container = document.getElementById(
'turnstile-container'
);
if (!container || !window.turnstile) {
return;
}
if (window.turnstileLoginWidget !== null) {
return;
}
window.turnstileLoginWidget = window.turnstile.render(
container,
{
sitekey: @js(config('services.turnstile.site_key')),
callback: function (token) {
const input = document.getElementById(
'turnstile-token'
);
input.value = token;
input.dispatchEvent(
new Event('input', { bubbles: true })
);
},
'expired-callback': function () {
clearTurnstileToken();
},
'error-callback': function () {
clearTurnstileToken();
}
}
);
};
function clearTurnstileToken() {
const input = document.getElementById(
'turnstile-token'
);
if (input) {
input.value = '';
input.dispatchEvent(
new Event('input', { bubbles: true })
);
}
}
window.addEventListener('turnstile-reset', function () {
clearTurnstileToken();
if (
window.turnstile &&
window.turnstileLoginWidget !== null
) {
window.turnstile.reset(
window.turnstileLoginWidget
);
}
});
</script>
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
async
onload="window.renderTurnstileLogin()"
></script>
@endonce
こちらも、少し内容を見ておきましょう。
最初に気になるのは wire:ignore だと思います。
<div wire:ignore>
<div id="turnstile-container"></div>
</div>
Turnstileのウィジェット内部のHTML要素はCloudflare側のJavaScriptによって管理されます。そのため、LivewireによるDOM更新の影響を受けないようにしています。
次に、こちらのhidden inputです。
<input
type="hidden"
wire:model="turnstileToken"
>
Cloudflareが生成したトークンを、Livewireのプロパティに同期するために使用します。
トークンが生成されると、JavaScriptのコールバックでinputの値を更新し、inputイベントを発生させます。これによってLivewire側にも値が同期されます。
Turnstileのウィジェットを実際に表示しているのは、こちらの部分です。
window.turnstileLoginWidget = window.turnstile.render(
container,
{
sitekey: 'YOUR_SITE_KEY',
callback: function (token) {
// 生成されたトークンの処理
}
}
);
今回は自動描画ではなく、明示的なレンダリングを使用しています。いつウィジェットを初期化するか、いつリセットするかを制御しやすくするためです。
また、トークンの有効期限が切れた場合や、ウィジェットでエラーが発生した場合にも対応しています。
どちらの場合も、Livewire側のトークンをクリアします。
認証処理の後には、Loginクラスから turnstile-reset イベントを発生させています。JavaScript側でこのイベントを受け取り、ウィジェットをリセットします。
これで次の試行時には新しいトークンを取得できます。
最後に、FilamentにカスタムLoginクラスを登録します
必要なファイルはすべて作成できました。
あとは、Filamentに今回作成したLoginクラスを使用するように設定するだけです。
Panel Providerを開いてください。一般的な構成では、次のファイルになります。
app/Providers/Filament/AdminPanelProvider.php
既存のログイン設定を探します。
->login()
こちらを次のように変更してください。
->login(
\App\Filament\Pages\Auth\Login::class
)
例えば、以下のような設定になります。
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login(
\App\Filament\Pages\Auth\Login::class
);
}
既存のPanel設定をすべて置き換える必要はありません。ログインの登録部分だけ変更し、その他の設定はそのまま残してください。
これで実装は完了です!
実際に動作を確認してみましょう
ここまでできたら、実際に動作するか確認してみましょう。
まず、.env ファイルに常に成功するテストキーが設定されていることを確認します。
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
設定キャッシュをクリアします。
php artisan config:clear
それではFilamentのログイン画面を開いてみてください。
Turnstileのウィジェットが表示されるはずです。常に成功するテスト用Site Keyを使用しているため、実際のチャレンジを解くことなくダミートークンが生成されます。
有効なログイン情報を入力してログインしてみましょう。
正しく設定できていれば、Cloudflareによる検証が成功し、通常のFilament認証処理に進みます。
今度は、失敗するケースも試してみましょう。
検証が失敗する場合をテストする
.env のキーを次のように変更します。
TURNSTILE_SITE_KEY=2x00000000000000000000AB
TURNSTILE_SECRET_KEY=2x0000000000000000000000000000000AA
もう一度設定キャッシュをクリアします。
php artisan config:clear
ログイン画面を再読み込みして、もう一度ログインを試してください。
今度は検証が失敗し、先ほど設定したエラーメッセージが表示されるはずです。
使用済みトークンのレスポンスも確認する
Cloudflareがトークンを使用済みと判断した場合の動作も確認できます。
次のように設定してください。
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=3x0000000000000000000000000000000AA
ページを再読み込みして、ログインを試してみましょう。
今回は使用済みトークンのエラーが返され、検証が失敗するはずです。
ウィジェットがトークンを生成したという理由だけで、無条件にログインを許可していないことを確認できます。
ほかのテストキーも試してみてください。それぞれの組み合わせと期待されるレスポンスについては、Cloudflareの公式ドキュメントに詳しく記載されています。
本番環境で使用する前に確認しておきたいこと
基本的な実装はできましたが、本番環境にデプロイする前に、いくつか確認しておきたい点があります。
本番環境では実際のキーを使用する
Cloudflareのダッシュボードで発行したSite KeyとSecret Keyに変更してください。開発環境、ステージング環境、本番環境では、それぞれ別の認証情報を使用することをおすすめします。
サーバー側の検証を省略しない
ブラウザ上でウィジェットが正常に動作したからといって、トークンが有効であるとは限りません。必ずSiteverify APIを使ってサーバー側で検証してください。
HostnameとActionも確認する
Cloudflareのレスポンスには、HostnameやActionなどの追加情報も含まれています。同じ認証情報や検証サービスを複数のフォームで使用する場合は、期待するHostnameやActionと一致しているか確認すると、さらに安全性を高められます。
なお、Cloudflareのテスト用レスポンスにはテスト専用の情報が含まれるため、こうした追加チェックは実際のキーを使用して確認してください。
認証のレート制限も維持する
Turnstileは追加のセキュリティ対策です。Filamentが標準で提供している認証のレート制限を置き換えるものではありません。両方を併用しましょう。
Cloudflareに接続できない場合も考慮する
今回の実装では、Cloudflareに接続できない場合は検証失敗として扱います。検証を行えない状態でログインを許可してしまうと、Turnstileを導入した意味がなくなってしまうためです。
もう一つ覚えておきたいのは、Cloudflareのトークンは5分で有効期限が切れ、一度しか使用できないという点です。ログインを再試行するときには、新しいトークンを取得できるようにしておきましょう。
まとめ
Turnstileは、ログイン画面に組み込むだけなら、それほど複雑ではありません。ただ、フロントエンドでトークンを取得するだけでなく、サーバー側での検証やトークンの有効期限なども忘れずに扱う必要があります。
今回はFilamentの認証フローに合わせて実装してみました。既存のプロジェクトに合わせてカスタマイズしたい場合は、こうした方法も参考になると思います。
ちなみに、Filament向けのTurnstileパッケージも作っています。パッケージを使った実装に興味があれば、こちらの記事もどうぞ。
FilamentでCloudflare Turnstileを使う方法(パッケージ編)
実装についてのご意見や改善案などがあれば、ぜひコメントで教えてください。
最後まで読んでいただき、ありがとうございました!