jQueryフォームにCSRF対策を追加する方法

はじめに

Webアプリケーションを開発する際、CSRF (Cross-Site Request Forgery) 対策は欠かせません。本記事では、jQuery を使用したシンプルなフォームに、CSRF対策を実装する方法を解説します。

この記事では、以下の技術スタックを利用しています。

利用技術とバージョン情報

  • HTML5/CSS3
    フロントエンドのベースとして使用。
  • jQuery: 3.x
    使用バージョン: 3.6.4
    最新の安定版を利用しています。
  • Tailwind CSS: 3.x
    使用バージョン: 3.4.15
    レスポンシブデザインを簡単に実現。
  • PHP: 8.x
    使用バージョン: 8.2.22
    サーバーサイドでフォーム処理とCSRFトークン検証を実装。
  • サーバー環境: エックスサーバー
    PHPとメール送信が動作するレンタルサーバーを想定。

完成形のデモ

以下のフォームは、CSRF対策を実装した完成形です。簡単なお問い合わせフォームをベースに、安全なデータ送信を実現します。

機能の概要

  1. CSRFトークンを生成し、セッションに保存。
  2. フォーム送信時にトークンを含めてサーバーに送信。
  3. サーバーでトークンを検証し、一致しない場合はリクエストを拒否。

実装手順

1. フロントエンド:フォームにCSRFトークンを埋め込む

まず、ページロード時にCSRFトークンをサーバーから取得し、フォームデータに含めるようにします。

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>jQuery お問い合わせフォーム</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://code.jquery.com/jquery-3.6.4.min.js"></script>
  <script src="https://www.google.com/recaptcha/api.js?render=your-site-key"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
  <div id="form-wrapper" class="container mx-auto p-4 max-w-screen-md">
    <!-- 入力画面 -->
    <div id="input-screen" class="page bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
      <h1 class="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
      <form id="contact-form">
        <div class="mb-4">
          <label for="name" class="block text-sm font-medium text-gray-700">名前:</label>
          <input type="text" id="name" name="name" required
            class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
        </div>
        <div class="mb-4">
          <label for="email" class="block text-sm font-medium text-gray-700">メールアドレス:</label>
          <input type="email" id="email" name="email" required
            class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
        </div>
        <div class="mb-4">
          <label for="message" class="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
          <textarea id="message" name="message" required
            class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"></textarea>
        </div>
        <button type="button" id="to-confirm" class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
          確認画面へ
        </button>
      </form>
    </div>

    <!-- 確認画面 -->
    <div id="confirm-screen" class="page hidden bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
      <h1 class="text-2xl font-bold mb-4">内容の確認</h1>
      <p class="mb-2"><strong>名前:</strong> <span id="confirm-name"></span></p>
      <p class="mb-2"><strong>メールアドレス:</strong> <span id="confirm-email"></span></p>
      <p class="mb-4"><strong>お問い合わせ内容:</strong> <span id="confirm-message"></span></p>
      <div class="flex space-x-4">
        <button id="back-to-input" class="w-1/2 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">
          戻る
        </button>
        <button id="submit-form" class="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
          送信する
        </button>
      </div>
    </div>

    <!-- 完了画面 -->
    <div id="complete-screen" class="page hidden bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
      <h1 class="text-2xl font-bold mb-4">送信が完了しました</h1>
      <p>お問い合わせいただきありがとうございます。</p>
      <button id="reset-form" class="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
        フォームに戻る
      </button>
    </div>
  </div>

  <script>
    $(document).ready(function () {
      let csrfToken;

      // CSRFトークンを取得
      $.getJSON('backend/csrf_token.php', function (response) {
        csrfToken = response.csrf_token;
      });

      const routes = {
        "/": "#input-screen",
        "/confirm": "#confirm-screen",
        "/complete": "#complete-screen",
      };

      function navigate(path) {
        $(".page").addClass("hidden");
        const target = routes[path] || "#input-screen";
        $(target).removeClass("hidden");
        history.pushState({}, "", path);
      }

      navigate(window.location.pathname);

      $("#to-confirm").click(function () {
        const name = $("#name").val().trim();
        const email = $("#email").val().trim();
        const message = $("#message").val().trim();

        if (!name || !email || !message) {
          alert("全ての項目を入力してください。");
          return;
        }

        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(email)) {
          alert("有効なメールアドレスを入力してください。");
          return;
        }

        $("#confirm-name").text(name);
        $("#confirm-email").text(email);
        $("#confirm-message").text(message);

        navigate("/confirm");
      });

      $("#back-to-input").click(function () {
        navigate("/");
      });

      $("#submit-form").click(function () {
        grecaptcha.ready(() => {
          grecaptcha.execute("your-site-key", { action: "submit" }).then((token) => {
            const formData = {
              name: $("#name").val(),
              email: $("#email").val(),
              message: $("#message").val(),
              recaptcha_token: token,
              csrf_token: csrfToken,
            };

            $.ajax({
              url: "backend/contact.php",
              type: "POST",
              contentType: "application/json",
              data: JSON.stringify(formData),
              success: function (response) {
                if (response.success) {
                  navigate("/complete");
                } else {
                  alert("送信に失敗しました: " + (response.error || "不明なエラー"));
                }
              },
              error: function () {
                alert("エラーが発生しました。");
              },
            });
          });
        });
      });

      $("#reset-form").click(function () {
        $("#name").val("");
        $("#email").val("");
        $("#message").val("");
        navigate("/");
      });
    });
  </script>
</body>
</html>

2. バックエンド:CSRFトークンの生成

次に、PHPを使用してCSRFトークンを生成し、セッションに保存します。

csrf_token.php

<?php
// csrf_token.php
session_start();

// トークン生成(セッションに保存)
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// レスポンスをJSONで返す
header('Content-Type: application/json');
echo json_encode(['csrf_token' => $_SESSION['csrf_token']]);

3. バックエンド:トークンの検証

フォーム送信時にトークンを受け取り、セッションに保存されているトークンと一致するか確認します。

contact.php

<?php
session_start();
header('Content-Type: application/json');

// リクエストデータの取得
$data = json_decode(file_get_contents('php://input'), true);

// CSRFトークンの検証
if (empty($_SESSION['csrf_token']) || empty($data['csrf_token']) || $_SESSION['csrf_token'] !== $data['csrf_token']) {
    echo json_encode(['success' => false, 'error' => 'CSRFトークンが無効です。']);
    exit;
}

// reCAPTCHA の検証
$recaptcha_url = 'https://www.google.com/recaptcha/api/siteverify';
$recaptcha_secret = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; // シークレットキーを設定
$recaptcha_response = $data['recaptcha_token'];

$response = file_get_contents($recaptcha_url . '?secret=' . $recaptcha_secret . '&response=' . $recaptcha_response);
$recaptcha = json_decode($response, true);

if (!$recaptcha['success']) {
    echo json_encode(['success' => false, 'error' => 'reCAPTCHA 検証に失敗しました。']);
    exit;
}

// 入力データのサニタイズとバリデーション
$name = htmlspecialchars($data['name'], ENT_QUOTES, 'UTF-8');
$email = filter_var($data['email'], FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars($data['message'], ENT_QUOTES, 'UTF-8');

if (!$email) {
    echo json_encode(['success' => false, 'error' => 'メールアドレスが無効です。']);
    exit;
}

// メール送信処理
$toAdmin = 'admin@example.com';
$subjectAdmin = 'お問い合わせフォームからのメッセージ';
$messageAdmin = "名前: $name\nメールアドレス: $email\nメッセージ: $message";
$headersAdmin = 'From: no-reply@example.com';

$mailToAdmin = mail($toAdmin, $subjectAdmin, $messageAdmin, $headersAdmin);

// 問い合わせ者への自動返信メール
$toUser = $email;
$subjectUser = 'お問い合わせありがとうございます';
$messageUser = "$name 様\n\nお問い合わせありがとうございます。\n以下の内容で受け付けました。\n\n----------------------\nお名前: $name\nメールアドレス: $email\nお問い合わせ内容:\n$message\n----------------------\n\n担当者より折り返しご連絡いたします。\n\nよろしくお願いいたします。\n";
$headersUser = 'From: no-reply@example.com';

$mailToUser = mail($toUser, $subjectUser, $messageUser, $headersUser);

// レスポンス
if ($mailToAdmin && $mailToUser) {
    echo json_encode(['success' => true]);
} else {
    echo json_encode(['success' => false, 'error' => 'メール送信に失敗しました。']);
}

サンプルコードの動作

以下のフローで動作します:

  1. index.html がロードされると、csrf_token.php にリクエストを送り、トークンを取得します。
  2. フォーム送信時に、入力内容とともにトークンをcontact.phpに送信します。
  3. サーバーでトークンを検証し、一致する場合のみ処理を続行します。

まとめ

CSRF対策は、外部サイトからの不正なリクエストを防ぐための基本的なセキュリティ対策です。本記事の方法を活用すれば、jQueryを使ったフォームでも簡単にCSRF対策を実装できます。

利用技術のポイント

  • jQuery は簡潔なコードでフォームの動作を実現。
  • Tailwind CSS により、シンプルでモダンなUIを作成。
  • PHP を使用してトークンの生成と検証を実装。

コメント

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