Vue.js フォームに CSRF 対策を追加する方法

お問い合わせフォームを開発する際、CSRF(クロスサイトリクエストフォージェリ)攻撃を防ぐことは重要です。本記事では、Vue.jsとPHPを用いて、CSRF対策を組み込んだ安全なフォームを作成する方法を解説します。


概要

CSRF(Cross-Site Request Forgery)は、ユーザーが意図しないリクエストを実行させる攻撃手法です。例えば、ユーザーが知らない間にデータを送信したり、操作を実行させたりするリスクがあります。これを防ぐには、CSRFトークンを用いたセキュリティ対策が有効です。

本記事では以下を実装します:

  • CSRFトークンの生成と検証
  • Google reCAPTCHA v3を組み合わせた二重のセキュリティ
  • 安全なメール送信

技術情報と環境

  • フロントエンド: Vue.js 3.x, Vue Router 4.x, Tailwind CSS 3.x
  • バックエンド: PHP 7.4+
  • メール送信: PHPのmail()関数
  • CSRFトークン生成: PHPのrandom_bytes()
  • reCAPTCHA: Google reCAPTCHA v3
  • 動作環境: エックスサーバー(スタンダードプラン)

ファイル構成

以下の構成でプロジェクトを設定します。

project-directory/
├── index.html
└── backend/
    ├── csrf_token.php
    └── contact.php

1. フロントエンドのコード(index.html)

このファイルはVue.jsでフォーム画面を構築し、バックエンドと連携します。

主な機能

  • csrf_token.php からCSRFトークンを取得し、localStorage に保存。
  • reCAPTCHAトークンとCSRFトークンを含めてサーバーに送信。
  • 入力、確認、完了の3画面構成をVue Routerで実現。

コード

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>お問い合わせフォーム</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/vue@3"></script>
  <script src="https://unpkg.com/vue-router@4"></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="app" class="bg-white shadow-md rounded p-6 w-full max-w-md mx-auto">
    <router-view></router-view>
  </div>

  <script>
    const { createRouter, createWebHistory } = VueRouter;
    const { createApp } = Vue;

    const InputForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
          <form @submit.prevent="goToConfirm">
            <div class="mb-4">
              <label for="name" class="block text-sm font-medium text-gray-700">名前:</label>
              <input v-model="formData.name" type="text" id="name" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
            </div>
            <div class="mb-4">
              <label for="email" class="block text-sm font-medium text-gray-700">メールアドレス:</label>
              <input v-model="formData.email" type="email" id="email" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
            </div>
            <div class="mb-4">
              <label for="message" class="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
              <textarea v-model="formData.message" id="message" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm"></textarea>
            </div>
            <button type="submit" class="w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">確認画面へ</button>
          </form>
        </div>
      `,
      data() {
        return {
          formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', message: '' },
        };
      },
      methods: {
        goToConfirm() {
          localStorage.setItem('formData', JSON.stringify(this.formData));
          this.$router.push({ name: 'confirm' });
        },
      },
    };

    const ConfirmForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">内容の確認</h1>
          <p class="mb-2"><strong>名前:</strong> {{ formData.name }}</p>
          <p class="mb-2"><strong>メールアドレス:</strong> {{ formData.email }}</p>
          <p class="mb-4"><strong>お問い合わせ内容:</strong> {{ formData.message }}</p>
          <div class="flex space-x-4">
            <button @click="goBack" class="w-1/2 bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700">戻る</button>
            <button @click="sendForm" class="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">送信する</button>
          </div>
        </div>
      `,
      data() {
        return {
          formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', message: '' },
        };
      },
      methods: {
        goBack() {
          this.$router.push({ name: 'input' });
        },
        async sendForm() {
          try {
            const csrfToken = localStorage.getItem('csrfToken');
            const recaptchaToken = await grecaptcha.execute('your-site-key', { action: 'submit' });

            const response = await fetch('backend/contact.php', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
              body: JSON.stringify({
                name: this.formData.name,
                email: this.formData.email,
                message: this.formData.message,
                recaptcha_token: recaptchaToken,
                csrf_token: csrfToken,
              }),
            });

            const data = await response.json();
            if (data.success) {
              localStorage.removeItem('formData');
              this.$router.push({ name: 'complete' });
            } else {
              alert('送信に失敗しました: ' + data.error);
            }
          } catch (error) {
            alert('エラー: ' + error.message);
          }
        },
      },
      created() {
        if (!localStorage.getItem('csrfToken')) {
          fetch('backend/csrf_token.php')
            .then((response) => response.json())
            .then((data) => {
              localStorage.setItem('csrfToken', data.csrf_token);
            })
            .catch(() => alert('CSRFトークンの取得に失敗しました。'));
        }
      },
    };

    const CompleteForm = {
      template: `
        <div>
          <h1 class="text-2xl font-bold mb-4">送信が完了しました</h1>
          <p>お問い合わせいただきありがとうございます。</p>
          <button @click="goToInput" class="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded hover:bg-indigo-700">フォームに戻る</button>
        </div>
      `,
      methods: {
        goToInput() {
          this.$router.push({ name: 'input' });
        },
      },
    };

    const routes = [
      { path: '/', name: 'input', component: InputForm },
      { path: '/confirm', name: 'confirm', component: ConfirmForm },
      { path: '/complete', name: 'complete', component: CompleteForm },
    ];

    const router = createRouter({ history: createWebHistory(), routes });

    const app = createApp({});
    app.use(router);
    app.mount('#app');
  </script>
</body>
</html>

2. CSRFトークン生成(csrf_token.php)

このスクリプトはCSRFトークンを生成し、JSON形式でクライアントに返します。

コード

<?php
session_start();
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes

(32));
}
header('Content-Type: application/json');
echo json_encode(['csrf_token' => $_SESSION['csrf_token']]);

解説

  1. トークン生成
    PHPのrandom_bytes()を使用して32バイトのランダムデータを生成。
  2. セッション保存
    CSRFトークンをセッションに保存し、後で検証に使用。
  3. JSONレスポンス
    クライアントがトークンを利用できるようJSON形式で返却。

3. フォームデータ処理(contact.php)

クライアントから送信されたフォームデータ、CSRFトークン、reCAPTCHAトークンを検証し、安全にメールを送信します。

コード

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

$data = json_decode(file_get_contents('php://input'), true);

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

$recaptcha_url = 'https://www.google.com/recaptcha/api/siteverify';
$recaptcha_secret = 'your-secret-key';
$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お名前: $name\nメールアドレス: $email\nお問い合わせ内容:\n$message\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. CSRFトークンの検証
    送信されたトークンとセッション内のトークンを比較。
  2. reCAPTCHAの検証
    Google APIを利用してリクエストの正当性を確認。
  3. 入力データのサニタイズとバリデーション
    XSS攻撃や無効なデータを防ぎます。
  4. メール送信
    管理者とユーザー双方に通知メールを送信。

4. 実行と確認

動作確認の手順

  1. トークン確認
    ブラウザのコンソールでlocalStorage.getItem('csrfToken')を実行し、トークンが取得されているか確認。
  2. ネットワーク確認
    開発者ツールの「ネットワーク」タブで送信されるリクエストを確認。
  3. サーバー確認
    トークンやフォームデータが正しく処理されているか、サーバーログを確認。

コメント

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