CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Ruby on Rails 实战入门/课时/认证与授权
RawBlame
课时目录12 节

Ruby on Rails 实战入门

  1. 01Rails 概览与项目结构
  2. 02路由设计
  3. 03控制器
  4. 04Active Record 模型
  5. 05查询与优化
  6. 06数据库适配
  7. 07视图与组件
  8. 08表单
  9. 09认证与授权
  10. 10资产与后台任务
  11. 11测试与调试
  12. 12部署与运维

认证与授权

Devise、Pundit 与 CanCanCan。

30 分钟

认证与授权

这一课你将能做到

  1. 用 has_secure_password 实现注册/登录/登出,密码以 bcrypt 哈希存库
  2. 用 Session + current_user + before_action 保护需要登录的页面
  3. 区分认证与授权,用 Pundit 风格的所有权检查防止越权访问

为什么要学

认证(你是谁)和授权(你能干什么)是 Web 应用的安全地基,也是面试必考题。Rails 7.1 之后官方把「手写认证」变成了推荐路径——has_secure_password + Session 几十行代码就能搞定,比引入 Devise 黑盒更能让你理解每个环节。理解了手写版,再用任何 gem 都是在做配置而不是祈祷。

对比本站:Spring Boot 用 Spring Security + JWT,过滤器链配置繁重;Rails 全栈模式用服务端 Session + Cookie,天然免疫 JWT 的「无法主动失效」难题。两种方案的取舍正是本课的对照主线。

先建立图像

认证(Authentication)= 你是谁
  登录表单 → 验密码 → session[:user_id] = 5 → 之后每个请求带 Cookie
                                                    │
授权(Authorization)= 你能干什么                      ▼
  @article.user == current_user ? 可以编辑 : 403/重定向

登录后每个请求的识别过程:
浏览器 Cookie(加密签名)
   → Rails 解出 session[:user_id]
   → current_user = User.find_by(id: session[:user_id])
   → 控制器动作里可用

密码存储的本质:password_digest 字段存的是 bcrypt 哈希(带盐、慢速),即使库被拖,攻击者也几乎无法反推明文。

跟着做

步骤 1:User 模型与注册

bin/rails generate model User email:string:uniq password_digest:string name:string
bin/rails db:migrate

Gemfile 确认有(Rails 7 默认已带,取消注释即可):

gem "bcrypt", "~> 3.1"
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  validates :email, presence: true, uniqueness: true,
            format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name, presence: true
  normalizes :email, with: ->(e) { e.strip.downcase }
end

has_secure_password 一行带来:password/password_confirmation 虚拟属性、保存时自动 bcrypt 哈希写入 password_digest、authenticate 验证方法。

# console 验证
user = User.create(email: "a@b.com", name: "A", password: "secret123", password_confirmation: "secret123")
user.password_digest   # => "$2a$12$..." 哈希,不是明文
user.authenticate("wrong")       # => false
user.authenticate("secret123")   # => user 对象

步骤 2:Session 登录/登出

bin/rails generate controller Sessions new create destroy
# config/routes.rb
get  "login",  to: "sessions#new"
post "login",  to: "sessions#create"
delete "logout", to: "sessions#destroy"
resources :users, only: [:new, :create]
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:email]&.downcase)
    if user&.authenticate(params[:password])
      reset_session                 # 防会话固定攻击
      session[:user_id] = user.id
      redirect_to root_path, notice: "登录成功"
    else
      flash.now[:alert] = "邮箱或密码错误"
      render :new, status: :unprocessable_entity
    end
  end

  def destroy
    reset_session
    redirect_to root_path, notice: "已退出登录"
  end
end
<%# app/views/sessions/new.html.erb %>
<h1>登录</h1>
<%= form_with url: login_path do |form| %>
  <%= form.email_field :email, placeholder: "邮箱", required: true %>
  <%= form.password_field :password, placeholder: "密码", required: true %>
  <%= form.submit "登录" %>
<% end %>

注意失败分支用 flash.now——render 不走重定向,普通 flash 会残留到下一个请求。

步骤 3:current_user 与登录保护

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  private

  def current_user
    @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
  end
  helper_method :current_user   # 视图也可用

  def require_login
    unless current_user
      redirect_to login_path, alert: "请先登录"
    end
  end
end
# 需要登录的控制器
class ArticlesController < ApplicationController
  before_action :require_login, except: [:index, :show]
end

导航栏根据登录态切换:

<% if current_user %>
  <span>你好,<%= current_user.name %></span>
  <%= button_to "退出", logout_path, method: :delete %>
<% else %>
  <%= link_to "登录", login_path %>
<% end %>

步骤 4:授权——只能改自己的文章

登录≠有权操作一切。给文章加归属:

bin/rails generate migration AddUserToArticles user:references
bin/rails db:migrate
class Article < ApplicationRecord
  belongs_to :user
end

控制器里做所有权检查:

class ArticlesController < ApplicationController
  before_action :require_login, except: [:index, :show]
  before_action :set_article, only: [:show, :edit, :update, :destroy]
  before_action :require_owner, only: [:edit, :update, :destroy]

  def create
    @article = current_user.articles.build(article_params)  # 自动挂当前用户
    # ...
  end

  private

  def require_owner
    unless @article.user == current_user
      redirect_to articles_path, alert: "没有权限操作这篇文章"
    end
  end
end

步骤 5:复杂权限上 Pundit(了解)

角色多了(管理员/编辑/普通用户)后,if 判断散落各处会失控。Pundit 把授权规则集中成 Policy 类:

# app/policies/article_policy.rb
class ArticlePolicy < ApplicationPolicy
  def update?
    user.admin? || record.user == user
  end
end
# 控制器一行
authorize @article   # 无权限自动抛 Pundit::NotAuthorizedError → 403

中小项目用 require_owner 这类 before_action 足够;权限矩阵超过 5 条规则再引入 Pundit。

讲清楚要点

  1. 永不存明文密码:has_secure_password 用 bcrypt(自适应慢哈希,带盐)。看到数据库里 password 字段(而非 password_digest)就是红色警报。
  2. 登录成功必须 reset_session:防止会话固定攻击(攻击者预设 session id 给你用)。登出同理。
  3. Session vs JWT:Rails 默认服务端会话 + 加密 Cookie,能即时吊销、无需前端处理令牌;JWT 无状态适合纯 API 多端场景,但主动失效麻烦。全栈 Rails 选 Session 几乎总是对的。
  4. 认证和授权是两道独立关卡:先 require_login(你是谁),再 require_owner(这是你的吗)。漏掉后者就是经典的 IDOR 越权漏洞——改 URL 里的 id 就能删别人文章。
  5. 失败提示别泄露信息:「邮箱或密码错误」合并提示,别说「该邮箱未注册」——否则等于告诉攻击者哪些邮箱可撞库。

你可能会懵的地方

  • flash.now 和普通 flash 区别? 普通 flash 存活到「下一个请求」,配 redirect 用;render 是同请求内渲染,普通 flash 会多残留一次,flash.now 只在本次渲染有效。

  • &.(安全导航)是什么? user&.authenticate(...) 等价于 user && user.authenticate(...)。邮箱不存在时 user 为 nil,直接调方法会 NoMethodError,&. 短路返回 nil/false。

  • Cookie 里的 session 能被篡改吗? Rails 的 Cookie 存储经过加密 + 签名(密钥来自 credentials),用户看不到也改不了内容,改一个字节服务端验签即失败。

  • 「记住我」怎么做? 生成随机 token 存数据库(哈希后)+ 长期 Cookie,登录时比对。不要图省事把 user_id 直接塞长期 Cookie。Rails 7.1+ 可用 generates_token_for 简化。

  • API 模式还能用 session 吗? 前后端分离的纯 API(本站 Next.js 模式)通常用 Token/JWT,因为跨域 Cookie 受限。Rails API 模式(--api)默认关掉 session 中间件。

错法对照

错法问题正解
数据库存 password 明文列拖库即全军覆没password_digest + has_secure_password
登录后只设 session[:user_id] 不 reset会话固定攻击面登录/登出都 reset_session
只查 id 不查归属:Article.find(params[:id]).updateIDOR 越权改别人数据先 set_article 再 require_owner
登录失败提示「邮箱不存在」泄露注册信息助撞库「邮箱或密码错误」合并提示
render 分支用普通 flash提示残留到下个请求render 配 flash.now

卡点急救

  1. authenticate 总返回 false:确认传的是明文密码(方法内部哈希比对);确认用户创建时 password 和 password_confirmation 一致且 ≥ bcrypt 最小长度;检查是否手贱直接给 password_digest 赋了值。
  2. 登录后刷新又变未登录:Cookie 没写入——检查是否用了 render 而非 redirect(session 写入靠响应 Set-Cookie,render 后点击下一页才生效属正常);浏览器禁了 Cookie;开发环境换过端口导致 Cookie 域不匹配。
  3. undefined method 'articles' for nil:在未登录状态访问了 current_user.articles。漏加 before_action :require_login。

面试常问

  • 为什么不能存明文密码?bcrypt 为什么比 MD5/SHA256 好? 答:库会被拖,明文 = 用户全平台账号陪葬(撞库)。MD5/SHA256 是快哈希,GPU 每秒可试数十亿次;bcrypt 是自适应慢哈希(cost 因子可调),单次哈希故意耗时 ~100ms,且内置随机盐,相同明文哈希结果不同,彩虹表失效。慢,正是密码哈希要的特性。

  • Cookie Session 和 JWT 的取舍? 答:Cookie Session 状态在服务端,可即时吊销(踢人下线、改密码即失效),但多实例需共享存储(Rails 默认加密 Cookie 存储则天然分布式);JWT 无状态易扩展,但签发后到期前无法主动失效,且令牌通常较大、需前端小心保管防 XSS 窃取。同源全栈应用用 Cookie Session,跨端 API 用 JWT。

  • 如何防止越权(IDOR)? 答:所有按 id 取资源的写操作都要过所有权/权限检查:@article.user == current_user 或 Policy 对象。Rails 惯用 current_user.articles.find(params[:id]) 把查询范围直接限定在当前用户名下——查不到即 404,比事后判断更干净。

自测三问

  1. has_secure_password 要求数据库有什么字段?它提供了哪些方法?
  2. 为什么登录成功要 reset_session?防的是什么攻击?
  3. current_user.articles.find(params[:id]) 相比 Article.find(params[:id]) 安全在哪?

小结一句话

认证认人、授权认事:has_secure_password 管密码,Session 管登录态,所有权检查管越权——三道关卡缺一不可。

延伸

  • Securing Rails Applications(官方安全指南)
  • 下一课:资产与后台任务——Propshaft、Importmap 与 Active Job/Solid Queue。
加载本节练习…
← 上一课表单下一课 →资产与后台任务