🍪

SessionとCookieの学習がてらCSRF対策を手動でやってみた

2024/05/23に公開

概要

フロントエンド : Next.js
バックエンド : Ruby on Rails

この組み合わせで、CookieをRailsで発行して、それをNext.js側で扱うというイメージが全く沸きませんでした。

学習のために、実際にいろいろイジってみて、ようやく理解できたので共有します。

本記事を読むことで

  • Next.js <=> Rails間でCookieをやり取りの方法がわかる
  • RailsでCookie、Sessionを扱う方法がわかる
  • CSRF対策について理解できる
  • セッション固定化攻撃について理解できる

Rails環境構築

以下のコマンドでapiモードでRailsアプリケーションを構築

rails new hogehoge --api
rails db:create
rails g model User name:string email:string password:string password_digest:string
rails db:migrate

Next.jsのポートと競合してしまうので、3001で起動することとする。

rails s -p 3001

Cookie、Sessionを扱うために以下の設定が必要

config/application.rb
    # Cookieを使う
    # CSRFトークンを使用するのにも必要 form_authenticity_tokenメソッドを使用する場合
    # → _csrf_tokenというkeyでsessionに保存している
    config.middleware.use ActionDispatch::Cookies
    
    # セッションを使う
    # Sessionに値をセットしようとすると、以下が発生
    # ActionDispatch::Request::Session::DisabledSessionError: Your application has sessions disabled. To write to the session you must first configure a session store
    config.middleware.use ActionDispatch::Session::CookieStore

    # フロントとバックエンドでドメイン名が異なる場合(本番環境のため)
    # https://www.hogehoge.com と https://api.hogehoge.com のような場合
    # デフォルトではlaxになっている → GETメソッドは許可して、POSTメソッドではCookieを送れない
    # :noneではないことに注意
    config.action_dispatch.cookies_same_site_protection = nil

Next.js環境構築

  1. 以下のコマンドでNext.jsのアプリケーションを構築
npx create-next-app@latest hogehoge --ts
  1. global.cssの中身を削除

  2. page.module.cssの中身を以下に書き換え

page.module.css
page.module.css
   .main {
     padding: 50px;
   }

   .container {
     display: flex;
     flex-direction: column;
     align-items: center;
     justify-content: center;
     background: #ddd;
     margin-bottom: 30px;
     padding: 50px;
     border-radius: 10px;
   }

   .title {
     text-align: center;
     color: #141414;
     margin: 0;
     margin-bottom: 30px;
   }

   .buttons {
     width: 100%;
     display: flex;
     justify-content: space-around;
     align-items: center;
   }

   .button {
     background-color: #141414;
     border: 1px solid rgba(54, 54, 54, 0.6);
     font-weight: 600;
     position: relative;
     outline: none;
     border-radius: 10px;
     display: flex;
     justify-content: center;
     align-items: center;
     cursor: pointer;
     opacity: 1;
     color: #fff;
     padding: 10px;
   }

   .label {
     margin-bottom: 5px;
   }

   .input {
     font-size: 1em;
     padding: 5px;
     border: 1px solid #cecece;
     border-radius: 5px;
     box-sizing: border-box;
     margin-bottom: 10px;
   }

   .userContainer {
     display: flex;
     flex-direction: column;
     align-items: center;
     justify-content: center;
   }

   .user {
     font-size: 20px;
     font-weight: bold;
     margin-bottom: 20px;
   }

   .notLogin {
     font-size: 20px;
     font-weight: bold;
   }
  1. page.tsxを書き換え
page.tsx
page.tsx
"use client";

import styles from "./page.module.css";
import { useState, useEffect, FormEvent, useRef } from "react";

type User = {
  name: string;
  email: string;
};
export default function Home() {
  const [csrfToken, setCsrfToken] = useState<string | null>(null);
  const [user, setUser] = useState<User | null>(null);
  const signupFormRef = useRef<HTMLFormElement>(null);
  const loginFormRef = useRef<HTMLFormElement>(null);

  const getCsrfToken = async () => {
  
  };

  const setSession = async () => {

  };
  const getSession = async () => {

  };

  const getUser = async () => {

  };

  const signup = async (formData: Iterable<readonly [PropertyKey, any]>) => {

  };

  const login = async (formData: Iterable<readonly [PropertyKey, any]>) => {

  };

  const logout = async () => {

  };

  async function handleSignup(e: FormEvent<HTMLFormElement>) {

  }

  async function handleLogin(e: FormEvent<HTMLFormElement>) {

  }

  return (
    <main className={styles.main}>
      <div className={styles.container}>
        <h2 className={styles.title}>Session</h2>
        <div className={styles.buttons}>
          <button className={styles.button} onClick={() => getCsrfToken()}>
            CSRFトークン発行
          </button>
          <button className={styles.button} onClick={() => setSession()}>
            セッションをセット
          </button>
          <button className={styles.button} onClick={() => getSession()}>
            セッションを取得
          </button>
        </div>
      </div>

      <form
        className={styles.container}
        onSubmit={handleSignup}
        ref={signupFormRef}
      >
        <h2 className={styles.title}>Sign Up</h2>
        <label className={styles.label}>Name:</label>
        <input
          className={styles.input}
          type="text"
          name="name"
          autoComplete="name"
        />
        <label className={styles.label}>Email:</label>
        <input
          className={styles.input}
          type="email"
          name="email"
          autoComplete="email"
        />
        <label className={styles.label}>Password:</label>
        <input className={styles.input} type="password" name="password" />
        <label className={styles.label}>Confirm Password:</label>
        <input
          className={styles.input}
          type="password"
          name="password_confirmation"
        />
        <button className={styles.button} type="submit">
          Submit
        </button>
      </form>
      <form
        className={styles.container}
        onSubmit={handleLogin}
        ref={loginFormRef}
      >
        <h2 className={styles.title}>Login</h2>
        <label className={styles.label}>Email:</label>
        <input
          className={styles.input}
          type="email"
          name="email"
          autoComplete="email"
        />
        <label className={styles.label}>Password:</label>
        <input className={styles.input} type="password" name="password" />
        <button className={styles.button} type="submit">
          Submit
        </button>
      </form>
      <div className={styles.container}>
        <h2 className={styles.title}>ユーザー情報</h2>
        {user ? (
          <div className={styles.userContainer}>
            <label className={styles.label}>Name:</label>
            <div className={styles.user}>{user.name}</div>
            <label className={styles.label}>Email:</label>
            <div className={styles.user}>{user.email}</div>
          </div>
        ) : (
          <div className={styles.notLogin}>ログインしてください</div>
        )}
        {user ? (
          <button className={styles.button} onClick={() => logout()}>
            Logout
          </button>
        ) : null}
      </div>
    </main>
  );
}

以下のコマンドでアプリを立ち上げる。

npm run dev

デフォルトの設定のままなら、ポート3000で起動するので、http://localhost:3000にアクセスして、以下の画面が表示されていればOK。

cookie_practice

CSRF対策

CSRF(クロスサイトリクエストフォージェリ)とは

安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ)
IPA独立行政法人情報処理推進機構- 安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ)

ログイン認証の仕組みとして、多くのサービスでは、ブラウザのCookieにtokenをセットしているものとして、CSRFは以下のような流れで起こってしまいます。

  1. tokenをセットした状態で、リクエストが来たときに、サーバー側はこのtokenを見て「ログイン済みのAさんか」と判断している。

  2. 例えば掲示板への書き込みを行うときに、POSTメソッドでサーバーにリクエストを送る。

  3. これが正規の掲示板から送られるなら問題ないが、リンクを踏ませてPOSTすることもできてしまう。

  4. サーバー側からは、正規の書き込みなのか、第3者が用意したリンクを踏んだことで送られた書き込みなのかは識別できないので、Aさんが書き込んだことになってしまう。

この手口で起こった事件として、横浜CSRF事件が有名です。
犯行予告を行ったとして、書き込んでいない人が誤認逮捕されました。

CSRF対策方法

解決方法として、POSTリクエストが正規のものかどうかを、区別できればいいわけです。

そしてそれは、以下の流れで行えば実現しそうです。

  1. POST送信する前に、正規の掲示板からサーバーにリクエストを送りtokenを発行しておく。
  2. このtokenをPOST送信時に確認する。
  3. 確認ができたときだけ、POSTリクエストを受け付ける。

このときのtokenを、CSRFトークンと呼んだりします。

Railsの設定

CSRFの検証をする設定

application_controller.rbに以下を記述

app/controllers/application_controller.rb
class ApplicationController < ActionController::API

  # CSRF対策をするため
  include ActionController::RequestForgeryProtection
  protect_from_forgery with: :exception

end

これを書いておくと、GET以外のリクエストが来たときに、CSRFトークンを検証するようになります。
検証に失敗すると、ActionController::InvalidAuthenticityTokenの例外が発生するようになります。

corsを設定

csrfトークンはcookieに保存されます。これをRails側に送るために、明示的に設定が必要です。cors.rbファイルにcredentials: trueとすることで、Cookieをリクエストに含めることを許可します。

config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:3000'
    resource '/api/v1/*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ['X-CSRF-Token'],
      credentials: true
  end
end

コントローラーの設定

sessionsコントローラーを作成

rails g controller API::V1::Sessions
app/controllers/api/v1/sessions_controller.rb
  def set_csrf_token
    render json: { authenticity_token: form_authenticity_token }, status: :ok
  end
config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      get "csrf", to: "sessions#set_csrf_token"
    end
  end
end

これで、http://localhost:3001にGETリクエストを送ることでCSRFトークンが発行されます。

その他コントローラーなど
app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController

  def set_csrf_token
    render json: { authenticity_token: form_authenticity_token }, status: :ok
  end

  def set_session
    session[:test] = "test_session"
    cookies[:test] = {
      value: "test_cookie",
      same_site: :none,
      secure: true,
      http_only: true,
    }
    render json: {session: session, cookie: cookies}, status: :ok
  end
  
  def get_session
    render json: {session: session, cookies: cookies}, status: :ok
  end

  def login
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
      session[:user_id] = @user.id
      render json: {user: @user}, status: :ok
    else
      render json: {error: @user.error}, status: :unprocessable_entity
    end
  end

  def logout
    reset_session
    render json: {}, status: :ok
  end

end

rails g controller API::V1::Users
app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      session[:user_id] = @user.id
      render json: {user: @user}, status: :ok
    else
      render json: {error: @user.error}, status: :unprocessable_entity
    end
  end

  def current_user
    user_id = session[:user_id]
    @user = User.find_by(id: user_id)
    if @user
      render json: {user: @user}, status: :ok
    else
      render json: {user: nil}, status: :ok
    end
  end

  private
  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end
app/models/user.rb
class User < ApplicationRecord
  has_secure_password
end
config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      get "csrf", to: "sessions#set_csrf_token"
      post "session", to: "sessions#set_session"
      get "session", to: "sessions#get_session"
      post "signup", to: "users#create"
      post "login", to: "sessions#login"
      delete "logout", to: "sessions#logout"
      get "user", to: "users#current_user"
      post "cookie", to: "sessions#post_cookie"
    end
  end
end

Next.jsの設定

CSRFトークンを取得

page.tsx
  const getCsrfToken = async () => {
    const res = await fetch("http://localhost:3001/api/v1/csrf", {
      // sessionを使うリクエストに必要
      credentials: "include", // CSRFトークンをsessionに入れるために必要
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const data = await res.json();
    console.log(data.authenticity_token);
    setCsrfToken(data.authenticity_token);
  };

これで、CSRFトークン発行ボタンを押すとCSRFトークンが発行されます。どこにあるかというと、ブラウザのCookieに保存されています。

chromeであれば、右クリック→検証→アプリケーション→Cookie→localhost:3000で、以下のように_session_idがセットされていればOK。

cookie

細かく言えば、CSRFトークンはSessionに保存されています。しかしRailsはSessionをブラウザのCookieにデフォルトで保存するようになっていますので、このように_session_idという名前のCookieが保存されるのです。

CSRFトークンは暗号化されて保存されているので、値だけを見ても分かりませんが、Railsに渡したときに復号化してくれるので問題ありません。

その他関数を定義

page.tsx
useEffect(() => {
    const handleGetUser = async () => {
      await getUser();
    };
    try {
      handleGetUser();
    } catch (error) {
      console.error(error);
    }
  }, []);

  const getCsrfToken = async () => {
    const res = await fetch("http://localhost:3002/api/v1/csrf", {
      // sessionを使うリクエストに必要
      credentials: "include", // CSRFトークンをsessionに入れるために必要
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const data = await res.json();
    console.log(data.authenticity_token);
    setCsrfToken(data.authenticity_token);
  };

  const setSession = async () => {
    const res = await fetch("http://localhost:3002/api/v1/session", {
      credentials: "include", // CSRFトークンを渡すために必要
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": csrfToken as string,
      },
    });
    const data = await res.json();
    console.log(data);
  };
  const getSession = async () => {
    const res = await fetch("http://localhost:3002/api/v1/session", {
      // sessionやcookieをセットするような処理なら必要だがGETメソッドなので基本不要
      credentials: "include",
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const data = await res.json();
    console.log(data);
  };

  const getUser = async () => {
    const res = await fetch("http://localhost:3002/api/v1/user", {
      credentials: "include",
      method: "GET",
      headers: {
        "Content-Type": "application/json",
      },
    });
    const data = await res.json();
    setUser(data.user);
  };

  const signup = async (formData: Iterable<readonly [PropertyKey, any]>) => {
    try {
      const res = await fetch("http://localhost:3002/api/v1/signup", {
        credentials: "include",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": csrfToken as string,
        },
        body: JSON.stringify({ user: Object.fromEntries(formData) }),
      });
      const data = await res.json();
      setUser(data.user);
    } catch (error) {
      console.error(error);
    }
  };

  const login = async (formData: Iterable<readonly [PropertyKey, any]>) => {
    try {
      const res = await fetch("http://localhost:3002/api/v1/login", {
        credentials: "include",
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": csrfToken as string,
        },
        body: JSON.stringify({ session: Object.fromEntries(formData) }),
      });
      const data = await res.json();
      setUser(data.user);
    } catch (error) {
      console.error(error);
    }
  };

  const logout = async () => {
    try {
      const res = await fetch("http://localhost:3002/api/v1/logout", {
        credentials: "include",
        method: "DELETE",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": csrfToken as string,
        },
      });
      const data = await res.json();
      if (res.ok) {
        setUser(null);
      }
    } catch (error) {
      console.error(error);
    }
  };

  async function handleSignup(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await signup(formData);
    if (signupFormRef.current) {
      signupFormRef.current.reset();
    }
  }

  async function handleLogin(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await login(formData);
    if (loginFormRef.current) {
      loginFormRef.current.reset();
    }
  }

これで、ブラウザのコンソールや、Cookieを見ながら、いろいろ試してみれば理解できると思います。

  1. CSRFトークンを発行せずに、Cookieをセット、会員登録、ログインはできないが、セッションを取得ボタンは押せる(GETメソッドなので)
  2. CSRFトークンを発行することで、_session_idというCookieがセットされる
  3. トークンを発行した状態であれば、全てのリクエストができる

セッション固定化攻撃対策

ついでなので、セッション固定化攻撃対策もしておきます。

セッション固定化攻撃とは、

  1. session_idを攻撃者が発行して、javascriptを使って<script>document.cookie="_session_id=16d5b78abb28e3d6206b60f22a03c8d9";</script>のようにターゲットのブラウザに仕込む。

  2. この状態でターゲットがログインすると、そのsession_idはログイン済みということになるため、攻撃者はログインに必要な情報を得ることなく、アカウントを乗っ取ることができてしまう。

といった具合です。これはログインしたときに、session_idを新しく振り直すことで防ぐことができます。

そのためには、認証後にセッションをリセットする処理を入れます。

app/controllers/api/v1/sessions_controller.rb
  def login
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
+     reset_session
      session[:user_id] = @user.id
      render json: {user: @user}, status: :ok
    else
      render json: {error: @user.error}, status: :unprocessable_entity
    end
  end

この1行だけで、セッション固定化攻撃の対策になります。

まとめ

  • RailsではSessionとCookieを扱うために設定が必要
  • RailsでCookieを受け取る設定、Next.jsでCookieを送る設定が必要

今回のソースコード

参考文献

GitHubで編集を提案

Discussion