发布于 2026-01-06 0 阅读
0

从零开始构建 Rails 身份验证:超越 Railscasts

从零开始构建 Rails 身份验证:超越 Railscasts

如果你需要在 Rails 应用中实现身份验证,Devise 是最安全的选择。自己编写身份验证机制无异于搬起石头砸自己的脚。

但使用 Devise 对我来说感觉不像是在写代码。它就像通过阅读网上的教程博客文章来设置你的新 Mac 一样。Devise 的文档非常完善,涵盖了你所有的问题。你只需按照说明操作,就能获得行业级别的安全保障。

但如果我们能够理解 Devise 以及身份验证的工作原理,那将是良好的编码实践。

所以我按照瑞恩·贝茨 (Ryan Bates) 的著名教程从头开始实现了它。但真正的动力来自贾斯汀·塞尔斯 (Justin Searls),他在最近的演讲“自私的程序员”中提到,他自己并不了解 Devise,所以为了他的一个业余项目从头开始实现了身份验证。他独自完成了所有常见的流程——注册、登录、忘记密码、重置密码等等——这帮助他“把应用程序的所有代码都记在脑子里”。(这也是你在参与项目的整个生命周期中应该保持的状态。)

我也做了同样的事情。但在主要工作流程完成后,我开始效仿 Devise 的方式实现其他功能。我直接克隆了他们的代码库,然后搜索某个功能的实现方式。Devise 支持的每个功能都考虑到了所有可能出现的特殊情况。我对此并不在意。所以我只提取了功能的核心部分并进行了编码。

我实现的功能包括:

  • 用户注册
  • 通过电子邮件和密码进行身份验证(登录和注销)
  • 记住用户在未登录状态下尝试访问的需要身份验证的页面,然后在用户登录后重定向到该页面。
  • 用户确认(只有已确认的用户才能执行某些/所有操作)
  • 忘记密码和重置密码

在本节的剩余部分,我将解释我是如何实现这些功能的。对于这里缺失的代码部分,您可以在实际的仓库中找到它们:https://github.com/npras/meaningless


用户数据库设计

这是用户迁移文件,其中显示了所有字段、索引和数据类型。

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name

      t.string :email, null: false
      t.string :password_digest

      t.string :remember_token

      t.string :password_reset_token
      t.datetime :password_reset_sent_at

      t.string :confirmation_token
      t.string :unconfirmed_email
      t.datetime :confirmation_sent_at
      t.datetime :confirmed_at

      t.timestamps
    end

    add_index :users, :email, unique: true
    add_index :users, :remember_token, unique: true

    # I like to use empty lines to group related code
    add_index :users, :password_reset_token, unique: true

    add_index :users, :confirmation_token, unique: true
    add_index :users, :unconfirmed_email, unique: true
  end
end

代码结构

Devise 的所有控制器都继承自某个类DeviseController。我希望我的身份验证功能也继承自某个类PrasDeviseController

(并非仅仅出于美观。之前我把用户创建(注册)放在 UsersController 里,这导致很难把所有通用代码提取到父控制器中。后来我发现 Devise 有一个叫做 registrations 的特殊机制,用户创建就在这里进行。这样我们就可以让 UsersController 处理非身份验证相关的操作,同时把用户创建代码提取到身份验证相关的代码中。好吧,这个命名确实有点花哨。

控制器PrasDevise承载了所有其他身份验证相关代码使用的通用方法。例如,如果您打算在任何身份验证表单中使用 reCAPTCHA 代码,那么这里是放置 reCAPTCHA 代码的绝佳位置。我到处都放了 reCAPTCHA 代码——注册、登录、密码重置等等,只是为了惹恼用户(其实就是我自己)。以下是用于 reCAPTCHA 的两个方法:https ://github.com/npras/meaningless/blob/master/app/controllers/pras_devise/pras_devise_controller.rb#L70-L79

既然控制器是限定作用域的,那么 URL 也最好限定作用域。以下是routes.rb文件的内容:

  scope module: :pras_devise do
    resources :registrations, only: [:new, :create]
    resources :confirmations, only: [:new, :show]
    resources :sessions, only: [:new, :create, :destroy]
    resources :password_resets, only: [:new, :edit, :create, :update]
  end

这些控制器文件都位于app/controllers/pras_devise/该文件夹中。

用户注册工作流程

我希望所有需要身份验证的操作都只允许已验证用户执行。已验证用户是指点击注册时发送到其邮箱的特殊链接并声明该链接属于自己的用户。

在此create操作中,我们仅通过字段查找/初始化用户,unconfirmed_email而不通过email字段。用户确认后,我们将从未确认字段中删除电子邮件地址。

    # in pras_devise/registrations_controller.rb
    def create
      @user = User.find_or_initialize_by(unconfirmed_email: user_params[:email])
      @user.attributes = user_params
      if @user.save
        @user.generate_token_and_send_instructions!(token_type: :confirmation)
        redirect_to root_url, notice: "Check your email with subject 'Confirmation instructions'"
      else
        render :new
      end
    end

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

(请注意创建操作中的第二行。我发现这是一种将类似哈希的数据结构分配给 ActiveRecord 对象所有属性的好方法。)

用户确认工作流程

user.generate_token_and_send_instructions!方法会生成一个唯一的 confirm_token,并向该用户发送一封包含该令牌链接的电子邮件。

  # in models/user.rb

  # token_type is:
  # confirmation for confirmation_token,
  # password_reset for password_reset_token
  # etc.
  def generate_token_and_send_instructions!(token_type:)
    generate_token(:"#{token_type}_token")
    self[:"#{token_type}_sent_at"] = Time.now.utc
    save!
    UserMailer.with(user: self).send(:"email_#{token_type}").deliver_later
  end

邮件发送方法如下所示:

  # in mailers/user_mailer.rb
  def email_confirmation
    @user = params[:user]
    @email = @user.unconfirmed_email
    @token = @user.confirmation_token

    mail to: @email, subject: "Confirmation instructions"
  end

邮件正文如下:

<p>Welcome <%= @email %>!</p>

<p>You can confirm your account email through the link below:</p>

<p><%= link_to 'Confirm my account', confirmation_url(@user, confirmation_token: @token) %></p>

链接看起来是这样的:http://example.com/confirmations/111?confirmation_token=sOmErandomToken123其中111是用户的 ID,但在这里无关紧要。我们只需要它,因为我们要以 RESTful 的方式定义相关的控制器操作。

confirmations_ocntroller#show实际操作中,我们通过参数查找用户confirmation_token。如果令牌尚未过期,则通过将令牌标记unconfirmed_email为 nil 并保存记录来确认用户身份。

# confirmation_controller.rb
    def show
      user = User.find_by(confirmation_token: params[:confirmation_token])

      if user.confirmation_token_expired?
        redirect_to new_registration_path, alert: "Confirmation token has expired. Signup again." and return
      end

      if user
        user.email, user.unconfirmed_email = user.unconfirmed_email, nil
        user.confirmed_at = Time.now.utc
        user.save
        redirect_to root_url, notice: "You are confirmed! You can now login."
      else
        redirect_to root_url, alert: "No user found for this token"
      end
    end

请注意,如果令牌过期,我们会重定向到注册页面。如果用户现在尝试使用相同的电子邮件地址注册,仍然可以成功,因为在注册页面中,registrations_controller#create我们会使用一次性令牌,User.find_or_initialize_by而不是User.new每次有人尝试注册时都使用一次性令牌。

签到/签退工作流程

这很简单。

  • 根据登录表单收到的电子邮件地址,从数据库中查找该用户。
  • 尝试使用传入的密码验证用户身份
  • 如果身份验证成功,则创建一个唯一的标识符remember_token,对其进行加密并将其保存在 cookie 中。
    def create
      if @user&.authenticate(params[:password])
        login!
        redirect_to after_sign_in_path_for(:user), notice: "Logged in!"
      else
        flash.now.alert = "Email or password is invalid"
        render :new
      end
    end

    private def login!
      unless @user.remember_token
        @user.generate_token(:remember_token)
        @user.save
      end
      if params[:remember_me]
        cookies.encrypted.permanent[:remember_token] = @user.remember_token
      else
        cookies.encrypted[:remember_token] = @user.remember_token
      end
    end

如果用户Remember me在登录表单中勾选了复选框,则我们会创建一个永久性 cookie;否则,它只是一个普通的 cookie。

如果身份验证成功,我们会将用户重定向到他们之前因未通过身份验证而尝试访问失败的页面。这部分代码直接取自 Devise,位于父控制器中PrasDeviseController

让我们来看看它的具体实现方式……

登录后重定向到指定页面。

为此,首先需要将用户访问的每个页面保存到一个会话中(使用类似 cookie 的方法)。但并非所有页面都需要保存。Devise 只保存满足以下所有条件的请求:

  • 请求应该是 GET 请求。其他任何方式都显得危险,而且实现起来也不容易。(这里有一个关于此问题的Stack Overflow 讨论,很有意思。)
  • 该请求不应是 Ajax 请求。
  • 请求应该针对的是一个并非来自任何 PrasDevise 控制器的操作。也就是说,它不应该针对登录、注册、忘记密码表单等页面。这样做没有意义。
  • 请求格式应仅为 HTML。

这类请求会被存储在带有特定键的会话 cookie 中:user_return_to。用户成功登录后,该sessions_controller#create操作会将他们重定向到正确的页面。

所以,这里有一个 before_action 回调函数,它会在每次向应用程序发出请求时被调用。

# in pras_devise_controller.rb

    before_action :store_user_location!, if: :storable_location?


    private def storable_location?
      request.get? &&
        is_navigational_format? &&
        !is_a?(PrasDevise::PrasDeviseController) &&
        !request.xhr?
    end

    private def is_navigational_format?
      ["*/*", :html].include?(request_format)
    end

    private def request_format
      @request_format ||= request.format.try(:ref)
    end

    private def store_user_location!
      # :user is the scope we are authenticating
      #store_location_for(:user, request.fullpath)
      path = extract_path_from_location(request.fullpath)
      session[:user_return_to] = path if path
    end

    private def parse_uri(location)
      location && URI.parse(location)
    rescue URI::InvalidURIError
      nil
    end

    private def extract_path_from_location(location)
      uri = parse_uri(location)
      if uri 
        path = remove_domain_from_uri(uri)
        path = add_fragment_back_to_path(uri, path)
        path
      end
    end

    private def remove_domain_from_uri(uri)
      [uri.path.sub(/\A\/+/, '/'), uri.query].compact.join('?')
    end

    private def add_fragment_back_to_path(uri, path)
      [path, uri.fragment].compact.join('#')
    end

    private def after_sign_in_path_for(resource_or_scope)
      if is_navigational_format?
        session.delete(:user_return_to) || root_url
      else
        session[:user_return_to] || root_url
      end
    end

(这一切都来自Devise。)

密码重置工作流程

重置密码需要 4 个步骤。

  • new显示用户输入电子邮件地址的表单。
  • 然后,它会将请求提交到create应用程序,应用程序会生成一个文件password_reset_token并将其发送到接收的电子邮件地址。
  • 用户随后会点击电子邮件中的链接,该链接会将他带到一个password edit页面,用户会通过password_reset_token该链接找到该页面。
  • 用户填写完新密码并提交表单后,系统将执行将update新密码保存到数据库的操作。

以下是控制器代码:

# password_resets_controller.rb

    def new
    end

    def create
      user = User.find_by(email: params[:email])
      user&.generate_token_and_send_instructions!(token_type: :password_reset)
      redirect_to root_url, notice: "If you had registered, you'd receive password reset email shortly"
    end

    def edit
      set_user
      redirect_to root_url, alert: "Cannot find user!" unless @user
    end

    def update
      set_user
      if (Time.now.utc - @user.password_reset_sent_at) > 2.hours
        redirect_to new_password_reset_path, alert: "Password reset has expired!"
      elsif @user.update(password_update_params)
        redirect_to root_url, notice: "Password has been reset!"
      else
        render :edit
      end
    end

    private def set_user
      @user = User.find_by(password_reset_token: params[:id])
    end

    private def password_update_params
      params
        .require(:user)
        .permit(:password, :password_confirmation)
    end

请注意,提交更新表单后,我们会确保密码重置邮件是最近发送的。我们不希望用户滥用此功能。

模板email_password_reset.html.erb如下所示:

To reset your password, click the URL below.

<%= link_to edit_password_reset_url(@user.password_reset_token), edit_password_reset_url(@user.password_reset_token) %>

If you did not request your password to be reset, just ignore this email and your password will continue to stay the same.

结论

深入了解任何库的底层原理都是一件非常棒的事情。借助 Devise 代码库中的示例代码和测试用例,我得以将各个部分拼凑起来,并了解了一些主要功能的工作原理。我还发现了许多简洁优美的代码示例,尤其是它们的测试用例。他们仅使用 minitest 和 mocha 就编写出了简短易读的测试用例。

只要你花时间带着好奇心去探索,就没有什么是神秘的。

文章来源:https://dev.to/npras/rails-authentication-from-scratch-going-beyond-railscasts-59i5