mcpField
I
inertia-rails-auth

inertia-rails-auth

github.com/cole-robertson開発3882 回インストール30 週間前に更新
リポジトリ

inertia-rails-auth について

Inertia Railsアプリケーションに認証と認可を導入するためのガイドスキルです。Railsのセッション方式を軸に、Deviseやパスワード認証、権限の共有、保護ルート、安全対策の例を紹介します。

主な機能

  1. 01Deviseとパスワード認証
  2. 02Inertiaでの認証状態の共有
  3. 03認可結果をpropsとして渡す方法
  4. 04PunditとAction Policyの例
  5. 05保護ルートとロールチェック
  6. 06セッション、CSRF、履歴のセキュリティガイド

活用シーン

  1. 01ログインと登録フローの追加
  2. 02フロントエンドコンポーネントへの権限提供
  3. 03ルートと管理領域の保護
  4. 04ログアウト時の履歴とセッションの安全な処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/cole-robertson/inertia-rails-skills --skill inertia-rails-auth

インストール後、Claude Code で/inertia-rails-authを入力して読み込まれたか確認します。

詳細

Inertia Rails の認証と認可

Inertia Rails アプリケーションで認証と認可を実装するためのガイドです。

重要な原則

Inertia は既存の Rails 認証インフラストラクチャを使用します。特別な OAuth 認証やトークンベースの認証は不要です。フロントエンドとバックエンドが同じドメインを共有するため、セッションベースの認証がシームレスに機能します。

Devise による認証

セットアップ
# Gemfile
gem 'devise'
bundle install
rails generate devise:install
rails generate devise User
rails db:migrate
認証状態を共有する
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  inertia_share do
    {
      auth: {
        user: current_user&.as_json(only: [:id, :name, :email, :avatar_url]),
        signed_in: user_signed_in?
      }
    }
  end
end
ログインページを作成する
# app/controllers/sessions_controller.rb
class SessionsController < Devise::SessionsController
  def new
    render inertia: {}
  end

  def create
    self.resource = warden.authenticate(auth_options)

    if resource
      sign_in(resource_name, resource)
      redirect_to after_sign_in_path_for(resource), notice: 'Signed in successfully!'
    else
      redirect_to new_session_path(resource_name), inertia: {
        errors: { email: 'Invalid email or password' }
      }
    end
  end

  def destroy
    sign_out(resource_name)
    redirect_to root_path, notice: 'Signed out successfully!'
  end
end
ログインコンポーネント(React)
// app/frontend/pages/sessions/new.jsx
import { useForm, Link } from '@inertiajs/react'

export default function Login() {
  const { data, setData, post, processing, errors, reset } = useForm({
    email: '',
    password: '',
    remember: false,
  })

  function submit(e) {
    e.preventDefault()
    post('/users/sign_in', {
      onSuccess: () => reset('password'),
    })
  }

  return (
    <form onSubmit={submit}>
      <h1>Sign In</h1>

      <div>
        <label>Email</label>
        <input
          type="email"
          value={data.email}
          onChange={(e) => setData('email', e.target.value)}
          autoFocus
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>

      <div>
        <label>Password</label>
        <input
          type="password"
          value={data.password}
          onChange={(e) => setData('password', e.target.value)}
        />
      </div>

      <div>
        <label>
          <input
            type="checkbox"
            checked={data.remember}
            onChange={(e) => setData('remember', e.target.checked)}
          />
          Remember me
        </label>
      </div>

      <button type="submit" disabled={processing}>
        {processing ? 'Signing in...' : 'Sign In'}
      </button>

      <p>
        <Link href="/users/sign_up">Create an account</Link>
        <Link href="/users/password/new">Forgot password?</Link>
      </p>
    </form>
  )
}
ログインコンポーネント(Vue)

<script setup>
import { useForm, Link } from '@inertiajs/vue3'

const form = useForm({
  email: '',
  password: '',
  remember: false,
})

function submit() {
  form.post('/users/sign_in', {
    onSuccess: () => form.reset('password'),
  })
}
</script>

<template>
  <form @submit.prevent="submit">
    <h1>Sign In</h1>

    <div>
      <label>Email</label>
      <input v-model="form.email" type="email" autofocus />
      <span v-if="form.errors.email" class="error">{{ form.errors.email }}</span>
    </div>

    <div>
      <label>Password</label>
      <input v-model="form.password" type="password" />
    </div>

    <div>
      <label>
        <input v-model="form.remember" type="checkbox" />
        Remember me
      </label>
    </div>

    <button type="submit" :disabled="form.processing">
      {{ form.processing ? 'Signing in...' : 'Sign In' }}
    </button>

    <p>
      <Link href="/users/sign_up">Create an account</Link>
      <Link href="/users/password/new">Forgot password?</Link>
    </p>
  </form>
</template>

has_secure_password による認証

ユーザーモデル
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password

  validates :email, presence: true, uniqueness: true
  validates :password, length: { minimum: 8 }, allow_nil: true
end
セッションコントローラー
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  skip_before_action :authenticate!, only: [:new, :create]

  def new
    render inertia: {}
  end

  def create
    user = User.find_by(email: params[:email])

    if user&.authenticate(params[:password])
      session[:user_id] = user.id
      redirect_to dashboard_path, notice: 'Welcome back!'
    else
      redirect_to login_path, inertia: {
        errors: { email: 'Invalid email or password' }
      }
    end
  end

  def destroy
    session.delete(:user_id)
    redirect_to root_path, notice: 'Signed out successfully!'
  end
end

# Routes
# config/routes.rb
get 'login', to: 'sessions#new'
post 'login', to: 'sessions#create'
delete 'logout', to: 'sessions#destroy'
アプリケーションコントローラーのヘルパー
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate!
  helper_method :current_user, :user_signed_in?

  inertia_share do
    {
      auth: {
        user: current_user&.as_json(only: [:id, :name, :email]),
        signed_in: user_signed_in?
      }
    }
  end

  private

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

  def user_signed_in?
    current_user.present?
  end

  def authenticate!
    unless user_signed_in?
      redirect_to login_path, alert: 'Please sign in to continue'
    end
  end
end

認可

権限を props として渡す

フロントエンドのコンポーネントからサーバーサイドの認可ヘルパーにアクセスできないため、権限の判定結果を props として渡します。

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    render inertia: {
      can: {
        create_user: policy(User).create?
      },
      users: User.all.map do |user|
        serialize_user(user)
      end
    }
  end

  def show
    user = User.find(params[:id])

    render inertia: {
      user: serialize_user(user),
      can: {
        edit: policy(user).edit?,
        delete: policy(user).destroy?
      }
    }
  end

  private

  def serialize_user(user)
    user.as_json(only: [:id, :name, :email]).merge(
      can: {
        edit: policy(user).edit?,
        delete: policy(user).destroy?
      }
    )
  end
end
Pundit を使用する
# Gemfile
gem 'pundit'

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Pundit::Authorization

  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized

  private

  def user_not_authorized
    redirect_to root_path, alert: 'You are not authorized to perform this action'
  end
end

# app/policies/user_policy.rb
class UserPolicy < ApplicationPolicy
  def index?
    true
  end

  def show?
    true
  end

  def create?
    user.admin?
  end

  def update?
    user.admin? || record == user
  end

  def destroy?
    user.admin? && record != user
  end
end
Action Policy を使用する
# Gemfile
gem 'action_policy'

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    render inertia: {
      can: {
        create_user: allowed_to?(:create?, User)
      },
      users: User.all.map do |user|
        user.as_json(only: [:id, :name]).merge(
          can: {
            edit: allowed_to?(:edit?, user),
            delete: allowed_to?(:destroy?, user)
          }
        )
      end
    }
  end
end
フロントエンドでの権限チェック(React)
import { Link, usePage } from '@inertiajs/react'

export default function UsersIndex({ users, can }) {
  const { auth } = usePage().props

  return (
    <div>
      <h1>Users</h1>

      {can.create_user && (
        <Link href="/users/new" className="btn">
          Create User
        </Link>
      )}

      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name}

            {user.can.edit && (
              <Link href={`/users/${user.id}/edit`}>Edit</Link>
            )}

            {user.can.delete && (
              <Link
                href={`/users/${user.id}`}
                method="delete"
                as="button"
              >
                Delete
              </Link>
            )}
          </li>
        ))}
      </ul>
    </div>
  )
}
フロントエンドでの権限チェック(Vue)
<script setup>
import { Link, usePage } from '@inertiajs/vue3'

const props = defineProps(['users', 'can'])
const { auth } = usePage().props
</script>

<template>
  <div>
    <h1>Users</h1>

    
    <Link v-if="can.create_user" href="/users/new" class="btn">
      Create User
    </Link>

    <ul>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}

        
        <Link v-if="user.can.edit" :href="`/users/${user.id}/edit`">
          Edit
        </Link>

        <Link
          v-if="user.can.delete"
          :href="`/users/${user.id}`"
          method="delete"
          as="button"
        >
          Delete
        </Link>
      </li>
    </ul>
  </div>
</template>

履歴の暗号化

ログアウト後にブラウザーの戻るボタンから機密データが露出するのを防ぎます。

# config/initializers/inertia_rails.rb
InertiaRails.configure do |config|
  # Enable globally
  config.encrypt_history = true
end

# Or per-controller for sensitive areas
class Admin::BaseController < ApplicationController
  inertia_config(encrypt_history: true)
end

# Or per-request
def show
  render inertia: { secret_data: data }, encrypt_history: true
end
ログアウト時に履歴を消去する
def destroy
  sign_out(current_user)

  # Clear encrypted history (rotates encryption key)
  redirect_to root_path, inertia: { clear_history: true }
end

クライアントサイド:

import { router } from '@inertiajs/react'

function logout() {
  router.clearHistory()
  router.post('/logout')
}

保護されたルート

ミドルウェア方式
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate!

  private

  def authenticate!
    unless user_signed_in?
      redirect_to login_path, alert: 'Please sign in'
    end
  end
end

# Skip for public controllers
class PagesController < ApplicationController
  skip_before_action :authenticate!, only: [:home, :about]
end
ロールベースのアクセス制御
class Admin::BaseController < ApplicationController
  before_action :require_admin!

  private

  def require_admin!
    unless current_user&.admin?
      redirect_to root_path, alert: 'Admin access required'
    end
  end
end

CSRF 保護

Inertia は CSRF を自動的に処理します。Rails が次のように設定されていることを確認してください。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end

Inertia は次の方法で CSRF を自動的に処理します。

  1. Rails が設定した XSRF-TOKEN Cookie を読み取る
  2. リクエストに X-XSRF-TOKEN ヘッダーを付けて送信する

注:Inertia.js v3 では、Axios が組み込みの HTTP クライアントに置き換えられました。CSRF の処理方法は同じで、設定は不要です。

登録フロー

# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
  skip_before_action :authenticate!

  def new
    render inertia: {}
  end

  def create
    user = User.new(user_params)

    if user.save
      session[:user_id] = user.id
      redirect_to dashboard_path, notice: 'Welcome!'
    else
      redirect_to register_path, inertia: { errors: user.errors }
    end
  end

  private

  def user_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end

パスワードリセットフロー

# app/controllers/password_resets_controller.rb
class PasswordResetsController < ApplicationController
  skip_before_action :authenticate!

  def new
    render inertia: {}
  end

  def create
    user = User.find_by(email: params[:email])
    user&.send_password_reset_email

    # Always show success to prevent email enumeration
    redirect_to login_path, notice: 'Check your email for reset instructions'
  end

  def edit
    user = User.find_by(password_reset_token: params[:token])

    if user&.password_reset_valid?
      render inertia: { token: params[:token] }
    else
      redirect_to login_path, alert: 'Invalid or expired reset link'
    end
  end

  def update
    user = User.find_by(password_reset_token: params[:token])

    if user&.password_reset_valid? && user.update(password_params)
      user.clear_password_reset!
      session[:user_id] = user.id
      redirect_to dashboard_path, notice: 'Password updated!'
    else
      redirect_to edit_password_reset_path(token: params[:token]),
        inertia: { errors: user&.errors || { token: 'Invalid token' } }
    end
  end

  private

  def password_params
    params.permit(:password, :password_confirmation)
  end
end

ベストプラクティス

1. クライアントサイドの認証チェックを決して信頼しない

必ずサーバーサイドで権限を検証してください。

def destroy
  user = User.find(params[:id])
  authorize user  # Pundit/ActionPolicy check

  user.destroy
  redirect_to users_path
end
2. 公開するユーザーデータを最小限にする
# Bad
inertia_share auth: { user: current_user }

# Good
inertia_share auth: {
  user: current_user&.as_json(only: [:id, :name, :email])
}
3. 安全なセッション設定を使用する
# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: '_myapp_session',
  secure: Rails.env.production?,
  httponly: true,
  same_site: :lax
4. セッションの有効期限切れを適切に処理する
// Check auth state on each navigation
router.on('navigate', () => {
  const { auth } = usePage().props
  if (!auth.signed_in && requiresAuth(page.component)) {
    router.visit('/login')
  }
})
5. レート制限を実装する
# Gemfile
gem 'rack-attack'

# config/initializers/rack_attack.rb
Rack::Attack.throttle('login attempts', limit: 5, period: 60) do |req|
  req.ip if req.path == '/login' && req.post?
end

コメント