CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
认证与授权
这一课你将能做到
用 has_secure_password 实现注册/登录/登出,密码以 bcrypt 哈希存库
用 Session + current_user + before_action 保护需要登录的页面
区分认证与授权,用 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。
讲清楚要点
永不存明文密码 :has_secure_password 用 bcrypt(自适应慢哈希,带盐)。看到数据库里 password 字段(而非 password_digest)就是红色警报。
登录成功必须 reset_session :防止会话固定攻击(攻击者预设 session id 给你用)。登出同理。
Session vs JWT :Rails 默认服务端会话 + 加密 Cookie,能即时吊销、无需前端处理令牌;JWT 无状态适合纯 API 多端场景,但主动失效麻烦。全栈 Rails 选 Session 几乎总是对的。
认证和授权是两道独立关卡 :先 require_login(你是谁),再 require_owner(这是你的吗)。漏掉后者就是经典的 IDOR 越权漏洞——改 URL 里的 id 就能删别人文章。
失败提示别泄露信息 :「邮箱或密码错误」合并提示,别说「该邮箱未注册」——否则等于告诉攻击者哪些邮箱可撞库。
你可能会懵的地方
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]).update IDOR 越权改别人数据 先 set_article 再 require_owner 登录失败提示「邮箱不存在」 泄露注册信息助撞库 「邮箱或密码错误」合并提示 render 分支用普通 flash 提示残留到下个请求 render 配 flash.now
卡点急救
authenticate 总返回 false :确认传的是明文密码(方法内部哈希比对);确认用户创建时 password 和 password_confirmation 一致且 ≥ bcrypt 最小长度;检查是否手贱直接给 password_digest 赋了值。
登录后刷新又变未登录 :Cookie 没写入——检查是否用了 render 而非 redirect(session 写入靠响应 Set-Cookie,render 后点击下一页才生效属正常);浏览器禁了 Cookie;开发环境换过端口导致 Cookie 域不匹配。
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,比事后判断更干净。
自测三问
has_secure_password 要求数据库有什么字段?它提供了哪些方法?
为什么登录成功要 reset_session?防的是什么攻击?
current_user.articles.find(params[:id]) 相比 Article.find(params[:id]) 安全在哪?
小结一句话 认证认人、授权认事:has_secure_password 管密码,Session 管登录态,所有权检查管越权——三道关卡缺一不可。
延伸