TurnstileをReact Routerに実装する!

TurnstileをReact Routerに実装する! ソフトウェア

はじめに

 本記事はCloudfrareのTurnstileをReact Routerに実装します。
 Cloudflare Turnstile は、ウェブサイトの管理者がボットからのアクセスなどを防ぐために使用するCAPTCHAの代替手段です。無料で使用することができCAPTCHAよりも簡単に導入ができます。
 React RouterはフルスタックのReactフレームワークです。以前まではRemixと別れていましたが統合されました。
※RemixはReact Routerに代わりコードの書き方が変わったりしていますが、Remixでも同様に利用できると思います。

 使用したコードはTurnstile React Router Exampleに公開しています。

環境

  • Node.js v23.10.0
  • React Router v7.5.3
  • @marsidev/react-turnstile v1.1.0

今回React Routerは npx create-react-router@latest で作成したものを使用しました。

手順

キーの準備

 Turnstileのサイトキー・シークレットキーを取得している場合読み飛ばしてください。

 まずはCloudflareのダッシュボードのTurnstileタブを開きます。

 ※Turnstileの使用にはCloudflareのアカウントが必要になるのでお持ちでない場合は作成してください。

Cloudflareダッシュボード

 ウィジェットを追加をクリックします。

Cloudflareダッシュボード

 適当な名前をつけて、ホスト名の追加をクリック。非推奨ですがlocalhostを追加することでローカル環境で実際のサイトキー・シークレットキーを試すことができます。

Cloudflareダッシュボード

 Turnstileを使用したいサイトのホスト名を入力し追加をクリックします。

Cloudflareダッシュボード

 ホスト名を追加したら作成をクリックし、準備完了です。

サイトキー・シークレットキーは再度表示できるのでコピーはしなくても問題ありません。

react-turnstileのインストール

React用のTurnstileパッケージはどうやら二種類が調べるとトップに出てくるようですが、今回はこちらのパッケージを使用します。もう一方はどうやらSSRとの相性が悪いようです。

Bash
npm i @marsidev/react-turnstile

実装

 今回はログインフォームにTurnstileを実装します。以下のように適当なログインフォームを作成します。複数の場所で使用する場合action関数内のコードを関数に分けることをお勧めします。

実際にログインの処理をする場合はoutcome.successで行ってください。

app/routes/_index.tsx
import { Form, type ActionFunctionArgs } from "react-router";
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { useRef, useState } from "react";

// ここは環境に合わせる
import type { Route } from "./+types/_index";

export async function loader() {
  const CF_TURNSTILE_SITE_KEY = process.env.CF_TURNSTILE_SITE_KEY as string;
  return { CF_TURNSTILE_SITE_KEY };
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const token = formData.get("token") as string;

  const CF_TURNSTILE_SECRET_KEY = process.env.CF_TURNSTILE_SECRET_KEY as string;
  const CF_TURNSTILE_URL = process.env.CF_TURNSTILE_URL as string;

  if (!token) {
    return "Token is missing or invalid";
  }
  const tokenForm = new FormData();
  tokenForm.append("secret", CF_TURNSTILE_SECRET_KEY);
  tokenForm.append("response", token);

  const result = await fetch(CF_TURNSTILE_URL, {
    body: tokenForm,
    method: "POST",
  });

  const outcome = await result.json();
  if (outcome.success) {
    // ここでログインの処理とかをする

    return "Success!";
  }
  return "Failure...";
}

export default function Login({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  const { CF_TURNSTILE_SITE_KEY } = loaderData;
  const result = actionData;

  const [token, setToken] = useState("");
  const ref = useRef<TurnstileInstance | undefined>(undefined);
  return (
    <div className="min-h-screen bg-gradient-to-r from-indigo-400 to-blue-500 flex items-center justify-center p-4">
      <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-8">
        <h2 className="text-3xl font-bold text-center text-gray-800 mb-6">
          ログイン
        </h2>
        <Form method="post" className="space-y-4">
          <div>
            <label
              htmlFor="email"
              className="block text-sm font-medium text-gray-600"
            >
              メールアドレス
            </label>
            <input
              type="email"
              id="email"
              name="email"
              required
              className="w-full mt-1 px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
              placeholder="[email protected]"
            />
          </div>

          <div>
            <label
              htmlFor="password"
              className="block text-sm font-medium text-gray-600"
            >
              パスワード
            </label>
            <input
              type="password"
              id="password"
              name="password"
              required
              className="w-full mt-1 px-4 py-2 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
              placeholder="••••••••"
            />
          </div>
          
          <Turnstile
            siteKey={CF_TURNSTILE_SITE_KEY}
            options={{
              // 色を白にする
              theme: "light",
              // 幅を引き延ばす
              size: "flexible",
            }}
            onSuccess={setToken}
            ref={ref}
          />
          <input type="hidden" name="token" value={token} />

          <button
            type="submit"
            className="w-full py-2 px-4 bg-indigo-500 hover:bg-indigo-600 text-white font-semibold rounded-xl transition duration-200"
            // ここでサブミットするごとにTurnstileの検証をやり直す。
            onSubmit={() => ref.current?.reset()}
            // Tokenがないとボタンを押せないようにする
            disabled={!token}
          >
            ログイン
          </button>
        </Form>
        {/* 結果の表示 */}
        {result && (
          <div className={"mt-4 text-center font-semibold text-black"}>
            {result}
          </div>
        )}
      </div>
    </div>
  );
}

 .envにサイトキー・シークレットキーを入れます。

ローカルホストで実際のキーを使用する場合localhostをホストに追加する必要があります。

テスト用のダミーキーはこちらにあります。以下は一例

.env
CF_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
CF_TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"
CF_TURNSTILE_URL="https://challenges.cloudflare.com/turnstile/v0/siteverify"

npm run devで動作させると以下のような画面になるかと思います。
ダミーキーではなく実際のキーに置き換えた場合、ボタンのdiabledを外して送信したり、自分でtokenを書き換えたりするとエラーになるはずです。

ログイン画面

おわりに

 @marsidev/react-turnstileを使用することで非常に簡単に導入することができました。
無料で使用することができreCAPTCHAよりもキーの取得が分かりやすいため、最近はボット対策としてこちらを使用することが個人的に多くなりました。

使用したコードはTurnstile React Router Exampleに公開しています。

コメント

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