React

React Hook Form、Zod を使ったフォームに reCAPTCHA v3 をサクッと導入する

RHF と zod を使ったフォームに Google reCAPTCHA v3 をプラグインで簡単に導入する方法を紹介します。

React Hook Form、Zod を使ったフォームに reCAPTCHA v3 をサクッと導入する

コンニチハ!ハルミです。

今回は RHF(React Hook Form)と zod を使ったフォームに Google reCAPTCHA v3(現在は reCAPTCHA Enterprise に変更されていますが、便宜上 reCAPTCHA v3 と記載します)を導入する方法を紹介します。

Google reCAPTCHA は Google が提供するウェブサイトのセキュリティを強化するためのサービスです。

reCAPTCHA v3 を導入することで、フォームの送信時に bot の送信を検知して、バリデーションを行うことができます。

RHF と zod を使ったフォーム作成の記事は沢山ありますが、そこに reCAPTCHA を導入する記事はあまりないので、是非参考にしてみてください。

注意

2024年4月1日から、GoogleのreCAPTCHAサービスの無料利用枠が大幅に縮小されました。これまでの月間100万リクエストから、1万リクエストに制限されることになりました。この変更は、特に多くのトラフィックを持つウェブサイトにとって大きな影響を及ぼす可能性があります。具体的には、月間1万リクエストを超えると、追加料金が発生するため、ユーザーは新たに有料プランを契約する必要が出てきます。

この新しい料金体系では、reCAPTCHA Enterpriseの利用者は、1万件を超えた分については1000件あたり1ドルの料金が発生します。
また、新たに月額8ドルで10万リクエストまで利用できる「reCAPTCHA Standard」プランも導入される予定です。

月間1万リクエストを超えるようなサイトに導入を考えている場合は、注意が必要です。

はじめに

この記事をご覧になられている方は、RHF や zod の基本的な使い方についてはある程度理解していることを前提にしていますが、簡単に説明しておきます。

RHF(React Hook Form)とは

RHFは、React アプリケーションにおけるフォーム管理を簡素化するためのライブラリです。
このライブラリは、フォームの状態管理やバリデーションを効率的に行うことができ、特に大規模なフォームや複雑なバリデーションが必要な場合にその効果を発揮します。

読み込み中...

Zod とは

Zodは、TypeScript 向けのスキーマ定義とバリデーションライブラリです。
TypeScript の型システムと統合されており、コンパイル時に型エラーを検出しやすくし、ランタイムエラーを減らすのに役立ちます。
データの構造や形式を明示的に定義し、型安全な方法でバリデーションを行うことができます。
特にフォームバリデーションや API レスポンスの検証において、開発効率とコードの安全性を向上させるツールとして利用されています。

読み込み中...

RHF と zod を使ったフォームの作成

それでは、実際に導入していきましょう。
フレームワークは Next.js App Router を使用します。

まずは基本的な RHF と zod を使ったフォームを作成します。 ここは簡単に進めるので、各自要件に合わせて作成してください。

ライブラリのインストール

まずは、RHF と zod をインストールします。

npm install react-hook-form zod @hookform/resolvers

Zod スキーマの定義

Zod を使用して、フォームのバリデーションルールを定義します。例えば、ユーザー名とメールアドレスのバリデーションを行うスキーマは次のようになります。

import { z } from "zod";

const schema = z.object({
  name: z.string().nonempty({ message: "名前は必須です" }),
  email: z.string().email({ message: "無効なメールアドレスです" }),
});

useForm フックの設定

RHF のuseFormフックを使用して、フォームの状態管理を行います。この際、Zod スキーマをzodResolverを通じて渡します。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm <
FormValues >
{
  resolver: zodResolver(schema),
};

フォームの作成

フォーム要素を作成し、registerメソッドを使用して各フィールドを登録します。エラーメッセージはerrorsオブジェクトから取得できます。

この例では、フォームの入力内容に問題がなければ、
/api/sendエンドポイントにフォームデータを送信します。

// フォームの型を定義
interface FormValues {
  name: string;
  email: string;
}

// フォームの送信処理
const onSubmit: SubmitHandler<FormValues> = useCallback(
  async (data: z.infer<typeof formSchema>) => {
    try {
      const response = await fetch("/api/send", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });
      return { success: true };
    } catch (error) {
      console.log(error);
      return { success: false };
    }
  },
  []
);

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("name")} />
  {errors.name && <span>{errors.name.message}</span>}
  <input {...register("email")} />
  {errors.email && <span>{errors.email.message}</span>}
  <button type="submit">Submit</button>
</form>;

API エンドポイントの作成

/api/sendエンドポイントを作成します。
ここでは、フォームデータを受け取り、メールを送信する処理を行います。
この記事ではResendを使用してメールを送信します。

Resend はメール送信のための API サービスです。

読み込み中...

メール送信サービスの解説については、この記事の趣旨とは異なるため、解説しません。
お好きなメール送信サービスを使用してください。

app/api/send/route.ts
import { NextResponse } from "next/server";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

const EmailTemplate = ({ username, email }: { username: string; email: string }) => {
  return (
    <div>
      <h1>{username} 様よりお問い合わせがありました</h1>
      <p>【お名前】</p>
      <p>{username}</p>
      <p>【メールアドレス】</p>
      <p>{email}</p>
    </div>
  );
};

export async function POST(req: Request) {
  const { username, email } = await req.json();

  try {
    const { data, error } = await resend.emails.send({
      from: "Acme <onboarding@resend.dev>",
      to: ["your-email@example.com"],
      subject: "お問い合わせがありました",
      react: EmailTemplate({ username, email }),
    });

    if (error) {
      return NextResponse.json({ error: "メールの送信に失敗しました" });
    }

    return NextResponse.json({ data });
  } catch (error) {
    console.log(error);
    return NextResponse.json({ error });
  }
}

これで、フォームの送信ができるようになりました。

reCAPTCHA v3 の設定

Google reCAPTCHA は、ウェブサイトをスパムや不正行為から守るためのセキュリティサービスです。
人間とボットを区別するために、高度なリスク分析エンジンを使用し、フォーム送信やログイン画面での不正アクセスを防ぎます。
reCAPTCHA には、v2 と v3 があり、v3 はユーザーに対してチェックボックスや画像認証を求めず、よりスムーズな体験を提供します。

Google reCAPTCHA v3 のセットアップ

Google reCAPTCHA v3 を使用するためには、まず Google Cloud プロジェクトを作成し、API キーを取得する必要があります。

  1. Google Cloud コンソールにログインします。
  2. プロジェクトを作成します。
  3. 「reCAPTCHA」 > 「鍵を作成」をします。
  4. 「サイトキー」と「シークレットキー」を取得します。

まずはこちらにアクセスしましょう。

読み込み中...

下のような画面が表示されますので、
「Get Started with Enterprise」をクリックしましょう。

Google Cloud コンソール画面

新規プロジェクト作成画面に遷移しますので、
任意のプロジェクト名を入力して「開始」をクリックしましょう。

Google Cloud コンソール画面

プロジェクトのセットアップが完了すると、キーの作成画面に遷移します。
こちらで必要事項を入力して「キーを作成」をクリックしましょう。

  • 表示名:任意の名前を入力しましょう。
  • プラットフォームの種類を選択: 「ウェブサイト」を選択しましょう。
  • ドメインリスト: フォームを設置するサイトのドメインを入力しましょう。開発環境でしたら localhost でも大丈夫です。
Google Cloud コンソール画面

キーの作成が完了すると、サイトキーとシークレットキーが発行されます。

サイトキーは「ID」と表示されている文字列です。
シークレットキーは「以前の鍵を使用する」をクリックすると表示されます。

サイトキーが表示された画面 以前の鍵を使用するをクリックした画面

最後に、サイトキーとシークレットキーを環境変数に設定しましょう。
以下は例ですので、各自のキーを設定してください。
サイトキーはクライアントサイドで使用するため、NEXT_PUBLIC_ を先頭に付与する必要がある点に注意してください。

.env
NEXT_PUBLIC_RECAPTCHA_SITE_KEY="6Ld1flkqAAAAAImDg196OrC2WVuscHVwDmZPMvNY"
RECAPTCHA_SECRET_KEY="6Ld1flkqAAAAAEoU0ivyPkYGIdggr7LxKE8GgYLf"
注意

.env ファイルはGitに公開しないようにしてください。
.gitignore ファイルに .env を追記してください。

.gitignore
.env

これで、Google reCAPTCHA v3 を使用する準備が整いました。

フォームに reCAPTCHA を組み込む

フォームに reCAPTCHA を組み込んでいきましょう。 ここで便利なライブラリがあるので、そちらを使用していきます。

読み込み中...

ライブラリのインストール

まずは、ライブラリをインストールしましょう。

npm install react-google-recaptcha

フォームに reCAPTCHA を組み込む

先ほどのフォームに reCAPTCHA を組み込んでいきます。
<ReCAPTCHA /> コンポーネントを使用して、reCAPTCHA を組み込んでいきます。

  • sitekey には、先ほど取得したサイトキーを設定します。
  • size には、invisible を設定します。
  • useRef を使用して、<ReCAPTCHA /> コンポーネントを参照します。
//~~~~~~~~~~~
import ReCAPTCHA from "react-google-recaptcha";
import { useRef } from "react";

const recaptchaRef = useRef<ReCAPTCHA>(null);
//~~~~~~~~~~~

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("name")} />
  {errors.name && <span>{errors.name.message}</span>}
  <input {...register("email")} />
  {errors.email && <span>{errors.email.message}</span>}
  <button type="submit">Submit</button>
  // reCAPTCHA を組み込む
  <ReCAPTCHA
    sitekey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY as string}
    size="invisible"
    ref={recaptchaRef}
  />
</form>

このままではまだ reCAPTCHA が有効になりません。 送信ボタンが押された時に reCAPTCHA トークンを取得する必要があるので、

<form onSubmit={handleSubmit(onSubmit)}>こちらの処理を変更していきます。

onSubmit の前に reCAPTCHA トークンを取得する

handleSubmit の引数に recaptchaWithSubmit を設定して、onSubmitする前にreCAPTCHA トークンを取得する処理を記述します。

reCAPTCHA トークンを取得するために、recaptchaRef.current?.executeAsync() を使用します。

const recaptchaWithSubmit = async (data: z.infer<typeof formSchema>) => {
  if (!recaptchaRef.current) {
    console.error("reCAPTCHA reference is not set");
    return;
  }

  // reCAPTCHA トークンを取得
  const recaptchaToken = await recaptchaRef.current.executeAsync();

  if (!recaptchaToken) {
    setErrorMessage("reCAPTCHAトークンの取得に失敗しました。");
    return;
  }

  // トークンをonSubmitに渡す
  const result = await onSubmit(data, recaptchaToken);

  if (!result.success) {
    setErrorMessage("reCAPTCHAトークンの取得に失敗しました。");
  } else {
    setErrorMessage(null);
  }
};

<form onSubmit={handleSubmit(recaptchaWithSubmit)}>
  <input {...register("name")} />
  {errors.name && <span>{errors.name.message}</span>}
  <input {...register("email")} />
  {errors.email && <span>{errors.email.message}</span>}
  <button type="submit">Submit</button>
  <ReCAPTCHA
    sitekey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY as string}
    size="invisible"
    ref={recaptchaRef}
  />
</form>

フォームデータと一緒に reCAPTCHA トークンを送信する

次にonSubmitの処理を変更していきます。

onSubmitの引数にrecaptchaTokenを追加します。
そして、/api/sendエンドポイントにrecaptchaTokenを送信します。

const onSubmit: SubmitHandler<FormValues> = useCallback(
-  async (data: z.infer<typeof formSchema>) => {
+  async (data: z.infer<typeof formSchema>, recaptchaToken: string) => {
    try {
      const response = await fetch("/api/send", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
-        body: JSON.stringify(data),
+        body: JSON.stringify({ ...data, recaptchaToken }),
      });
      return { success: true };
    } catch (error) {
      console.log(error);
      return { success: false };
    }
  },
  []
);

API で reCAPTCHA トークンを検証する

/api/sendエンドポイントで reCAPTCHA トークンを検証します。
recaptchaTokenPOSTボディに含めて、Google reCAPTCHA API にリクエストを送信します。

Google reCAPTCHA API からのレスポンスがsuccessの場合、メールを送信します。

app/api/send/route.ts
import { NextResponse } from "next/server";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

const EmailTemplate = ({ username, email }: { username: string; email: string }) => {
  return (
    <div>
      <h1>{username} 様よりお問い合わせがありました</h1>
      <p>【お名前】</p>
      <p>{username}</p>
      <p>【メールアドレス】</p>
      <p>{email}</p>
    </div>
  );
};

export async function POST(req: Request) {
  // reCAPTCHA トークンを受け取る
  const { username, email, recaptchaToken } = await req.json();

    // reCAPTCHAの検証
  const recaptchaResponse = await fetch(`https://www.google.com/recaptcha/api/siteverify`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      secret: RECAPTCHA_SECRET_KEY as string,
      response: recaptchaToken,
    }),
  });

  const recaptchaData = await recaptchaResponse.json();

  if (!recaptchaData.success) {
    return NextResponse.json({ error: "reCAPTCHAの検証に失敗しました" }, { status: 403 });
  }

  try {
    const { data, error } = await resend.emails.send({
      from: "Acme <onboarding@resend.dev>",
      to: ["your-email@example.com"],
      subject: "お問い合わせがありました",
      react: EmailTemplate({ username, email }),
    });

    if (error) {
      return NextResponse.json({ error: "メールの送信に失敗しました" });
    }

    return NextResponse.json({ data });
  } catch (error) {
    console.log(error);
    return NextResponse.json({ error });
  }
}

これで、メール送信前に reCAPTCHA の検証が行われるようになりました。 正しく実装できていれば、Google Cloud の reCAPTCHA のコンソールでこのように表示されているはずです。

「正しく設定されています」と表示された画面

これで、Google reCAPTCHA v3 を使用して、フォームの送信を安全に行うことができるようになりました。

まとめ

RHF と zod を使用して、フォームを作成し、Google reCAPTCHA v3 を導入することができました。
reCAPTCHA を使用することで、ボットのアクセスを防ぐことができます。

さらにセキュアなフォームを作成したい場合は、オリジンの設定やCSRFトークンでの検証も行うことをお勧めします。
当サイトでのお問い合わせフォームでは、JWT を使用したトークンでの検証も行っています。

JWTトークンを使用したCSRF対策についても、需要があれば記事にしたいと思います。

それでは、また👋

profile

ハルミ

1997年生まれ。某メーカーの新米DX担当。
三度の飯より効率化が好き。
プログラミングにハマり、Webエンジニアを目指す。
現在React/Next.jsを学習しています🚀