SessionとCookieの学習がてらCSRF対策を手動でやってみた
概要
フロントエンド : 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を扱うために以下の設定が必要
# 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環境構築
- 以下のコマンドでNext.jsのアプリケーションを構築
npx create-next-app@latest hogehoge --ts
-
global.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;
}
- 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。
CSRF対策
CSRF(クロスサイトリクエストフォージェリ)とは
IPA独立行政法人情報処理推進機構- 安全なウェブサイトの作り方 - 1.6 CSRF(クロスサイト・リクエスト・フォージェリ)
ログイン認証の仕組みとして、多くのサービスでは、ブラウザのCookieにtokenをセットしているものとして、CSRFは以下のような流れで起こってしまいます。
-
tokenをセットした状態で、リクエストが来たときに、サーバー側はこのtokenを見て「ログイン済みのAさんか」と判断している。
-
例えば掲示板への書き込みを行うときに、POSTメソッドでサーバーにリクエストを送る。
-
これが正規の掲示板から送られるなら問題ないが、リンクを踏ませてPOSTすることもできてしまう。
-
サーバー側からは、正規の書き込みなのか、第3者が用意したリンクを踏んだことで送られた書き込みなのかは識別できないので、Aさんが書き込んだことになってしまう。
この手口で起こった事件として、横浜CSRF事件が有名です。
犯行予告を行ったとして、書き込んでいない人が誤認逮捕されました。
CSRF対策方法
解決方法として、POSTリクエストが正規のものかどうかを、区別できればいいわけです。
そしてそれは、以下の流れで行えば実現しそうです。
- POST送信する前に、正規の掲示板からサーバーにリクエストを送りtokenを発行しておく。
- このtokenをPOST送信時に確認する。
- 確認ができたときだけ、POSTリクエストを受け付ける。
このときのtokenを、CSRFトークンと呼んだりします。
Railsの設定
CSRFの検証をする設定
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をリクエストに含めることを許可します。
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
def set_csrf_token
render json: { authenticity_token: form_authenticity_token }, status: :ok
end
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トークンが発行されます。
その他コントローラーなど
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
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
class User < ApplicationRecord
has_secure_password
end
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トークンを取得
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。
細かく言えば、CSRFトークンはSessionに保存されています。しかしRailsはSessionをブラウザのCookieにデフォルトで保存するようになっていますので、このように_session_idという名前のCookieが保存されるのです。
CSRFトークンは暗号化されて保存されているので、値だけを見ても分かりませんが、Railsに渡したときに復号化してくれるので問題ありません。
その他関数を定義
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を見ながら、いろいろ試してみれば理解できると思います。
- CSRFトークンを発行せずに、Cookieをセット、会員登録、ログインはできないが、セッションを取得ボタンは押せる(GETメソッドなので)
- CSRFトークンを発行することで、_session_idというCookieがセットされる
- トークンを発行した状態であれば、全てのリクエストができる
セッション固定化攻撃対策
ついでなので、セッション固定化攻撃対策もしておきます。
セッション固定化攻撃とは、
-
session_idを攻撃者が発行して、javascriptを使って
<script>document.cookie="_session_id=16d5b78abb28e3d6206b60f22a03c8d9";</script>
のようにターゲットのブラウザに仕込む。 -
この状態でターゲットがログインすると、そのsession_idはログイン済みということになるため、攻撃者はログインに必要な情報を得ることなく、アカウントを乗っ取ることができてしまう。
といった具合です。これはログインしたときに、session_idを新しく振り直すことで防ぐことができます。
そのためには、認証後にセッションをリセットする処理を入れます。
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を送る設定が必要
Discussion