ReactフォームにCSV保存機能を追加する方法(CSRF対策強化版)

Reactを使ったお問い合わせフォームに「CSV保存機能」を追加する方法を解説します。CSRFトークンの有効期限を導入した高度なセキュリティ対策を実現し、送信されたデータをCSV形式で保存する仕組みを実装します。


1. 実装の背景と目的

お問い合わせフォームは、データ管理やセキュリティが重要です。本記事では、以下の内容を解説します。

主な機能

  • ユーザーからの入力データをCSVファイルに保存。
  • CSRFトークンを活用したセキュリティ対策。
  • トークンの有効期限を追加し、再利用を防止。
  • URLのパスをシングルページアプリケーション(SPA)向けにリダイレクト設定。

使用技術

  • フロントエンド: React 17.x、Tailwind CSS、React Router DOM、Google reCAPTCHA v3。
  • バックエンド: PHP 7.4、CSV保存、CSRFトークン生成、reCAPTCHA検証。
  • サーバー設定: Apache 2.x、.htaccessでのURLリダイレクト。

2. ディレクトリ構成

プロジェクトのディレクトリ構成を以下に示します。

project/
├── index.html                 // Reactのエントリーポイント
└── api/                       // バックエンドAPI
    ├── config.php             // 環境設定ファイル
    ├── csrf_token.php         // CSRFトークン生成
    ├── recaptcha_key.php      // reCAPTCHAキー取得
    ├── contact.php            // フォーム送信データの処理
    ├── .htaccess              // ディレクトリ保護
    └── data/
        └── form_submissions.csv // 保存されるCSVデータ
├── .htaccess                  // ルートディレクトリのリダイレクト設定

3. フロントエンドのコード

index.html

Reactを利用したお問い合わせフォームのフロントエンド部分です。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React お問い合わせフォーム</title>
  <script>
    const API_URL = "/api"; // APIのベースURLを定義
    let RECAPTCHA_SITE_KEY = "";
    fetch(`${API_URL}/recaptcha_key.php`)
      .then((res) => res.json())
      .then((data) => {
        RECAPTCHA_SITE_KEY = data.site_key;
        const script = document.createElement("script");
        script.src = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`;
        document.head.appendChild(script);
      })
      .catch((error) => {
        console.error("Failed to load reCAPTCHA site key:", error);
      });
  </script>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/react@17/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
  <script src="https://unpkg.com/react-router-dom@5.3.4/umd/react-router-dom.min.js"></script>
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
  <div id="root" class="container mx-auto p-4 max-w-screen-md"></div>
  <script type="text/babel">
    const { BrowserRouter, Route, Switch, useHistory } = window.ReactRouterDOM;
    const { useState, useEffect } = React;

    function InputForm() {
      const history = useHistory();
      const [formData, setFormData] = useState({
        name: "",
        email: "",
        message: "",
      });

      useEffect(() => {
        async function fetchCSRFToken() {
          try {
            const response = await fetch(`${API_URL}/csrf_token.php`);
            const data = await response.json();
            if (data.csrf_token) {
              localStorage.setItem("csrf_token", data.csrf_token);
            }
          } catch (error) {
            alert("CSRFトークンの取得に失敗しました。");
          }
        }
        fetchCSRFToken();
      }, []);

      const handleChange = (e) => {
        const { name, value } = e.target;
        setFormData((prev) => ({ ...prev, [name]: value }));
      };

      const handleSubmit = (e) => {
        e.preventDefault();
        localStorage.setItem("formData", JSON.stringify(formData));
        history.push("/confirm");
      };

      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
          <form onSubmit={handleSubmit}>
            <div className="mb-4">
              <label htmlFor="name" className="block text-sm font-medium text-gray-700">名前:</label>
              <input
                type="text"
                id="name"
                name="name"
                value={formData.name}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <div className="mb-4">
              <label htmlFor="email" className="block text-sm font-medium text-gray-700">メールアドレス:</label>
              <input
                type="email"
                id="email"
                name="email"
                value={formData.email}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <div className="mb-4">
              <label htmlFor="message" className="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
              <textarea
                id="message"
                name="message"
                value={formData.message}
                onChange={handleChange}
                required
                className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
              />
            </div>
            <button type="submit" className="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
              確認画面へ
            </button>
          </form>
        </div>
      );
    }

    function ConfirmForm() {
      const history = useHistory();
      const formData = JSON.parse(localStorage.getItem("formData")) || {};
      const csrfToken = localStorage.getItem("csrf_token");

      const handleBack = () => {
        history.push("/");
      };

      const handleSubmit = async () => {
        grecaptcha.ready(async () => {
          const token = await grecaptcha.execute(RECAPTCHA_SITE_KEY, { action: "submit" });
          const response = await fetch(`${API_URL}/contact.php`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
              "X-CSRF-TOKEN": csrfToken,
            },
            body: JSON.stringify({ ...formData, recaptcha_token: token }),
          });

          const data = await response.json();
          if (data.success) {
            localStorage.removeItem("formData");
            history.push("/complete");
          } else {
            alert("送信に失敗しました: " + data.error);
          }
        });
      };

      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">内容の確認</h1>
          <p className="mb-2"><strong>名前:</strong> {formData.name}</p>
          <p className="mb-2"><strong>メールアドレス:</strong> {formData.email}</p>
          <p className="mb-4"><strong>お問い合わせ内容:</strong> {formData.message}</p>
          <div className="flex space-x-4">
            <button onClick={handleBack} className="w-1/2 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">
              戻る
            </button>
            <button onClick={handleSubmit} className="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">
              送信する
            </button>
          </div>
        </div>
      );
    }

    function CompletePage() {
      const history = useHistory();
      return (
        <div className="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
          <h1 className="text-2xl font-bold mb-4">送信が完了しました</h1>
          <p>お問い合わせいただきありがとうございます。</p>
          <button
            onClick={() => history.push("/")}
            className="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700"
          >
            フォームに戻る
          </button>
        </div>
      );
    }

    ReactDOM.render(
      <BrowserRouter>
        <Switch>
          <Route exact path="/" component={InputForm} />
          <Route path="/confirm" component={ConfirmForm} />
          <Route path="/complete" component={CompletePage} />
        </Switch>
      </BrowserRouter>,
      document.getElementById("root")
    );
  </script>
</body>
</html>

4. バックエンドのコード


config.php

<?php
return [
    'recaptcha' => [
        'site_key' => 'your_recaptcha_site_key',
        'secret_key' => 'your_recaptcha_secret_key',
    ],
    'csrf' => [
        'token_expiry' => 3600, // CSRFトークンの有効期限(秒)
    ],
    'admin_email' => 'admin@example.com',
    'from_email' => 'no-reply@example.com',
];

csrf_token.php

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

// 設定を読み込む
$config = include __DIR__ . '/config.php';

// CSRFトークンを生成
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    $_SESSION['csrf_token_expiry'] = time() + $config['csrf']['token_expiry'];
}

// トークンが期限切れの場合、新しいトークンを生成
if (time() > $_SESSION['csrf_token_expiry']) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    $_SESSION['csrf_token_expiry'] = time() + $config['csrf']['token_expiry'];
}

// レスポンスに CSRF トークンを含める
header('Content-Type: application/json');
echo json_encode(['csrf_token' => $_SESSION['csrf_token']]);

recaptcha_key.php

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

// 設定を読み込む
$config = include __DIR__ . '/config.php';

// reCAPTCHA サイトキーを返す
echo json_encode(['site_key' => $config['recaptcha']['site_key']]);

contact.php

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

$config = include __DIR__ . '/config.php';

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

// reCAPTCHA検証
$recaptcha_url = 'https://www.google.com/recaptcha/api/siteverify';
$recaptcha_response = file_get_contents($recaptcha_url . '?secret=' . $config['recaptcha']['secret_key'] . '&response=' . $_POST['recaptcha_token']);
$recaptcha = json_decode($recaptcha_response, true);

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

// データのサニタイズ
$name = htmlspecialchars($_POST['name'], ENT_QUOTES, 'UTF-8');
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
$message = htmlspecialchars($_POST['message'], ENT_QUOTES, 'UTF-8');

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

// CSV保存
$data = [$name, $email, $message, date('Y-m-d H:i:s')];
$csvFilePath = __DIR__ . '/data/form_submissions.csv';

if (!file_exists(dirname($csvFilePath))) {
    mkdir(dirname($csvFilePath), 0755, true);
}

$file = fopen($csvFilePath, 'a');
fputcsv($file, $data);
fclose($file);

echo json_encode(['success' => true]);

5. サーバー設定

ルートディレクトリの.htaccess

# URLのパスをindex.htmlにリダイレクト
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]

apiディレクトリの.htaccess

<Files "*.csv">
  Order Deny,Allow
  Deny from all
</Files>

6. 保存されるCSVのサンプル

form_submissions.csv

"名前","メールアドレス","お問い合わせ内容","送信日時"
"山田 太郎","yamada@example.com","お問い合わせ内容の例","2023-12-06 15:30:00"
"鈴木 一郎","suzuki@example.com","別のお問い合わせ","2023-12-06 16:45:00"

7. 実装のポイント

  1. CSRF対策の強化
  • CSRFトークンに有効期限を設定。
  • トークンを期限切れにすることで、セッションの再利用を防止。
  1. reCAPTCHAの活用
  • Google reCAPTCHA v3を使用して、スパム送信を防止。
  1. SPA向けのリダイレクト設定
  • .htaccessで未定義のURLリクエストをindex.htmlにリダイレクト。

8. まとめ

本記事では、ReactフォームにCSV保存機能を追加し、セキュリティを強化した実装方法を解説しました。特に、CSRFトークンの有効期限設定やreCAPTCHAの導入により、より安全なフォーム送信を実現しました。

この仕組みは、小規模な運用や簡易なプロジェクトに最適です。必要に応じて、バックエンドでのデータベース保存への拡張も検討してください。

コメント

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