CAPTCHA を二重化する — Turnstile + reCAPTCHA v3 のフォールバック設計

TL;DR

  • Cloudflare Turnstile を primary、Google reCAPTCHA v3 を fallback として 2 重に CAPTCHA を構える。
  • 切り替えは 2 段階: フロントで widget エラー時の自動切替 + サーバ verify 失敗時の retry_with_provider レスポンスによるフロント再試行。
  • バックエンドは CaptchaVerifier interface + CaptchaManager で provider 抽象化。Manager が 「サーバ側で暗黙に fallback」をやらない設計が肝。
  • HTTP 呼び出しは native curl で実装 (Guzzle ではなく)。共有レンタルサーバの古い libcurl で TLS 関連の罠を踏まないため。
  • テストは postForm()protected にして子クラスでスタブ。Laravel の DI で provider 切替まで含めて検証する。

なぜ Turnstile と reCAPTCHA を両方使うのか

Cloudflare Turnstile は ユーザ体験が良く、無料枠も大きい ので primary に採用したい。一方で、運用していると以下が地味に効いてくる:

  • Cloudflare 側の障害で widget が落ちる時間帯がある
  • 広告ブロックや特定ブラウザ拡張で script が読めないユーザがいる
  • 企業ネットワークが Cloudflare の challenge エンドポイントをブロックしているケースがある

これらが起きると、Turnstile だけのサービスは「サインアップできない」という致命的な体験を生む。fallback として Google reCAPTCHA v3 (目立たないスコア型) を組み合わせると、ほとんどの時間帯で Turnstile、そうでない時に v3 が出る、という運用にできる。

ただし 両方の SDK を同時にロードしない のが原則。grecaptchaturnstile のグローバルが両方乗ると、バンドルも script タグも騒がしくなる。「片方だけ動的にロードし、ダメなら切り替える」設計 が要る。

設計の全体像 — 2 段階のフォールバック

切り替えポイントは 2 つあり、それぞれ別の信号で発火する:

信号切替の主体
フロント自動切替Turnstile script load failure / error-callback 発火ブラウザ内の widget が switchToFallback() を呼ぶ
サーバ→フロント指示primary verify が失敗、fallback が設定されているサーバが 422 で retry_with_providerfallback_site_key を返す

サーバ側は 「primary が失敗したら自動で fallback で再 verify する」をやらない。理由:

  1. フロントが送ってきた token は primary 用のもの。fallback 用に流用しても通らない (provider が違う)。
  2. ユーザに新しい widget で再認証してもらう必要がある。サーバが暗黙に切替えても、結果は「ユーザがもう一度操作するべき」になる。
  3. 「次は fallback で来てね」とフロントに伝える方が、責務分担として綺麗。

つまりサーバの責務は「今届いた token がどっち用かを見て、その verifier で verify する」だけ。状態機械の主役はフロント。

CaptchaVerifier interface — 抽象化の最小単位

最初に切り出した interface はこれだけ:

interface CaptchaVerifier
{
    public function verify(
        ?string $token,
        ?string $remoteIp = null,
        ?string $action = null,
    ): CaptchaResult;

    public function name(): string;
}
  • verify() — token / IP / action を受け取って CaptchaResult を返す
  • name()'turnstile' / 'recaptcha_v3' のプロバイダ識別子

戻り値 CaptchaResult全プロバイダ共通の値オブジェクト:

final class CaptchaResult
{
    public function __construct(
        public readonly bool $success,
        public readonly array $errorCodes = [],
        public readonly ?float $score = null,
        public readonly ?string $action = null,
        public readonly ?string $hostname = null,
    ) {}

    public static function pass(?float $score = null, /* ... */): self
    public static function fail(array $errorCodes = ['unknown']): self
}

Turnstile は score が常に null、v3 では値が入る。プロバイダ固有のフィールドも共通の Result に同居させることで、Controller 側のコードがプロバイダごとに分岐せずに済む。

Turnstile / reCAPTCHA v3 の挙動の違い

API は似ているが、検証ロジックに大きな差 が 2 つある:

観点TurnstilereCAPTCHA v3
スコア無し (success のみ)あり (0.0〜1.0、低いほどボット)
action 検証値は返るが strict 比較なしstrict 比較 (mismatch なら fail)
失敗の理由error-codes 配列error-codes + 低スコア / mismatch

v3 側の verify は素朴に書くとこう:

$score = isset($data['score']) ? (float) $data['score'] : null;
$respAction = $data['action'] ?? null;

if ($score !== null && $score < $this->minScore) {
    return CaptchaResult::fail(['low-score']);
}
if ($action !== null && $respAction !== null && $action !== $respAction) {
    return CaptchaResult::fail(['action-mismatch']);
}

スコア閾値config('captcha.recaptcha_v3.min_score') から、既定 0.5。本番では 0.3〜0.5 あたりが普通。低すぎると bot が通る、高すぎると人間が弾かれる、というトレードオフ。

action mismatch チェックはリプレイ攻撃対策。「サインアップで取った token をログインで使い回す」を防ぐ。

CaptchaManager — provider を「フロント送信値で」振り分ける

CaptchaManager は 3 メソッドを公開する:

public function primary(): CaptchaVerifier
public function fallback(): ?CaptchaVerifier
public function byName(?string $name): CaptchaVerifier

中心は byName():

public function byName(?string $name): CaptchaVerifier
{
    $name = $name !== null ? trim($name) : '';
    if ($name === '') {
        return $this->primary();
    }
    if ($name === (string) ($this->config['provider'] ?? '')) {
        return $this->primary();
    }
    $fallback = $this->fallback();
    if ($fallback !== null && $fallback->name() === $name) {
        return $fallback;
    }
    // 知らない provider 名 → primary に倒す
    return $this->primary();
}

フロントが送ってきた captcha_provider 名を信じる」設計。サーバが暗黙に fallback しない。「知らない名前」が来たら primary に寄せる (安全側のフォールバック)。

fallback() は config 上で fallback provider 名や secret_key が空ならば null を返す。これによって 「fallback 未配備の環境」も同じ設計で動く。dev 環境では provider を null にして CAPTCHA を切れる。

ChecksCaptcha trait — Controller から呼ぶ口

Controller 共通の trait に集約する:

protected function verifyCaptchaOrFail(Request $request, string $action): void
{
    $manager = app(CaptchaManager::class);
    $token = $request->input('captcha_token');
    $provider = $request->input('captcha_provider');

    $verifier = $manager->byName(is_string($provider) ? $provider : null);
    $result = $verifier->verify(
        token: is_string($token) ? $token : null,
        remoteIp: $request->ip(),
        action: $action,
    );

    if ($result->success) {
        return;
    }

    $body = ['message' => 'CAPTCHA verification failed.'];

    // primary が失敗 & fallback あり → フロントに切替を指示
    $primary = $manager->primary();
    $fallbackName = $manager->fallbackName();
    if ($verifier->name() === $primary->name() && $fallbackName !== null) {
        $body['retry_with_provider'] = $fallbackName;
        $body['fallback_site_key'] = $manager->fallbackSiteKey();
    }

    abort(response()->json($body, 422));
}

ContactController での使用例:

public function store(Request $request): JsonResponse
{
    $request->validate([
        'captcha_token'    => ['nullable', 'string', 'max:4096'],
        'captcha_provider' => ['nullable', 'string', 'max:32'],
    ]);
    $this->verifyCaptchaOrFail($request, 'contact');
    // ... 業務ロジック
}

action (第 2 引数) は Controller ごとに固定。サインアップなら 'register'、コンタクトなら 'contact'。これが reCAPTCHA v3 の action 一致チェックに繋がる。

フロント CaptchaWidget — 自動切替の実装

React 側はステートマシン的に provider を切り替える。初期値はビルド時の env で決定:

const initialProvider: Provider = PRIMARY_SITE_KEY !== ''
  ? 'turnstile'
  : FALLBACK_SITE_KEY !== ''
    ? 'recaptcha_v3'
    : 'null';

const [provider, setProvider] = useState<Provider>(initialProvider);

Turnstile の自動 fallback はこう:

useEffect(() => {
  if (provider !== 'turnstile') return;
  loadScript(TURNSTILE_SRC)
    .then(() => {
      window.turnstile.render(el, {
        sitekey: PRIMARY_SITE_KEY,
        action,
        'error-callback': () => {
          console.warn('[Turnstile] error-callback - falling back');
          switchToFallback();
        },
      });
    })
    .catch((e) => {
      console.warn('[Turnstile] script load failed - falling back', e);
      switchToFallback();
    });
}, [provider, action, switchToFallback]);

切替の発火点は 3 つ:

  1. loadScript() が reject (script タグの load イベントが error)
  2. Turnstile の error-callback 発火 (= widget 内部で何か起きた)
  3. 後述: サーバから retry_with_provider レスポンスを受けた時、親コンポーネントが switchToFallback() を呼ぶ

reCAPTCHA v3 側の token 取得:

if (provider === 'recaptcha_v3' && FALLBACK_SITE_KEY !== '') {
  await new Promise<void>((resolve) =>
    window.grecaptcha!.ready(() => resolve())
  );
  const tok = await window.grecaptcha.execute(FALLBACK_SITE_KEY, { action });
  return { token: tok, provider: 'recaptcha_v3' };
}

v3 は invisible なので「ボタンを押した瞬間に execute()」する。Turnstile のように widget としては描画されない。

公開 API は ref 経由:

type CaptchaWidgetHandle = {
  getToken: () => Promise<{ token: string; provider: Provider }>;
  switchToFallback: () => void;
  reset: () => void;
};

API クライアント — エラーレスポンスを受けて切替

クライアントは token を Body 内に乗せる:

register: (body: {
  name: string;
  email: string;
  password: string;
  captcha_token?: string;
  captcha_provider?: string;
}) =>
  request<AuthResponse>('/api/auth/register', {
    method: 'POST',
    body: JSON.stringify(body),
  })

header に乗せると CORS preflight が増えやすい、というのもあるが、それより Laravel 側の $request->input() で取れる方が trait からアクセスしやすい という実装側の都合。

422 を受けた時に、レスポンス内の retry_with_provider を ApiError に同梱して上に投げる:

if (typeof body.retry_with_provider === 'string') {
  retryWithProvider = body.retry_with_provider;
}
if (typeof body.fallback_site_key === 'string') {
  fallbackSiteKey = body.fallback_site_key;
}
throw new ApiError(message, res.status, errors, retryWithProvider, fallbackSiteKey);

呼び出し側 (SignUp.tsx 等) は ApiError を catch して widget の switchToFallback() を呼び、getToken()fallback provider で再実行して、もう一度 register を叩く。フロントが状態遷移の主役、という構図がここで完結する。

native curl で呼ぶ理由 — 共有レンタルサーバの libcurl 罠

両 Verifier の HTTP 呼び出しは Guzzle (Http facade) ではなく native curl_* で書いている。表面的には冗長だが、理由は本番環境にある:

  • 共有レンタルサーバの libcurl が古く (例: 7.29.0)、CURL_SSLVERSION_TLSv1_2 等の定数が未定義
  • Guzzle がこの定数を SSL_VERSION オプションに使うと FATAL
  • そもそも Guzzle 内部の TLS デフォルト挙動が「サーバの libcurl の素」と一致しない (バージョン依存)

native curl ならオプションを明示できる:

curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($form),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => max(1, (int) ceil($timeout)),
    CURLOPT_SSL_VERIFYPEER => true,
    CURLOPT_SSL_VERIFYHOST => 2,
]);
$body   = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err    = (string) curl_error($ch);
curl_close($ch);

「Guzzle で書いても動く環境」と「動かない環境」が分かれる時、先に薄く curl ラッパーで書いておくほうが移植可能、というのが教訓。あとからインフラを動かす時に「ライブラリの内部 TLS 実装」を疑わなくて済む。

テスト戦略 — postForm() を protected にする

verify ロジックを単体テストしたいが、本物の Cloudflare/Google を叩きたくない。postForm()protected メソッド として切り出して、テストで子クラスで上書きする:

class TurnstileVerifier implements CaptchaVerifier
{
    protected function postForm(string $url, array $form, float $timeout): array
    {
        // [body, status, error] を返す
    }
}

テストはこう:

private function makeVerifierWith(array $stubbedResponse): TurnstileVerifier
{
    return new class(/* constructor args */) extends TurnstileVerifier {
        public array $stub;
        protected function postForm(string $url, array $form, float $timeout): array
        {
            return $this->stub;
        }
    };
}

public function test_returns_pass_when_cloudflare_says_success(): void
{
    $verifier = $this->makeVerifierWith([
        json_encode(['success' => true, 'hostname' => 'example.com']),
        200,
        '',
    ]);
    $result = $verifier->verify('valid-token', '203.0.113.42');
    $this->assertTrue($result->success);
}

「protected メソッドで外部 I/O を分離する」 は古典的な Laravel テストパターン。HTTP モックライブラリを入れなくていい分、依存関係も浅い。

DI バインディングも別途テスト:

public function test_resolves_turnstile_when_configured(): void
{
    config(['captcha.provider' => 'turnstile', 'captcha.secret_key' => 'secret']);
    $verifier = $this->app->make(CaptchaVerifier::class);
    $this->assertInstanceOf(TurnstileVerifier::class, $verifier);
}

public function test_resolves_null_when_secret_empty(): void
{
    config(['captcha.provider' => 'turnstile', 'captcha.secret_key' => '']);
    $verifier = $this->app->make(CaptchaVerifier::class);
    $this->assertInstanceOf(NullVerifier::class, $verifier);
}

secret_key 未配備 → 自動で NullVerifier (常に pass) に落ちる、という仕様は dev/staging で CAPTCHA を切る ためのもの。これがあると開発フローが止まらない。

設定 — primary / fallback を対称に

// config/captcha.php
return [
    'provider'   => env('CAPTCHA_PROVIDER', 'turnstile'),
    'site_key'   => env('CAPTCHA_SITE_KEY', ''),
    'secret_key' => env('CAPTCHA_SECRET_KEY', ''),

    'fallback' => [
        'provider'   => env('CAPTCHA_FALLBACK_PROVIDER', ''),
        'site_key'   => env('CAPTCHA_FALLBACK_SITE_KEY', ''),
        'secret_key' => env('CAPTCHA_FALLBACK_SECRET_KEY', ''),
    ],

    'timeout' => (float) env('CAPTCHA_TIMEOUT', 5.0),

    'turnstile' => [
        'verify_url' => env('CAPTCHA_TURNSTILE_URL', '...'),
    ],
    'recaptcha_v3' => [
        'verify_url' => env('CAPTCHA_RECAPTCHA_URL', '...'),
        'min_score'  => (float) env('CAPTCHA_RECAPTCHA_MIN_SCORE', 0.5),
    ],
];

primary と fallback の構造を完全に対称にしてある。これによって「primary を v3 に / fallback を Turnstile に」と入れ替える時もコード変更が要らない。env だけで切れる。

罠まとめ

  1. 両方の SDK を同時にロードしない — script の競合 + バンドル肥大
  2. サーバ側で暗黙の fallback をしない — token は provider 固有、ユーザ操作も必要
  3. Turnstile と v3 は API 形が違うscore / action の扱いが対称ではない
  4. 共有サーバの libcurl — native curl で書いておくと環境依存を減らせる
  5. action mismatch チェック (v3) — Controller ごとに固定の action 文字列を渡す
  6. dev では NullVerifier — secret 未配備で自動的に常時 pass、これで開発フローを止めない
  7. token は Body 内 — header に乗せると CORS preflight が増えやすい

まとめ

CAPTCHA を 2 重化する設計の核心は:

  • interface (CaptchaVerifier) + Manager で provider を抽象化
  • 「サーバが暗黙に fallback しない」 で責務を綺麗に分ける
  • フロントが状態機械の主役、サーバは retry_with_provider で次の動きを指示
  • dev/staging では NullVerifier に落とす ことで「CAPTCHA で開発が止まる」を防ぐ
  • primary / fallback の設定を対称にして env だけで入れ替え可能 にしておく

このパターンは他のサードパーティ依存 (決済 / OCR / 通知) でも応用が効く。一次選択肢が落ちた時に二次に切り替える、という構造は interface + Manager + フロント自動切替 のセットで形にできる。

コメント

タイトルとURLをコピーしました