Skip to main content

第4章: 認証機能(Cookieベース)

🎯 この章で学ぶこと(所要時間: 4時間)

第3章で作成したCRUD APIに、セキュアな認証機能を追加します。Railsの標準的なCookieベースの認証を実装し、実践的なセキュリティ対策も学びます。

📢 はじめに

第3章で作成したTodo APIには重大なセキュリティ問題があります。user_idをパラメータで指定できるため、誰でも他人のTodoを操作できてしまいます。

この章では、本格的な認証機能を実装して、この問題を解決します。ログインしたユーザーだけが自分のTodoを操作できるようになります。

今回はCookieベース認証を採用します。Railsの標準機能を活用でき、実装がシンプルだからです。

💡 認証方式について

認証には他にもJWT(JSON Web Token)を使う方法もあります。JWTはトークンベースの認証で、API専用のアプリケーションでよく使われますが、今回はRailsの標準的なセッション機能を学ぶため、Cookieベース認証を採用します。

🎓 この章を終えると...

  • 認証と認可の違いが理解できる
  • Cookieベースの認証が実装できる
  • セッション管理の仕組みがわかる
  • CSRF対策について理解できる
  • 実践的なセキュリティ対策が身につく

💡 この章で身につくスキル

  • 🔐 has_secure_passwordによるパスワード管理
  • 🍪 Cookieとセッションの仕組み
  • 🛡️ CSRF対策の実装
  • 🔑 ログイン/ログアウト機能
  • 👤 現在のユーザー管理
  • ✅ 認証が必要なエンドポイントの保護
📖 この章の進め方
  1. セクション0: 作業ブランチの作成(実践)
  2. セクション1: 認証の基礎知識(読むだけ)
  3. セクション2〜3: 認証機能の実装(実践)
  4. セクション4: テストの更新(実践)
  5. セクション5: CSRF対策(実践)
  6. セクション6: 動作確認(実践)
📓 前提条件
  • 第3章でUser・Todoモデルが作成済みであること
  • seed_fuでテストデータが投入済みであること
  • RSpecのテストが通っていること
  • Dockerコンテナが正常に動作していること

それでは、アプリケーションをセキュアにしていきます!

📚 0. 作業ブランチの作成

新しい機能開発なので、新しいブランチを作成します。

🔨 実際にやってみましょう!

ターミナルで実行
# 現在のブランチを確認
git branch

# mainブランチに切り替え
git checkout main

# 最新の状態に更新
git pull origin main

# feature/chapter-4ブランチを作成して切り替え
git checkout -b feature/chapter-4

# ブランチが切り替わったか確認
git branch

1. 認証の基礎知識

開発を始める前に、重要な概念を理解します。

🔑 認証(Authentication)と認可(Authorization)

Webアプリケーションのセキュリティを実装する上で、認証認可は最も重要な概念です。この2つは密接に関連していますが、異なる役割を持っています。

認証(Authentication)- 「あなたは誰ですか?」

認証とは、ユーザーの身元を確認するプロセスです。システムに対して「私は〇〇です」と主張するユーザーが、本当にその人物であることを確認します。

認証の仕組み:

  1. 認証情報の提示: ユーザーがメールアドレスとパスワードを入力
  2. 検証: システムが保存されている情報と照合
  3. 結果: 一致すれば本人と認め、セッションを開始

認証が必要な理由:

  • なりすまし防止: 他人になりすましてシステムを利用することを防ぐ
  • アカウント保護: 個人情報や重要なデータを不正アクセスから守る
  • 監査証跡: 誰がいつシステムを利用したかを記録できる

認可(Authorization)- 「何ができますか?」

認可とは、認証されたユーザーに対して、どのリソースへのアクセスやどの操作を許可するかを決定するプロセスです。

認可の仕組み:

  1. リクエスト受信: 認証済みユーザーからのアクション要求
  2. 権限チェック: そのユーザーに該当アクションの権限があるか確認
  3. アクセス制御: 権限があれば許可、なければ拒否

認可が必要な理由:

  • データの分離: ユーザーは自分のデータのみアクセス可能
  • 機能の制限: 管理者機能は管理者のみが使用可能
  • 最小権限の原則: 必要最小限の権限のみを付与

実装における認証と認可の流れ

実際のコードでの実装例:

  • 認証: SessionsControllercreateアクションでユーザーを検証
  • 認可: TodosControllercurrent_user.todosを使用して自分のデータのみアクセス
💡 認証と認可の混同について

認証と認可は混同されがちですが、別の概念です。ただし、実際の開発では以下のような扱いになることが多いです:

シンプルなアプリケーションの場合

  • 「ログインしているか」だけを確認すれば十分
  • 認証と認可をまとめて考えても大きな問題にならない
  • 今回のTodoアプリのように「自分のデータのみアクセス可能」という単純なルール

複雑なアプリケーションの場合

  • 管理者、一般ユーザー、ゲストなどの役割(ロール)がある
  • 各機能に対して細かい権限設定が必要
  • 認証と認可を明確に分離して設計する必要がある

例:ECサイトの場合

  • 認証: ログインしているかどうか
  • 認可: 商品閲覧(全員OK)、購入(会員のみ)、在庫管理(管理者のみ)

このように、認証と認可を適切に実装することで、セキュアなWebアプリケーションを構築できます。

🍪 セッションとCookieの仕組み

Webアプリケーションで「ログイン状態を維持」する仕組みを理解するためには、まずHTTPの基本的な性質を知る必要があります。

HTTPのステートレス性

HTTPプロトコルはステートレス(状態を持たない)という性質があります。これは以下を意味します:

  • 各リクエストは独立: サーバーは前回のリクエストを覚えていない
  • 毎回認証が必要: 本来なら、すべてのリクエストで認証情報を送る必要がある
  • 状態管理の工夫が必要: ログイン状態を維持するには特別な仕組みが必要

このように、ステートレスな通信では毎回認証情報を送る必要があり、非効率的です。

セッションとCookieによる解決

この問題を解決するのがセッションCookieの仕組みです。

🏨 ホテルの例で理解する:

  • チェックイン = ログイン(最初の認証)
  • ルームキー = Cookie(セッションIDが書かれている)
  • 部屋 = セッション(サーバー側でユーザー情報を保管)
  • チェックアウト = ログアウト(セッション破棄)

技術的な仕組み

  1. セッション(Session)

    • サーバー側でユーザーの状態を保存する仕組み
    • 各ユーザーに一意のセッションIDを発行
    • セッションIDに紐づけてユーザー情報を保存
  2. Cookie

    • ブラウザがリクエストごとに自動的に送信する小さなデータ
    • セッションIDをCookieに保存することで、毎回のリクエストで自動送信
    • サーバーはCookieのセッションIDからユーザーを特定
  3. 動作の流れ

    1. ユーザーがログイン(認証情報を送信)
    2. サーバーが認証し、セッションを作成
    3. セッションIDをCookieとしてブラウザに送信
    4. 以降のリクエストでブラウザが自動的にCookieを送信
    5. サーバーはCookieからユーザーを特定

この仕組みにより、HTTPのステートレス性を保ちながら、効率的に状態管理ができるようになります。

🔄 実際の認証フロー

🍪 Cookieの重要な設定

Cookieを使った認証では、セキュリティ設定が極めて重要です。適切に設定しないと、様々な攻撃の対象となる可能性があります。

設定説明セキュリティ効果
httponlyJavaScriptから読み取り不可XSS攻撃を防ぐ
secureHTTPS通信でのみ送信盗聴を防ぐ
same_site同一サイトからのみ送信CSRF攻撃を防ぐ

各設定の詳細説明

1. httponly属性

  • ブラウザのJavaScriptからdocument.cookieでアクセスできなくなる
  • XSS(クロスサイトスクリプティング)攻撃でCookieが盗まれるリスクを大幅に軽減
  • サーバー側のみでCookieを扱う場合は必ず設定すべき

2. secure属性

  • HTTPS接続でのみCookieが送信される
  • HTTP通信では送信されないため、ネットワーク上での盗聴を防ぐ
  • 本番環境では必須の設定(開発環境では無効にすることも)

3. same_site属性

  • 他のサイトからのリクエストでCookieを送信するかを制御
  • 値:strict(厳格)、lax(緩い)、none(制限なし)
  • CSRF攻撃の防御に効果的(今回はlaxを使用)
⚠️ セキュリティの重要性

これらの設定を適切に行わないと、以下のような攻撃を受ける可能性があります:

  • XSS攻撃: 悪意のあるJavaScriptがCookieを盗む
  • 中間者攻撃: 通信を傍受してCookieを盗む
  • CSRF攻撃: 他サイトから勝手にリクエストを送信される

Railsではこれらの設定を簡単に行えるので、必ず適切に設定しましょう。

2. 認証機能の実装

概念を理解したところで、実際にコードを書いていきます。

🎯 これから実装する機能の全体像

これから、以下の4つのステップで認証機能を実装します:

  1. has_secure_passwordの仕組みを理解

    • 第3章で追加した機能の動作原理を詳しく理解
    • パスワードの安全な保存方法を学習
  2. SessionsControllerの作成

    • ログイン/ログアウトを処理するコントローラー
    • /login/logout/meの3つのエンドポイントを実装
  3. ApplicationControllerの更新

    • すべてのAPIで共通して使う認証機能を実装
    • current_userメソッドで現在のユーザーを取得
    • 認証が必要なアクションの保護
  4. セッション設定の追加

    • Rails APIモードでCookieとセッションを有効化
    • セキュアなCookie設定を実装

🔄 実装の流れ

これらの実装により、第3章の「user_idパラメータ問題」を解決し、セキュアなAPIを構築します。

🔐 has_secure_passwordの仕組み

第3章でUserモデルにhas_secure_passwordを追加しましたが、これがどのように動作するか詳しく理解します。

password_digestカラムの役割

第3章で作成したpassword_digestカラムは、パスワードを安全に保存するための重要な仕組みです:

  • 平文パスワードは保存しない: セキュリティの基本原則
  • ハッシュ値を保存: 一方向変換されたデータを保存
  • bcryptアルゴリズム使用: 業界標準の安全なハッシュ関数

一方向ハッシュによるセキュリティ

パスワード入力からpassword_digestへの変換:

ユーザーがフォームに入力するパスワードは、画面上では **** のように表示されますが、サーバーには平文で送信されます。このパスワードを安全に保存するため、以下の処理を行います:

入力: password: **** (実際は "password")
↓ bcryptでハッシュ化
生成: password_digest: "$2a$12$K0ByB.8n..."(60文字の文字列)
↓ データベースに保存
保存: usersテーブルのpassword_digestカラムに格納

🔒 パスワードのハッシュ化の流れ:

一方向ハッシュの重要性:

  1. 復号化が困難

    • ハッシュ値から元のパスワードを復元することは計算的に不可能
    • 総当たり攻撃(ブルートフォース)にも時間がかかるよう設計
  2. 同じ入力は同じ出力

    • 同じパスワードは常に同じハッシュ値になる
    • だからこそログイン時の照合が可能
  3. わずかな違いで全く異なる出力

    • "password" と "password124" のハッシュ値は全く異なる
    • 類推攻撃を防ぐ
🧮 暗号理論の裏側

bcryptなどのハッシュ関数の安全性は、高度な数学に支えられています:

  • 一方向関数: 計算は簡単だが逆計算が困難な数学的性質を利用
  • 素因数分解問題: 大きな数の素因数分解の困難性
  • 離散対数問題: 暗号理論の基礎となる数学的問題

これらの数学的基盤により、現在のコンピュータでは現実的な時間内でハッシュを破ることができません。

has_secure_passwordが提供する機能:

  1. パスワードのハッシュ化: bcryptで安全に暗号化してpassword_digestに保存
  2. 認証メソッド: authenticateメソッドを追加(タイミング攻撃に強い実装)
  3. 自動バリデーション: パスワードの必須チェックと確認用パスワードの照合

🛡️ ApplicationControllerの更新

アプリケーション全体で使う認証機能の共通処理を実装します。ApplicationControllerはすべてのコントローラーの基底クラスなので、ここに実装することで全APIで認証機能が使えるようになります。

🔨 実際にやってみましょう!

app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# Cookieを使うためのモジュールを追加
include ActionController::Cookies

# 全アクションで認証を要求
before_action :require_authentication

# ヘルスチェックエンドポイント(第2章で実装済み)
def health
render json: { status: 'ok' }
end

private

# 現在ログイン中のユーザーを取得
def current_user
# メモ化:@current_userがすでにあればDBアクセスしない
# session[:user_id]は後述のSessionsControllerでログイン時に設定される
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end

# 認証が必要なアクションの前に実行
def require_authentication
unless current_user
render json: { error: '認証が必要です' }, status: :unauthorized
end
end
end
💡 メモ化パターンの解説
@current_user ||= User.find_by(id: session[:user_id])

このコードで使われている||=は、Rubyのメモ化パターンです:

  • ||=は「すでに値があればそれを使い、なければ右辺を実行」という意味
  • 1リクエスト中に何度current_userを呼んでも、DBアクセスは最初の1回だけ
  • パフォーマンスの向上とDBの負荷軽減に貢献
  • インスタンス変数@current_userにキャッシュすることで実現

🔐 セッションコントローラーの作成

ログイン・ログアウトを管理するコントローラーを作成します。SessionsControllerは認証機能の中核となり、以下の3つのアクションを提供します:

  • createアクション: メールとパスワードを受け取り、認証してセッションを作成
  • destroyアクション: セッションを破棄してCookieをクリア
  • showアクション: 現在ログイン中のユーザー情報を返す

🔨 実際にやってみましょう!

セッションコントローラーを生成:

ターミナルで実行
docker compose exec web rails generate controller api/v1/sessions

生成されたファイルを編集します:

app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < ApplicationController
# ログイン時はまだ認証されていないため、createアクションは認証をスキップ
skip_before_action :require_authentication, only: [:create]

# POST /api/v1/login
def create
user = User.find_by(email: params[:email]&.downcase)

if user&.authenticate(params[:password])
session[:user_id] = user.id
render json: {
user: {
id: user.id,
email: user.email,
name: user.name
}
}, status: :ok
else
render json: { error: 'メールアドレスまたはパスワードが正しくありません' },
status: :unauthorized
end
end

# DELETE /api/v1/logout
def destroy
reset_session
render json: { message: 'ログアウトしました' }, status: :ok
end

# GET /api/v1/me
def show
render json: {
user: {
id: current_user.id,
email: current_user.email,
name: current_user.name
}
}, status: :ok
end
end
end
end

Railsのsessionについて

先ほど「HTTPのステートレス性」のセクションで、セッションとCookieの仕組みについて学びました。ホテルの例で言えば:

  • ルームキー(Cookie)にはセッションIDが書かれている
  • ホテルの部屋(セッション)にはユーザーの情報が保管されている

Railsのsessionは、まさにこの「ホテルの部屋」に相当する機能を提供する特殊な変数です。

HTTPのステートレス性を克服する仕組み:

ステートレスなHTTP → セッション/Cookieで状態を保持 → Railsのsession変数で実装

上記のコードで使われているsession[:user_id]は、以下のような流れで動作します:

  1. ログイン時: session[:user_id] = user.id

    • サーバー側の「部屋」にユーザーIDを保存
    • ブラウザに「ルームキー」(セッションIDを含むCookie)を発行
  2. 次のリクエスト時: session[:user_id]で取得

    • ブラウザが自動的に「ルームキー」を提示
    • サーバーが対応する「部屋」からユーザーIDを取得

これにより、ステートレスなHTTPでも「ログイン状態」を維持できるのです。

セッションの基本的な使い方:

# セッションに値を保存
session[:user_id] = user.id # ログイン時にユーザーIDを保存

# セッションから値を取得
current_user_id = session[:user_id] # 保存されたユーザーIDを取得

# セッションから値を削除
session.delete(:user_id) # ログアウト時に削除

セッションの仕組み:

1. ログインリクエスト
ブラウザ → Rails

2. ユーザー認証
Rails → データベース → Rails

3. セッションにユーザーID保存
session[:user_id] = user.id

4. 暗号化されたCookie送信
Rails → ブラウザ

5. 次のリクエスト(Cookieを自動送信)
ブラウザ → Rails

6. Cookieを復号化してセッション取得
session[:user_id]でユーザーIDを取得

7. ユーザー情報を取得
User.find(session[:user_id])

セッションの特徴:

  1. ハッシュのようなインターフェース

    # 複数の値を保存可能
    session[:user_id] = 1
    session[:last_access] = Time.current
    session[:preferences] = { theme: 'dark' }
  2. 暗号化による安全性

    • Railsは自動的にセッションデータを暗号化
    • クライアント側では内容を読めない
    • 改ざんも検知できる
  3. Cookieベースの永続化

    • デフォルトでは_アプリ名_sessionという名前のCookieに保存
    • ブラウザが自動的にリクエストごとに送信
    • サーバー側でファイルやDBを使わない(デフォルト設定)
  4. 有効期限

    • デフォルトではブラウザを閉じるまで(セッションCookie)
    • 設定により有効期限を延長可能

なぜsession[:user_id]を使うのか:

# ❌ 悪い例:毎回ユーザーIDをパラメータで送る
def show
user = User.find(params[:user_id]) # 改ざん可能で危険
end

# ✅ 良い例:セッションから取得
def show
user = User.find(session[:user_id]) # サーバー側で管理されて安全
end

セッションを使うことで、ユーザーは一度ログインすれば、その後のリクエストで自動的に認証情報が引き継がれます。これがWebアプリケーションにおける「ログイン状態の維持」の仕組みです。

💡 skip_before_actionについて

skip_before_action :require_authentication, only: [:create]は、親クラス(ApplicationController)で定義されたbefore_actionを特定のアクションでスキップするためのメソッドです。

ここでcreateアクション(ログイン)をスキップする理由:

  • ログインする時点では、まだユーザーは認証されていない
  • 認証されていないユーザーがログインエンドポイントにアクセスできなければ、永遠にログインできない
  • そのため、createアクションだけは認証チェックを除外する必要がある

このように、skip_before_actionを使うことで、特定のアクションだけ認証を回避できます。

💡 コードのポイント

&.(ぼっち演算子)の意味:

user&.authenticate(params[:password])
# userがnilの場合にエラーを回避

reset_sessionの重要性:

  • セッションを完全に破棄
  • セッション固定攻撃を防ぐ

🧪 既存のテストが失敗することを確認

認証を追加したことで、第2章で作成したヘルスチェックのテストが失敗するはずです。確認してみましょう:

ターミナルで実行
docker compose exec web rspec spec/requests/health_spec.rb

期待されるエラー:

Failures:

1) Health Check GET /health returns success status
Failure/Error: expect(response).to have_http_status(:ok)
expected the response to have status code :ok (200) but it was :unauthorized (401)

このエラーは、ヘルスチェックエンドポイントも認証が必要になってしまったためです。しかし、ヘルスチェックは認証なしでアクセスできる必要があります(監視ツールなどから呼ばれるため)。

🔧 ヘルスチェックを認証から除外

skip_before_actionを使って、ヘルスチェックアクションを認証から除外します:

app/controllers/application_controller.rb(該当部分のみ)
class ApplicationController < ActionController::API
include ActionController::Cookies

# 全アクションで認証を要求
before_action :require_authentication

# ヘルスチェックは認証をスキップ(追加)
# SessionsControllerと同様に、特定のアクションで認証をスキップ
skip_before_action :require_authentication, only: [:health]

def health
render json: { status: 'ok' }
end

# ... private以下は同じ ...
end

再度テストを実行して、修正されたことを確認:

ターミナルで実行
docker compose exec web rspec spec/requests/health_spec.rb

期待される結果:

Health Check
GET /health
returns success status

Finished in 0.12345 seconds
1 example, 0 failures

これで、認証機能を追加しながら、既存の機能への影響を最小限に抑えることができました。

🔧 セッション設定の追加

Rails APIモードではデフォルトでセッションが無効になっているので、明示的に有効化します。

Rails APIモードは本来ステートレスなAPIを想定しているため、Cookie/セッション機能が無効化されています。Cookieベース認証を実装するには、これらの機能を有効化し、適切なセキュリティ設定を行う必要があります。

🔨 実際にやってみましょう!

config/application.rb
module TodoApp
class Application < Rails::Application
config.load_defaults 7.1
config.api_only = true

config.middleware.use ActionDispatch::Cookies

config.middleware.use ActionDispatch::Session::CookieStore,
key: '_todo_app_session',
same_site: :lax,
secure: Rails.env.production?
end
end

🌐 CORS設定の補足

第2章で設定したCORSに、Cookieベース認証のための設定を1つ追加します。

🔨 実際にやってみましょう!

config/initializers/cors.rb(1行追加)
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins ENV.fetch("FRONTEND_URL", "http://localhost:3001")

resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ["Authorization"],
max_age: 600,
credentials: true # この行を追加
end
end

credentials: trueを追加する理由:

  • ブラウザがCookieを自動送信するために必要
  • これがないと、セッションIDが含まれるCookieが送信されない
  • 第2章では不要だったが、Cookie認証では必須

🛤️ ルーティングの追加

🔨 実際にやってみましょう!

config/routes.rb
Rails.application.routes.draw do
# ヘルスチェック(ApplicationControllerのhealthアクションを使用)
get '/health', to: 'application#health'

namespace :api do
namespace :v1 do
# 認証関連(追加)
post 'login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy'
get 'me', to: 'sessions#show'

# Todo関連(既存)
resources :todos do
collection do
get :stats
end
end
end
end
end

💾 認証機能をコミット

🔨 実際にやってみましょう!

ターミナルで実行
# 変更を確認
git status

# 変更をステージング
git add .

# コミット
git commit -m "認証機能追加: SessionsControllerとセッション設定"

3. TodosControllerの修正

🔒 user_idパラメータの削除

第3章で一時的に使っていたuser_idパラメータを削除し、セッションから取得するように修正します。これにより、誰でも他人のTodoを操作できてしまうセキュリティホールを解決します。

🔨 実際にやってみましょう!

app/controllers/api/v1/todos_controller.rb
module Api
module V1
class TodosController < ApplicationController
before_action :set_todo, only: [:show, :update, :destroy]

# GET /api/v1/todos
def index
@todos = current_user.todos

# タイトルで検索
if params[:q].present?
@todos = @todos.where("title LIKE ?", "%#{params[:q]}%")
end

# 完了状態でフィルタリング
if params[:completed].present?
@todos = @todos.where(completed: ActiveModel::Type::Boolean.new.cast(params[:completed]))
end

# ページネーション
page = (params[:page] || 1).to_i
per_page = (params[:per_page] || 10).to_i
per_page = 100 if per_page > 100

@todos = @todos.limit(per_page).offset((page - 1) * per_page)

render json: {
todos: ActiveModelSerializers::SerializableResource.new(@todos).as_json,
meta: {
current_page: page,
per_page: per_page,
total_count: current_user.todos.count
}
}
end

# GET /api/v1/todos/:id
def show
render json: @todo
end

# POST /api/v1/todos
def create
@todo = current_user.todos.build(todo_params)

if @todo.save
render json: @todo, status: :created
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end

# PATCH/PUT /api/v1/todos/:id
def update
if @todo.update(todo_params)
render json: @todo
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end

# DELETE /api/v1/todos/:id
def destroy
@todo.destroy
head :no_content
end

# GET /api/v1/todos/stats
def stats
render json: {
total: current_user.todos.count,
completed: current_user.todos.completed.count,
incomplete: current_user.todos.incomplete.count,
completion_rate: calculate_completion_rate
}
end

private

def set_todo
# current_userのTodoのみ取得可能
@todo = current_user.todos.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Todo not found' }, status: :not_found
end

def todo_params
# user_idを削除
params.require(:todo).permit(:title, :description, :completed)
end

def calculate_completion_rate
total = current_user.todos.count
return 0 if total.zero?

completed = current_user.todos.completed.count
((completed.to_f / total) * 100).round(1)
end
end
end
end

主な変更箇所

  1. indexアクション(一覧取得)

    • 変更前: User.find(params[:user_id]).todos
    • 変更後: current_user.todos
  2. createアクション(新規作成)

    • 変更前: todo_paramsuser_idを含める
    • 変更後: current_user.todos.build(todo_params)で自動的に紐付け
  3. set_todoメソッド(個別取得)

    • 変更前: Todo.find(params[:id])(誰のTodoでも取得可能)
    • 変更後: current_user.todos.find(params[:id])(自分のTodoのみ)
  4. todo_paramsメソッド

    • 変更前: permit(:title, :description, :completed, :user_id)
    • 変更後: permit(:title, :description, :completed)(user_id削除)

APIのレスポンスの変化

重要な変化: user_idパラメータが不要に

Cookieからユーザー情報を自動的に取得するため、APIリクエストでuser_idを指定する必要がなくなりました:

# 変更前(第3章): user_idパラメータが必要
curl -X GET "http://localhost:3000/api/v1/todos?user_id=1"

# 変更後(第4章): Cookieがあれば自動的に自分のTodoを取得
curl -b cookies.txt -X GET "http://localhost:3000/api/v1/todos"
💡 cURLのCookie操作

-c cookies.txt: レスポンスで受け取ったCookieをファイルに保存

  • ログイン時に使用
  • サーバーから送られてきたセッションCookieを保存

-b cookies.txt: ファイルからCookieを読み込んでリクエストに含める

  • ログイン後のAPIアクセスで使用
  • 保存したセッションCookieを自動的に送信

これにより、ブラウザと同じようにセッション管理ができます。

レスポンス例:

{
"todos": [
{
"id": 1,
"title": "認証機能テスト",
"description": "Cookieで認証",
"completed": false,
"user_id": 1, // 自動的に現在のユーザーのIDが設定される
"created_at": "2024-01-01T00:00:00.000Z",
"updated_at": "2024-01-01T00:00:00.000Z"
}
],
"meta": {
"current_page": 1,
"per_page": 10,
"total_count": 1
}
}

このように、セキュリティホールを塞ぎながら、より使いやすいAPIになりました。

🧪 動作確認

修正したAPIが正しく動作するか確認しましょう。サーバーを再起動して、実際にログインからTodo操作までの流れをテストします。

🔨 実際にやってみましょう!

1. サーバーを再起動
# セッション設定を変更したので再起動が必要
docker compose restart web

# ログを確認
docker compose logs -f web

新しいターミナルを開いて実行:

2. ログインしてCookieを保存
curl -c cookies.txt -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"password"}'

期待されるレスポンス:

{
"user": {
"id": 1,
"email": "alice@example.com",
"name": "Alice"
}
}
3. ログイン状態を確認
# 保存したCookieを使って現在のユーザー情報を取得
curl -b cookies.txt http://localhost:3000/api/v1/me
4. user_idパラメータなしでTodo一覧を取得
# Cookieから自動的にユーザーを識別
curl -b cookies.txt http://localhost:3000/api/v1/todos | jq
5. 新しいTodoを作成
# user_idを指定しなくても自動的に紐付け
curl -b cookies.txt -X POST http://localhost:3000/api/v1/todos \
-H "Content-Type: application/json" \
-d '{"todo":{"title":"認証機能完成!","description":"セキュアなAPIになりました"}}'
6. 他人のTodoにアクセスできないことを確認
# 存在しないIDや他人のTodoを指定
curl -b cookies.txt http://localhost:3000/api/v1/todos/9999

期待されるレスポンス:

{"error":"Todo not found"}
7. ログアウト
# セッションを破棄
curl -b cookies.txt -c cookies.txt -X DELETE http://localhost:3000/api/v1/logout

期待されるレスポンス:

{"message":"ログアウトしました"}
8. ログアウト後はアクセス不可
# 認証なしでアクセスを試みる
curl -b cookies.txt http://localhost:3000/api/v1/todos

期待されるレスポンス:

{"error":"認証が必要です"}

これで、認証機能が正しく動作することが確認できました!

💾 TodosController修正をコミット

🔨 実際にやってみましょう!

ターミナルで実行
git add .
git commit -m "TodosController: 認証対応に修正"

4. テストの更新

認証機能を実装したことで、既存のテストは動作しなくなりました。すべてのAPIアクセスに認証が必要になったため、テストも認証を考慮する必要があります。

🧪 認証のテストヘルパー

毎回のテストでログイン処理を書くのは非効率的です。そこで、認証を簡単に扱えるヘルパーメソッドを作成します。

🔨 実際にやってみましょう!

ディレクトリを作成:

ターミナルで実行
docker compose exec web mkdir -p spec/support

ヘルパーファイルを作成:

spec/support/authentication_helper.rb
module AuthenticationHelper
def login_as(user)
post api_v1_login_path, params: {
email: user.email,
password: 'password'
}
end

def logout
delete api_v1_logout_path
end
end

RSpec.configure do |config|
config.include AuthenticationHelper, type: :request
end

rails_helper.rbを確認:

spec/rails_helper.rb
# spec/support配下のファイルを読み込み
Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f }

この設定がすでに含まれていることを確認してください。

作成したヘルパーメソッド

上記のコードで、以下の2つのヘルパーメソッドが使えるようになりました:

  1. login_as(user)

    • 指定したユーザーでログイン
    • セッションCookieを自動的に保持
    • 以降のリクエストで認証済み状態になる
  2. logout

    • 現在のセッションを破棄
    • ログアウト状態のテストに使用

これらのヘルパーにより、テストコードがシンプルで読みやすくなります。たとえば:

# ヘルパーなしの場合
post api_v1_login_path, params: { email: user.email, password: 'password' }
get api_v1_todos_path

# ヘルパーありの場合
login_as(user)
get api_v1_todos_path

🧪 セッションのテスト

新しく作成したSessionsControllerのテストを実装します。ログイン、ログアウト、ユーザー情報取得の各機能が正しく動作することを確認します。

テストする項目

  • 正しい認証情報でログインできること
  • 間違ったパスワードでログインに失敗すること
  • ログアウトでセッションが破棄されること
  • 認証なしで/meにアクセスできないこと

🔨 実際にやってみましょう!

spec/requests/api/v1/sessions_spec.rb
require 'rails_helper'

RSpec.describe 'Sessions API', type: :request do
let!(:user) { create(:user, email: 'test@example.com', password: 'password') }

describe 'POST /api/v1/login' do
context '正しい認証情報の場合' do
it 'ログインに成功する' do
post api_v1_login_path,
params: {
email: 'test@example.com',
password: 'password'
},
headers: api_headers

expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['user']['email']).to eq('test@example.com')
end
end

context '間違ったパスワードの場合' do
it 'ログインに失敗する' do
post api_v1_login_path,
params: {
email: 'test@example.com',
password: 'wrongpassword'
},
headers: api_headers

expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to be_present
end
end

context 'APIトークンなしの場合' do
it '403エラーを返す' do
post api_v1_login_path,
params: {
email: 'test@example.com',
password: 'password'
}

expect(response).to have_http_status(:forbidden)
json = JSON.parse(response.body)
expect(json['error']).to eq('APIトークンが無効です')
end
end
end

describe 'DELETE /api/v1/logout' do
before { login_as(user) }

it 'ログアウトに成功する' do
delete api_v1_logout_path, headers: api_headers

expect(response).to have_http_status(:ok)

# ログアウト後はアクセスできない
get api_v1_me_path, headers: api_headers
expect(response).to have_http_status(:unauthorized)
end
end

describe 'GET /api/v1/me' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '現在のユーザー情報を返す' do
get api_v1_me_path, headers: api_headers

expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['email']).to eq(user.email)
end
end

context '未ログインの場合' do
it '401エラーを返す' do
get api_v1_me_path, headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end
end

🧪 TodosControllerのテスト更新

第3章で作成したTodosControllerのテストを認証&APIトークン対応に修正します。すべてのテストケースで認証が必要になったため、作成したヘルパーメソッドを活用します。

⚠️ 重要な変更点

第3章で作成したテストは認証もAPIトークンも考慮していないため、そのまま実行するとすべて失敗します。必ずこのセクションの変更を適用してください。

主な変更点

1. すべてのリクエストにAPIヘッダーを追加

# 第3章(変更前)
get "/api/v1/todos"

# 第4章(変更後)
get "/api/v1/todos", headers: api_headers

2. user_idパラメータの削除

# 第3章(変更前)
get "/api/v1/todos", params: { user_id: user.id }
post "/api/v1/todos", params: { todo: {...}, user_id: user.id }

# 第4章(変更後)
get "/api/v1/todos", headers: api_headers # user_idは不要
post "/api/v1/todos", params: { todo: {...} }, headers: api_headers

3. 認証処理の追加

# 各テストケースの前にログイン
before { login_as(user) }

4. APIトークンなしのテストケース追加

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
get '/api/v1/todos' # headersを指定しない
expect(response).to have_http_status(:forbidden)
end
end

これらの変更により、APIが認証を通じて適切にユーザーを識別し、セキュリティが確保されていることを保証します。

🔨 実際にやってみましょう!

spec/requests/api/v1/todos_spec.rb
require 'rails_helper'

RSpec.describe 'Api::V1::Todos', type: :request do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:todo) { create(:todo, user: user) }
let!(:other_todo) { create(:todo, user: other_user) }

describe 'GET /api/v1/todos' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodo一覧のみを返すこと' do
create_list(:todo, 3, user: user)

get '/api/v1/todos'

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['todos'].size).to eq 3
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get '/api/v1/todos'

expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to eq '認証が必要です'
end
end
end

describe 'GET /api/v1/todos/:id' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoの詳細を返すこと' do
get "/api/v1/todos/#{todo.id}"

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['id']).to eq todo.id
expect(json['title']).to eq todo.title
end

it '他人のTodoは取得できないこと' do
get "/api/v1/todos/#{other_todo.id}"

expect(response).to have_http_status(:not_found)
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get "/api/v1/todos/#{todo.id}"

expect(response).to have_http_status(:unauthorized)
end
end
end

describe 'POST /api/v1/todos' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '新しいTodoを作成できること' do
todo_params = {
todo: {
title: '新しいタスク',
description: 'これは新しいタスクです',
completed: false
}
}

expect do
post '/api/v1/todos', params: todo_params
end.to change(Todo, :count).by(1)

expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['title']).to eq '新しいタスク'
expect(json['user']['id']).to eq user.id # 自動的に現在のユーザーが設定される
end

it '無効なパラメータの場合エラーを返すこと' do
todo_params = {
todo: { title: '' }
}

post '/api/v1/todos', params: todo_params

expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json['errors']).to include("Title can't be blank")
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
todo_params = {
todo: { title: '新しいタスク' }
}

post '/api/v1/todos', params: todo_params

expect(response).to have_http_status(:unauthorized)
end
end
end

describe 'PATCH /api/v1/todos/:id' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoを更新できること' do
patch "/api/v1/todos/#{todo.id}",
params: {
todo: { completed: true }
}

expect(response).to have_http_status(:success)
expect(todo.reload.completed).to be true
end

it '他人のTodoは更新できないこと' do
patch "/api/v1/todos/#{other_todo.id}",
params: {
todo: { completed: true }
}

expect(response).to have_http_status(:not_found)
expect(other_todo.reload.completed).to be false
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
patch "/api/v1/todos/#{todo.id}",
params: { todo: { completed: true } }

expect(response).to have_http_status(:unauthorized)
end
end
end

describe 'DELETE /api/v1/todos/:id' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoを削除できること' do
todo # let!ではないので、ここで作成

expect do
delete "/api/v1/todos/#{todo.id}"
end.to change(Todo, :count).by(-1)

expect(response).to have_http_status(:no_content)
end

it '他人のTodoは削除できないこと' do
expect do
delete "/api/v1/todos/#{other_todo.id}"
end.not_to change(Todo, :count)

expect(response).to have_http_status(:not_found)
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
delete "/api/v1/todos/#{todo.id}"

expect(response).to have_http_status(:unauthorized)
end
end
end

describe 'GET /api/v1/todos (with filters)' do
context 'ログイン済みの場合' do
before do
login_as(user)
create(:todo, user: user, title: 'Rails APIの勉強', completed: true)
create(:todo, user: user, title: 'RSpecでテストを書く', completed: false)
create(:todo, user: user, title: 'フロントエンドの実装', completed: false)
# 他のユーザーのTodoも作成(フィルタリングされることを確認)
create(:todo, user: other_user, title: 'Rails APIの復習', completed: true)
end

it 'タイトルで検索できること' do
get '/api/v1/todos', params: { q: 'Rails' }

json = JSON.parse(response.body)
expect(json['todos'].size).to eq 1
expect(json['todos'][0]['title']).to include('Rails')
end

it '完了状態でフィルタリングできること' do
get '/api/v1/todos', params: { completed: true }

json = JSON.parse(response.body)
expect(json['todos'].size).to eq 1
expect(json['todos'][0]['completed']).to be true
end
end
end

describe 'GET /api/v1/todos/stats' do
context 'ログイン済みの場合' do
before do
login_as(user)
create(:todo, user: user, title: 'Rails APIの勉強', completed: true)
create(:todo, user: user, title: 'RSpecでテストを書く', completed: false)
create(:todo, user: user, title: 'フロントエンドの実装', completed: false)
# 他のユーザーのTodoは統計に含まれないことを確認
create(:todo, user: other_user, completed: true)
end

it '自分のTodoの統計情報のみを返すこと' do
get '/api/v1/todos/stats'

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['total']).to eq 3
expect(json['completed']).to eq 1
expect(json['incomplete']).to eq 2
expect(json['completion_rate']).to eq 33.3
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get '/api/v1/todos/stats'

expect(response).to have_http_status(:unauthorized)
end
end
end
end

💾 テストをコミット

🔨 実際にやってみましょう!

ターミナルで実行
git add .
git commit -m "認証テスト追加"

5. APIトークン認証の追加

教材用の内部向けAPIとして、追加のセキュリティ層を実装します。環境変数でAPIトークンを設定し、リクエストヘッダーで検証する仕組みを追加します。

🔐 APIトークン認証の実装

🔨 実際にやってみましょう!

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

before_action :validate_api_token
before_action :require_authentication

# ヘルスチェックは両方の認証をスキップ
skip_before_action :validate_api_token, only: [:health]
skip_before_action :require_authentication, only: [:health]

def health
render json: { status: 'ok' }
end

private

# APIトークンの検証
def validate_api_token
api_token = request.headers['X-API-TOKEN']
use_api = request.headers['X-USE-API']

if api_token == ENV['API_TOKEN'] && use_api == 'sample-api'
return
end

render json: { error: 'APIトークンが無効です' }, status: :forbidden
end

def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end

def require_authentication
unless current_user
render json: { error: '認証が必要です' }, status: :unauthorized
end
end
end

SessionsControllerはAPIトークンチェックをスキップする必要があります:

app/controllers/api/v1/sessions_controller.rb(変更箇所のみ)
module Api
module V1
class SessionsController < ApplicationController
# ログイン時はユーザー認証をスキップ
skip_before_action :require_authentication, only: [:create]

# APIトークンは全アクションで必要
# (ApplicationControllerで設定済みなので追加設定不要)

# ... 既存のコード ...
end
end
end

🔧 環境変数の設定

APIトークンを環境変数で管理します。

🔨 実際にやってみましょう!

.env(プロジェクトルートに作成)
# APIトークン(本番環境では強固なランダム文字列を使用)
API_TOKEN=123456789abcdef
docker-compose.yml(webサービスに追加)
services:
web:
# ... 既存の設定 ...
environment:
- DATABASE_HOST=db
- DATABASE_USERNAME=root
- DATABASE_PASSWORD=password
- API_TOKEN=${API_TOKEN} # この行を追加

🔨 実際にやってみましょう!

.envファイルをGitにコミットしないように設定:

ターミナルで実行
# .envファイルを.gitignoreに追加
echo ".env" >> .gitignore
⚠️ セキュリティの注意
  • .envファイルは機密情報を含むため、絶対にGitにコミットしない
  • 本番環境では環境変数を安全に管理する(AWS Secrets Manager等)
  • APIトークンは十分な長さのランダム文字列を使用

🧪 RSpecヘルパーの更新

テストでAPIトークンを自動的に設定するようにヘルパーを更新します。

🔨 実際にやってみましょう!

spec/support/authentication_helper.rb
module AuthenticationHelper
# 指定したユーザーでログイン
def login_as(user)
post api_v1_login_path,
params: { email: user.email, password: 'password' },
headers: api_headers # APIトークンを含むヘッダー
end

# 現在のセッションをログアウト
def logout
delete api_v1_logout_path, headers: api_headers
end

# APIトークンを含むヘッダー
def api_headers
{
'X-API-TOKEN' => ENV.fetch('API_TOKEN', '123456789abcdef'),
'X-USE-API' => 'sample-api'
}
end
end

RSpec.configure do |config|
config.include AuthenticationHelper, type: :request
end

既存のテストも更新が必要です:

spec/requests/health_spec.rb
require 'rails_helper'

RSpec.describe 'Health Check', type: :request do
describe 'GET /health' do
it 'returns success status without authentication' do
# ヘルスチェックはAPIトークンも不要
get '/health'

expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['status']).to eq 'ok'
end
end
end

続いて、セッションAPIのテストをAPIトークン対応に更新します:

spec/requests/api/v1/sessions_spec.rb
require 'rails_helper'

RSpec.describe 'Sessions API', type: :request do
let!(:user) { create(:user, email: 'test@example.com', password: 'password') }

describe 'POST /api/v1/login' do
context '正しい認証情報の場合' do
it 'ログインに成功する' do
post api_v1_login_path, params: {
email: 'test@example.com',
password: 'password'
}, headers: api_headers

expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['user']['email']).to eq('test@example.com')
end
end

context '間違ったパスワードの場合' do
it 'ログインに失敗する' do
post api_v1_login_path, params: {
email: 'test@example.com',
password: 'wrongpassword'
}, headers: api_headers

expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to be_present
end
end
end

describe 'DELETE /api/v1/logout' do
before { login_as(user) }

it 'ログアウトに成功する' do
delete api_v1_logout_path, headers: api_headers

expect(response).to have_http_status(:ok)

# ログアウト後はアクセスできない
get api_v1_me_path, headers: api_headers
expect(response).to have_http_status(:unauthorized)
end
end

describe 'GET /api/v1/me' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '現在のユーザー情報を返す' do
get api_v1_me_path, headers: api_headers

expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json['user']['id']).to eq(user.id)
expect(json['user']['email']).to eq(user.email)
end
end

context '未ログインの場合' do
it '401エラーを返す' do
get api_v1_me_path, headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end
end

TodosControllerのテストもAPIトークン対応に更新します:

spec/requests/api/v1/todos_spec.rb(完全版)
require 'rails_helper'

RSpec.describe 'Api::V1::Todos', type: :request do
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:todo) { create(:todo, user: user) }
let!(:other_todo) { create(:todo, user: other_user) }

describe 'GET /api/v1/todos' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodo一覧のみを返すこと' do
create_list(:todo, 3, user: user)

get '/api/v1/todos', headers: api_headers

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['todos'].size).to eq 3
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get '/api/v1/todos', headers: api_headers

expect(response).to have_http_status(:unauthorized)
json = JSON.parse(response.body)
expect(json['error']).to eq '認証が必要です'
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
get '/api/v1/todos'

expect(response).to have_http_status(:forbidden)
json = JSON.parse(response.body)
expect(json['error']).to eq 'APIトークンが無効です'
end
end
end

describe 'GET /api/v1/todos/:id' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoの詳細を返すこと' do
get "/api/v1/todos/#{todo.id}", headers: api_headers

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['id']).to eq todo.id
expect(json['title']).to eq todo.title
end

it '他人のTodoは取得できないこと' do
get "/api/v1/todos/#{other_todo.id}", headers: api_headers

expect(response).to have_http_status(:not_found)
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get "/api/v1/todos/#{todo.id}", headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
get "/api/v1/todos/#{todo.id}"

expect(response).to have_http_status(:forbidden)
end
end
end

describe 'POST /api/v1/todos' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '新しいTodoを作成できること' do
todo_params = {
todo: {
title: '新しいタスク',
description: 'これは新しいタスクです',
completed: false
}
}

expect do
post '/api/v1/todos', params: todo_params, headers: api_headers
end.to change(Todo, :count).by(1)

expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json['title']).to eq '新しいタスク'
expect(json['user']['id']).to eq user.id
end

it '無効なパラメータの場合エラーを返すこと' do
todo_params = {
todo: { title: '' }
}

post '/api/v1/todos', params: todo_params, headers: api_headers

expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json['errors']).to include("Title can't be blank")
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
todo_params = {
todo: { title: '新しいタスク' }
}

post '/api/v1/todos', params: todo_params, headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
todo_params = {
todo: { title: '新しいタスク' }
}

post '/api/v1/todos', params: todo_params

expect(response).to have_http_status(:forbidden)
end
end
end

describe 'PATCH /api/v1/todos/:id' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoを更新できること' do
patch "/api/v1/todos/#{todo.id}",
params: { todo: { completed: true } },
headers: api_headers

expect(response).to have_http_status(:success)
expect(todo.reload.completed).to be true
end

it '他人のTodoは更新できないこと' do
patch "/api/v1/todos/#{other_todo.id}",
params: { todo: { completed: true } },
headers: api_headers

expect(response).to have_http_status(:not_found)
expect(other_todo.reload.completed).to be false
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
patch "/api/v1/todos/#{todo.id}",
params: { todo: { completed: true } },
headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
patch "/api/v1/todos/#{todo.id}",
params: { todo: { completed: true } }

expect(response).to have_http_status(:forbidden)
end
end
end

describe 'DELETE /api/v1/todos/:id' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before { login_as(user) }

it '自分のTodoを削除できること' do
todo # let!ではないので、ここで作成

expect do
delete "/api/v1/todos/#{todo.id}", headers: api_headers
end.to change(Todo, :count).by(-1)

expect(response).to have_http_status(:no_content)
end

it '他人のTodoは削除できないこと' do
expect do
delete "/api/v1/todos/#{other_todo.id}", headers: api_headers
end.not_to change(Todo, :count)

expect(response).to have_http_status(:not_found)
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
delete "/api/v1/todos/#{todo.id}", headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
delete "/api/v1/todos/#{todo.id}"

expect(response).to have_http_status(:forbidden)
end
end
end

describe 'GET /api/v1/todos (with filters)' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before do
login_as(user)
create(:todo, user: user, title: 'Rails APIの勉強', completed: true)
create(:todo, user: user, title: 'RSpecでテストを書く', completed: false)
create(:todo, user: user, title: 'フロントエンドの実装', completed: false)
# 他のユーザーのTodoも作成(フィルタリングされることを確認)
create(:todo, user: other_user, title: 'Rails APIの復習', completed: true)
end

it 'タイトルで検索できること' do
get '/api/v1/todos', params: { q: 'Rails' }, headers: api_headers

json = JSON.parse(response.body)
expect(json['todos'].size).to eq 1
expect(json['todos'][0]['title']).to include('Rails')
end

it '完了状態でフィルタリングできること' do
get '/api/v1/todos', params: { completed: true }, headers: api_headers

json = JSON.parse(response.body)
expect(json['todos'].size).to eq 1
expect(json['todos'][0]['completed']).to be true
end

it 'ページネーションが動作すること' do
get '/api/v1/todos', params: { page: 1, per_page: 2 }, headers: api_headers

json = JSON.parse(response.body)
expect(json['todos'].size).to eq 2
expect(json['meta']['current_page']).to eq 1
expect(json['meta']['per_page']).to eq 2
end
end
end
end

describe 'GET /api/v1/todos/stats' do
context 'APIトークンありの場合' do
context 'ログイン済みの場合' do
before do
login_as(user)
create(:todo, user: user, title: 'Rails APIの勉強', completed: true)
create(:todo, user: user, title: 'RSpecでテストを書く', completed: false)
create(:todo, user: user, title: 'フロントエンドの実装', completed: false)
# 他のユーザーのTodoは統計に含まれないことを確認
create(:todo, user: other_user, completed: true)
end

it '自分のTodoの統計情報のみを返すこと' do
get '/api/v1/todos/stats', headers: api_headers

expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json['total']).to eq 3
expect(json['completed']).to eq 1
expect(json['incomplete']).to eq 2
expect(json['completion_rate']).to eq 33.3
end
end

context '未ログインの場合' do
it '401エラーを返すこと' do
get '/api/v1/todos/stats', headers: api_headers

expect(response).to have_http_status(:unauthorized)
end
end
end

context 'APIトークンなしの場合' do
it '403エラーを返すこと' do
get '/api/v1/todos/stats'

expect(response).to have_http_status(:forbidden)
end
end
end
end

💡 テストのポイント
  • login_asメソッド内でAPIヘッダーが自動設定される
  • 追加のリクエストでは明示的にheaders: api_headersを指定
  • すべてのエンドポイントで「APIトークンなし」のテストケースを追加
  • 403(Forbidden)と401(Unauthorized)のエラーを明確に区別
    • 403: APIトークンが無効または未設定
    • 401: ユーザー認証が必要(ログインしていない)
  • .envファイルのAPI_TOKENがテストでも使用される(デフォルト: 123456789abcdef)

すべてのテストを実行

環境変数を追加したので、コンテナを再起動してからテストを実行します:

ターミナルで実行
# Dockerコンテナを再起動(環境変数を反映)
docker compose restart web

# すべてのテストを実行
docker compose exec web rspec

期待される結果:

.....................................

Finished in 2.36 seconds (files took 2.56 seconds to load)
37 examples, 0 failures

📝 curlでの動作確認

APIトークンを使った動作確認を行います。

🔨 実際にやってみましょう!

ターミナルで実行
# APIトークンなしでアクセス(失敗)
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"password"}'

# 期待される結果:
# {"error":"APIトークンが無効です"}

# APIトークン付きでログイン(成功)
curl -X POST http://localhost:3000/api/v1/login \
-H "Content-Type: application/json" \
-H "X-API-TOKEN: 123456789abcdef" \
-H "X-USE-API: sample-api" \
-d '{"email":"alice@example.com","password":"password"}' \
-c cookie.txt

# Todo一覧を取得(APIトークンとCookie両方が必要)
curl -X GET http://localhost:3000/api/v1/todos \
-H "X-API-TOKEN: 123456789abcdef" \
-H "X-USE-API: sample-api" \
-b cookie.txt

# ヘルスチェック(認証不要)
curl -X GET http://localhost:3000/health

💾 APIトークン認証をコミット

🔨 実際にやってみましょう!

ターミナルで実行
git add .
git commit -m "APIトークン認証を追加"

6. GitHubへのプッシュとプルリクエスト

📊 コミット履歴の確認

🔨 実際にやってみましょう!

ターミナルで実行
# コミット履歴を確認
git log --oneline -n 10

🚀 GitHubへプッシュ

🔨 実際にやってみましょう!

ターミナルで実行
# feature/chapter-4ブランチをプッシュ
git push -u origin feature/chapter-4

📝 プルリクエストの作成

GitHubでプルリクエストを作成します:

  1. ブラウザでGitHubリポジトリを開く
  2. 「Compare & pull request」ボタンをクリック
  3. プルリクエストの内容を入力:

Title:

第4章: 認証機能(Cookieベース)

Description:

## 概要
第3章で作成したTodo APIに認証機能を追加し、セキュリティを強化しました。

## 実装内容
- ✅ Cookieベースのセッション認証
- ✅ ログイン/ログアウト機能
- ✅ 現在のユーザー情報取得API
- ✅ TodosControllerの認証対応
- ✅ APIトークン認証(二重認証)
- ✅ 認証を含むテスト

## セキュリティの向上
- user_idパラメータを削除し、セッションから取得
- 他人のTodoにアクセスできない
- APIトークンによるアクセス制御(内部API向け)

## 動作確認
- [ ] `curl`でAPIトークン付きでログイン/ログアウトが正常に動作すること
- [ ] ログイン後のみTodoにアクセスできること
- [ ] APIトークンなしでは403エラーになること
- [ ] `docker compose exec web rspec`で全テストが通ること

## レビューポイント
- セキュリティ設定は適切か
- APIトークンの管理方法
- エラーハンドリングは十分か
- テストケースの網羅性

📋 まとめ

✅ この章で達成したこと

第4章お疲れさまでした!アプリケーションがセキュアになりました:

  • 🔐 Cookieベース認証: セッションを使ったログイン/ログアウト
  • 🍪 セッション管理: RailsのCookieストアを活用
  • 🔑 APIトークン認証: 内部API向けの追加セキュリティ層
  • 👤 認可の実装: 自分のデータのみアクセス可能
  • 認証込みテスト: ヘルパーを使った効率的なテスト

🚨 解決したセキュリティ問題

Before(第3章):

# 誰でも他人のTodoを操作可能
curl -X GET "http://localhost:3000/api/v1/todos?user_id=999"

After(第4章):

# APIトークンなしでアクセス不可
curl -X GET "http://localhost:3000/api/v1/todos"
# => {"error":"APIトークンが無効です"}

# APIトークンがあってもログインが必要
curl -X GET "http://localhost:3000/api/v1/todos" \
-H "X-API-TOKEN: your-secure-api-token-here" \
-H "X-USE-API: sample-api"
# => {"error":"認証が必要です"}

🎯 次章で学ぶこと

第5章では、いよいよReact/TypeScriptでフロントエンドを構築します。

🔗 これまでの成果を活かす

第1~4章で構築したAPIが、フロントエンドの土台となります:

  • CRUD API: Todoの作成・読み込み・更新・削除
  • 🔐 認証API: ログイン・ログアウト・ユーザー情報取得
  • 🍪 Cookie認証: ブラウザからのセキュアな通信
  • 🔑 APIトークン: セキュアなAPI通信

🚀 第5章で作るもの

実際のユーザーが使える**SPA(Single Page Application)**を構築:

  1. Reactの基礎

    • コンポーネントの考え方
    • Hooks(useState、useEffect)の使い方
    • モダンなReactの書き方
  2. TypeScript入門

    • 型のメリットを体感
    • APIレスポンスの型定義
    • コンパイル時のエラー検出
  3. API通信の実装

    • fetch APIを使った通信
    • Cookie認証の処理
    • エラーハンドリング
  4. ユーザーインターフェース

    • ログイン画面
    • Todo一覧・作成・編集画面
    • リアルタイム更新

これで、フルスタックのTodoアプリケーションが完成します!

🏃 演習問題

理解を深めるために、以下の課題に挑戦してみましょう。

📝 演習: セッションタイムアウト

30分でセッションが自動的に期限切れになる機能を実装してください。

💡 ヒント(クリックで展開)

セッションタイムアウトの実装には2つのアプローチがあります:

  1. 自前で実装する方法

    • セッションに最終アクセス時刻を記録
    • リクエストごとに時刻をチェック
    • 30分経過していればセッションを削除
  2. Railsの設定を使う方法

    • config/application.rbでセッションの有効期限を設定
    • より簡潔で保守性が高い
✅ 答え(クリックで展開)

方法1: 自前で実装する場合

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# ... 既存のコード ...

before_action :check_session_timeout

private

def check_session_timeout
if session[:last_access_time]
if session[:last_access_time] < 30.minutes.ago
reset_session
render json: { error: 'セッションがタイムアウトしました' }, status: :unauthorized
return
end
end

session[:last_access_time] = Time.current
end
end

方法2: Railsの設定を使う場合(推奨)

# config/application.rb
module TodoApp
class Application < Rails::Application
# ... 既存の設定 ...

# セッションをCookieで管理する設定(既存のコードを更新)
config.middleware.use ActionDispatch::Session::CookieStore,
key: '_todo_app_session',
same_site: :lax,
secure: Rails.env.production?,
expire_after: 30.minutes # 30分でセッション期限切れ
end
end

それぞれの方法のメリット・デメリット

方法1(自前実装):

  • ✅ 柔軟な制御が可能(警告メッセージなど)
  • ✅ アクセスごとに期限を延長できる
  • ❌ コードが複雑になる

方法2(Rails設定):

  • ✅ シンプルで保守性が高い
  • ✅ Railsの標準機能を活用
  • ❌ 固定時間での期限切れのみ

実際のプロジェクトでは、要件に応じて適切な方法を選択してください。

📚 参考資料

より深く学びたい方向け:


次章: 第5章: React & TypeScript入門