关于 inertia-rails-auth
为 Inertia Rails 应用实现身份验证与授权的指南技能,介绍基于 Rails 会话的模式,并提供 Devise、密码验证、权限共享、受保护路由及安全实践示例。
主要功能
- 01Devise 与密码验证
- 02通过 Inertia 共享认证状态
- 03将授权结果作为 props 传递
- 04Pundit 与 Action Policy 示例
- 05受保护路由与角色检查
- 06会话、CSRF 与历史记录安全指南
适用场景
- 01添加登录和注册流程
- 02向前端组件传递用户权限
- 03保护路由和管理区域
- 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'
Application 控制器辅助方法
# 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:
- 读取 Rails 设置的
XSRF-TOKENCookie - 在请求中发送
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
评论
支持 Markdown