在线版的内容可能落后于电子书,如果想及时获得更新,请购买电子书

第 9 章 高级登录功能

第 8 章实现的登录系统完全可用,不过大多数现代的网站都能记住用户的登录状态,当用户关闭浏览器后再次访问网站时,仍是登录状态。本章使用持久 cookie 实现这种行为。首先,我们将实现自动记住用户登录状态的功能(9.1 节),这是 Bitbucket 和 GitHub 等网站采用的策略。随后,我们将提供一个“记住我”复选框,让用户自己选择是否记住登录状态,这是 Twitter 和 Facebook 等网站采用的策略。

第 8 章已经为这个演示应用实现了完整的登录系统,如果愿意,你可以跳过本章,从第 10 章开始阅读(一直到第 13 章)。不过学习如何实现“记住我”功能也有好处,能为账户激活(第 11 章)和密码重设(第 12 章)奠定坚实的基础。而且,本章也能让你体验计算机的神奇,你曾在网上无数次见到这种“记住我”登录表单,现在终于有机会自己动手实现了。

9.1 记住我

本节添加一个功能,让应用记住用户的登录状态,即使关闭浏览器之后再访问,仍能记住用户的登录状态。这个“记住我”功能自动生效,除非用户退出,否则会一直处于登录状态。我们的实现方式还便于添加一个“记住我”复选框(9.2 节)。

与往常一样,我建议在继续之前切换到一个主题分支:

$ git checkout -b advanced-login

9.1.1 记忆令牌和摘要

8.2 节使用 Rails 提供的 session 方法存储用户的 ID,但是浏览器关闭后这个信息就不见了。本节,我们将迈出实现持久会话的第一步:生成使用 cookies 方法创建持久 cookie 所需的记忆令牌(remember token),以及验证令牌所需的安全记忆摘要(remember digest)。

8.2.1 节说过,使用 session 方法存储的信息默认情况下就是安全的,但使用 cookies 方法存储的信息则不然。具体而言,持久 cookie 有被会话劫持的风险,攻击者可以使用盗取的记忆令牌以某个用户的身份登录。盗取 cookie 中的信息主要有四种途径:(1)使用包嗅探工具截获不安全网络中传输的 cookie;[1](2)获取包含记忆令牌的数据库;(3)使用跨站脚本(Cross-Site Scripting,简称 XSS)攻击;(4)获取已登录用户的设备访问权。我们在 7.5 节启用了全站 SSL,这样能避免别人嗅探网络中传输的数据,因此解决了第一个问题。为了解决第二个问题,我们不会存储记忆令牌本身,而是存储令牌的哈希摘要——这种方法和 6.3 节一样,不存储原始密码,而是存储密码摘要。[2]Rails 会转义插入视图模板中的内容,所以自动解决了第三个问题。对于最后一个问题,虽然没有万无一失的方法能避免攻击者获取已登录用户电脑的访问权,不过我们可以在每次用户退出后修改令牌,并且签名加密存储在浏览器中的敏感信息,尽量降低第四个问题导致的不良影响。

经过上述分析,我们计划按照下面的方式实现持久会话:

  1. 生成随机字符串,用作记忆令牌;

  2. 把这个令牌存入浏览器的 cookie 中,并把过期时间设为未来的某个日期;

  3. 在数据库中存储令牌的摘要;

  4. 在浏览器的 cookie 中存储加密后的用户 ID;

  5. 如果 cookie 中有用户的 ID,就用这个 ID 在数据库中查找用户,并且检查 cookie 中的记忆令牌和数据库中的哈希摘要是否匹配。

注意,最后一步和登入用户很相似:使用电子邮件地址检索用户,然后(使用 authenticate 方法)验证提交的密码和密码摘要是否匹配(代码清单 8.7)。可见,我们的实现方式和 has_secure_password 差不多。

首先,我们把所需的 remember_digest 属性加入 User 模型,如图 9.1 所示。

user model remember digest
图 9.1:添加 remember_digest 属性后的 User 模型

为了把图 9.1 中的数据模型添加到应用中,我们要生成一个迁移:

$ rails generate migration add_remember_digest_to_users remember_digest:string

(可以和 6.3.1 节添加密码摘要的迁移比较一下。)与之前的迁移一样,迁移的名称以 _to_users 结尾,这么做是为了告诉 Rails 这个迁移是用来修改 users 表的。因为我们还指定了属性(remember_digest)及其类型(string),所以 Rails 会自动为我们生成迁移代码,如代码清单 9.1 所示。

代码清单 9.1:生成的迁移,用于添加记忆摘要
db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

我们不会通过记忆摘要检索用户,所以没必要在 remember_digest 列上添加索引,因此可以直接使用上述自动生成的迁移:

$ rails db:migrate

现在我们要决定使用什么做记忆令牌。不同的方式基本上都差不多,其实只要是一定长度的随机字符串都行。Ruby 标准库中 SecureRandom 模块的 urlsafe_base64 方法刚好能满足我们的需求。[3]这个方法返回长度为 22 的随机字符串,包含字符 A-Z、a-z、0-9、“-”和“_”(每一位都有 64 种可能,因此方法名中有“base64”)。典型的 base64 字符串如下所示:

$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

就像两个用户可以使用相同的密码一样,[4]记忆令牌也没必要是唯一的,不过如果唯一的话,安全性更高。[5]对 base64 字符串来说,22 个字符中的每一个都有 64 种取值可能,所以两个记忆令牌“碰撞”的几率小到可以忽略,只有 1/6422 = 2-132 ≈ 10-40[6]而且,使用可在 URL 中安全使用的 base64 字符串(如 urlsafe_base64 方法的名称所示),我们还能在账户激活和密码重设链接中使用类似的令牌。

记住用户的登录状态要创建一个记忆令牌,并且在数据库中存储这个令牌的摘要。我们已经定义了 digest 方法,并且在测试固件中用过(代码清单 8.21)。基于上述分析,现在我们可以定义一个 new_token 方法,用于创建新令牌。和 digest 方法一样,新建令牌的方法也不需要用户对象,所以也定义为类方法,[7]代码清单 9.2 所示。

代码清单 9.2:添加生成令牌的方法
app/models/user.rb
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 返回指定字符串的哈希摘要
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # 返回一个随机令牌
  def User.new_token
    SecureRandom.urlsafe_base64
  end
end

我们计划定义 user.remember 方法把记忆令牌和用户关联起来,并且把相应的记忆摘要存入数据库。代码清单 9.1 中的迁移已经添加了 remember_digest 属性,但是还没有 remember_token 属性。我们要找到一种方法,通过 user.remember_token 获取令牌(为了存入 cookie),但又不在数据库中存储令牌。6.3 节解决过类似的问题——使用虚拟属性 password 和数据库中的 password_digest 属性。其中,虚拟属性 passwordhas_secure_password 方法自动创建。但是,我们要自己编写代码创建 remember_token 属性,方法是使用 4.4.5 节用过的 attr_accessor,创建一个可访问的属性:

class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  def remember
    self.remember_token = ...
    update_attribute(:remember_digest, ...)
  end
end

注意 remember 方法中第一行代码的赋值操作。根据 Ruby 处理对象内部赋值操作的规则,如果没有 self,创建的是一个名为 remember_token 的局部变量——这并不是我们想要的行为。使用 self 的目的是确保把值赋给用户的 remember_token 属性。(现在你应该知道为什么 before_save 回调中要使用 self.email,而不是 email 了吧(代码清单 6.32)。)remember 方法的第二行代码使用 update_attribute 方法更新记忆摘要。(6.1.5 节说过,这个方法会跳过验证。这里必须跳过验证,因为我们无法获取用户的密码和密码确认。)

基于上述分析,创建有效令牌和摘要的方法是:首先使用 User.new_token 创建一个新记忆令牌,然后使用 User.digest 生成摘要,最后更新数据库中的记忆摘要。实现这个步骤的 remember 方法如代码清单 9.3 所示。

代码清单 9.3:在 User 模型中添加 remember 方法 GREEN
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 为了持久保存会话,在数据库中记住用户
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end
练习
  1. 打开控制台,把数据库中的第一个用户赋值给 user 变量,然后直接调用 remember 方法,确认它可用。remember_token 的值与 remember_digest 的值有何区别?

  2. 代码清单 9.3 中,我们定义了生成令牌和摘要的类方法,前面都加上了 User。这么定义没问题,而且因为我们会使用 User.new_tokenUser.digest,或许这样定义意思更明确。不过,定义类方法有两种更常用的方式,一种有点让人困惑,一种极其让人困惑。运行测试组件,确认代码清单 9.4(有点让人困惑)和代码清单 9.5(极其让人困惑)中的实现方式是正确的。(注意,在代码清单 9.4代码清单 9.5 中,selfUser 类,而 User 模型中的其他 self 都是用户对象实例。这就是让人困惑的根源所在。)

代码清单 9.4:使用 self 定义生成令牌和摘要的方法 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 返回指定字符串的哈希摘要
  def self.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # 返回一个随机令牌
  def self.new_token
    SecureRandom.urlsafe_base64
  end
  .
  .
  .
end
代码清单 9.5:使用 class << self 定义生成令牌和摘要的方法 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  class << self
    # 返回指定字符串的哈希摘要
    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                    BCrypt::Engine.cost
      BCrypt::Password.create(string, cost: cost)
    end

    # 返回一个随机令牌
    def new_token
      SecureRandom.urlsafe_base64
    end
  end
  .
  .
  .

9.1.2 登录时记住登录状态

定义好 user.remember 方法之后,我们可以创建持久会话了,方法是,把(加密后的)用户 ID 和记忆令牌作为持久 cookie 存入浏览器。为此,我们要使用 cookies 方法。这个方法和 session 一样,可以视为一个散列。一个 cookie 有两部分信息,一个是 value(值),一个是可选的 expires(过期日期)。例如,我们可以创建一个值为记忆令牌,20 年后过期的 cookie,实现持久会话:

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

(这里使用了一个便利的 Rails 时间辅助方法,参见旁注 9.1。 )Rails 应用经常使用 20 年后过期的 cookie,所以 Rails 提供了一个特殊的方法 permanent,用于创建这种 cookie,所以上述代码可以简写为:

cookies.permanent[:remember_token] = remember_token

这样写,Rails 会自动把过期时间设为 20.years.from_now

我们可以参照 session 方法(代码清单 8.14),使用下面的方式把用户的 ID 存入 cookie:

cookies[:user_id] = user.id

但是这种方式存储的是纯文本,攻击者很容易窃取用户的账户。为了避免这种情况发生,我们要对 cookie 签名,存入浏览器之前安全加密 cookie:[8]

cookies.signed[:user_id] = user.id

因为我们想让用户 ID 和持久记忆令牌配对,所以也要持久存储用户 ID。为此,我们可以串联调用 signedpermanent 方法:

cookies.permanent.signed[:user_id] = user.id

存储 cookie 后,再访问页面时可以使用下面的代码检索用户:

User.find_by(id: cookies.signed[:user_id])

其中,cookies.signed[:user_id] 会自动解密 cookie 中的用户 ID。然后,再使用 bcrypt 确认 cookies[:remember_token]代码清单 9.3 生成的 remember_digest 是否匹配。(你可能想知道为什么不能只使用签名的用户 ID。如果没有记忆令牌,攻击者一旦知道加密的 ID,就能以这个用户的身份登录。但是按照我们目前的设计方式,就算攻击者同时获得了用户 ID 和记忆令牌,至多只能维持登录状态到真正的用户退出。)

最后一步是,确认记忆令牌与用户的记忆摘要匹配。对现在这种情况来说,使用 bcrypt 确认是否匹配有很多等效的方法。如果查看安全密码的源码,你会发现下面这个比较语句:[9]

BCrypt::Password.new(password_digest) == unencrypted_password

这里,我们需要的代码如下:

BCrypt::Password.new(remember_digest) == remember_token

仔细一想,这行代码有点儿奇怪:看起来是直接比较 bcrypt 计算得到的密码哈希和令牌,那么,要使用 == 就得解密摘要。可是,使用 bcrypt 的目的是为了得到不可逆的哈希值,所以这么想是不对的。研究 bcrypt gem 的源码后你会发现,bcrypt 重新定义了 ==,上述代码其实等效于:

BCrypt::Password.new(remember_digest).is_password?(remember_token)

这种写法没使用 ==,而是使用返回布尔值的 is_password? 方法进行比较。因为这么写意思更明确,所以,在应用代码中我们将这么写。

基于上述分析,我们可以在 User 模型中定义 authenticated? 方法,比较摘要和令牌。这个方法的作用类似于 has_secure_password 提供用来认证用户的 authenticate 方法(代码清单 8.15)。authenticated? 方法的定义如代码清单 9.6 所示。(虽然代码清单 9.6 中的 authenticated? 方法和记忆令牌联系紧密,不过在其他情况下也很有用,第 11 章会改写这个方法,让它的使用范围更广。)

代码清单 9.6:在 User 模型中添加 authenticated? 方法
app/models/user.rb
class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

注意,authenticated? 方法中的 remember_token 参数与代码清单 9.3 中使用 attr_accessor :remember_token 定义的 remember_token 不同,它是方法内的局部变量。(这个参数指代记忆令牌,使用与方法同名的名称一点也不奇怪。)还要注意 remember_digest 属性的写法,这与写成 self.remember_digest 的作用一样;self.remember_digest 调用的是方法,与第 6 章中的 nameemail 类似,由 Active Record 根据数据库中的列名自动创建。

现在可以记住用户的登录状态了。我们要在 log_in 后面调用 remember 辅助方法,如代码清单 9.7 所示。

代码清单 9.7:登录并记住登录状态
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      remember user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out
    redirect_to root_url
  end
end

与登录功能一样,代码清单 9.7 把真正的工作交给 Sessions 辅助模块中的方法完成。在 Sessions 辅助模块中,我们要定义一个名为 remember 的方法,调用 user.remember,从而生成一个记忆令牌,并把对应的摘要存入数据库;然后使用 cookies 创建长久的 cookie,保存用户 ID 和记忆令牌。结果如代码清单 9.8 所示。

代码清单 9.8:记住用户
app/helpers/sessions_helper.rb
module SessionsHelper

  # 登入指定的用户
  def log_in(user)
    session[:user_id] = user.id
  end

  # 在持久会话中记住用户
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 返回当前登录的用户(如果有的话)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

  # 如果用户已登录,返回 true,否则返回 false
  def logged_in?
    !current_user.nil?
  end

  # 退出当前用户
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

现在,用户登录后会被记住,因为在浏览器中存储了有效的记忆令牌。但是这还没有什么实际作用,因为代码清单 8.16 中定义的 current_user 方法只能处理临时会话:

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

对持久会话来说,如果临时会话中有 session[:user_id],那么使用它检索用户;否则,应该检查 cookies[:user_id],检索(并登入)持久会话中存储的用户。实现方式如下:

if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.signed[:user_id]
  user = User.find_by(id: cookies.signed[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

(这里沿用了代码清单 8.7 中使用的 user && user.authenticated 模式。)上述代码是可以使用,但注意,其中重复使用了 sessioncookies。我们可以去除重复,写成这样:

if (user_id = session[:user_id])
  @current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
  user = User.find_by(id: user_id)
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

改写后使用了常见但有点儿让人困惑的结构:

if (user_id = session[:user_id])

别被外观迷惑了,这不是比较语句(比较时应该使用双等号 ==),而是赋值语句。如果读出来,不能念成“如果用户 ID 等于会话中的用户 ID”,应该是“如果会话中有用户的 ID,把会话中的 ID 赋值给 user_id”。[10]

按照上述分析定义 current_user 辅助方法,如代码清单 9.9 所示。

代码清单 9.9:更新 current_user 方法,支持持久会话 RED
app/helpers/sessions_helper.rb
module SessionsHelper

  # 登入指定的用户
  def log_in(user)
    session[:user_id] = user.id
  end

  # 在持久会话中记住用户
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 返回 cookie 中记忆令牌对应的用户
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end

  # 如果用户已登录,返回 true,否则返回 false
  def logged_in?
    !current_user.nil?
  end

  # 退出当前用户
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

现在,新登录的用户能正确记住登录状态了。你可以确认一下:登录后关闭浏览器,再打开浏览器,重新访问演示应用,检查是否还是已登录状态。如果愿意,甚至还可以直接查看浏览器中的 cookie,如图 9.2 所示。[11]

现在我们的应用还有一个问题:无法清除浏览器中的 cookie(除非等到 20 年后),因此用户无法退出。这正是测试应该捕获的问题,而且目前测试的确无法通过:

代码清单 9.10RED
$ rails test
练习
  1. 在你的浏览器中查看 cookie,确认登录后 cookie 中有记忆令牌和加密的用户 ID。

  2. 直接在控制台中确认代码清单 9.6 中定义的 authenticated? 方法行为正确。

9.1.3 忘记用户

为了让用户退出,我们要定义一些和记住用户相对的方法,忘记用户。最终实现的 user.forget 方法,把记忆摘要的值设为 nil,即撤销 user.remember 方法的操作,如代码清单 9.11 所示。

代码清单 9.11:在 User 模型中添加 forget 方法 RED
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # 忘记用户
  def forget
    update_attribute(:remember_digest, nil)
  end
end

然后我们可以定义 forget 辅助方法,忘记持久会话,然后在 log_out 辅助方法中调用 forget,如代码清单 9.12 所示。forget 方法先调用 user.forget,然后再从 cookie 中删除 user_idremember_token

代码清单 9.12:退出持久会话 GREEN
app/helpers/sessions_helper.rb
module SessionsHelper

  # 登入指定的用户
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 忘记持久会话
  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 退出当前用户
  def log_out
    forget(current_user)
    session.delete(:user_id)
    @current_user = nil
  end
end

此时,测试组件应该可以通过:

代码清单 9.13GREEN
$ rails test
练习
  1. 退出后在你的浏览器中确认相应的 cookie 被删除了。

9.1.4 两个小问题

现在还有两个相互之间有关系的小问题待解决。第一个,虽然只有登录后才能看到退出链接,但一个用户可能会同时打开多个浏览器窗口访问网站,如果用户在一个窗口中退出了,再在另一个窗口中点击退出链接的话会导致错误,这是因为 log_out 方法中使用了 forget(current_user)代码清单 9.12)。[12]我们可以限制只有已登录的用户才能退出,从而解决这个问题。

第二个问题,用户可能会在不同的浏览器中登录(登录状态被记住),例如 Chrome 和 Firefox,如果用户在一个浏览器中退出,而另一个浏览器中没有退出,就会导致问题。[13]假如用户在 Firefox 中退出了,那么记忆摘要的值变成了 nil(通过代码清单 9.11 中的 user.forget)。在 Firefox 中没什么问题,因为代码清单 9.12 中的 log_out 方法删除了用户的 ID,所以下面高亮的两行判断的结果都是 false

# 返回 cookie 中记忆令牌对应的用户
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

结果是,代码运行到 current_user 方法的末尾,返回 nil

而如果关闭了 Chrome,session[:user_id] 会变成 nil(因为关闭浏览器后 session 中的值自动过期),但是 cookie 中的用户 ID 仍然存在。这意味着,重启 Chrome 后,还会从数据库中获取相应的用户:

# 返回 cookie 中记忆令牌对应的用户
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

因此,内层 if 条件语句会执行下述表达式:

user && user.authenticated?(cookies[:remember_token])

因为 user 不是 nil,第二个表达式会执行,从而导致错误抛出。这是因为在 Firefox 中退出时记忆摘要被删除了(代码清单 9.11),在 Chrome 中访问应用时调用下述代码传入的记忆摘要是 nil,导致 bcrypt 抛出异常:

BCrypt::Password.new(remember_digest).is_password?(remember_token)

若想解决这个问题,authenticated? 方法要返回 false

这正是测试驱动开发的优势所在,所以在解决之前,我们先编写测试捕获这两个小问题。我们先让代码清单 8.31 中的集成测试失败,如代码清单 9.14 所示。

代码清单 9.14:测试用户退出 RED
test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "login with valid information followed by logout" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    assert is_logged_in?
    assert_redirected_to @user
    follow_redirect!
    assert_template 'users/show'
    assert_select "a[href=?]", login_path, count: 0
    assert_select "a[href=?]", logout_path
    assert_select "a[href=?]", user_path(@user)
    delete logout_path
    assert_not is_logged_in?
    assert_redirected_to root_url
    # 模拟用户在另一个窗口中点击退出链接
    delete logout_path
    follow_redirect!
    assert_select "a[href=?]", login_path
    assert_select "a[href=?]", logout_path,      count: 0
    assert_select "a[href=?]", user_path(@user), count: 0
  end
end

第二个 delete logout_path 会抛出异常,因为没有当前用户,从而导致测试组件无法通过:

代码清单 9.15RED
$ rails test

在应用代码中,我们只需在 logged_in? 返回 true 时调用 log_out 即可,如代码清单 9.16 所示。

代码清单 9.16:只有登录后才能退出 GREEN
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

第二个问题涉及到两种不同的浏览器,在集成测试中很难模拟,不过直接在 User 模型层测试很简单。我们只需创建一个没有记忆摘要的用户(setup 方法中定义的 @user 变量就没有),再调用 authenticated? 方法即可,如代码清单 9.17 所示。(注意,我们直接使用空记忆令牌,因为还没用到这个值之前就会发生错误。)

代码清单 9.17:测试没有摘要时 authenticated? 方法的行为 RED
test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
  test "authenticated? should return false for a user with nil digest" do
    assert_not @user.authenticated?('')
  end
end

因为 BCrypt::Password.new(nil) 会抛出异常,所以测试组件不能通过:

代码清单 9.18RED
$ rails test

为了修正这个问题,让测试通过,记忆摘要的值为 nil 时,authenticated? 要返回 false,如代码清单 9.19 所示。

代码清单 9.19:更新 authenticated? 方法,处理没有记忆摘要的情况 GREEN
app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # 忘记用户
  def forget
    update_attribute(:remember_digest, nil)
  end
end

如果记忆摘要的值为 nil,直接使用 return 关键字返回。这种方式经常用到,目的是强调其后的代码会被忽略。等价的代码如下:

if remember_digest.nil?
  false
else
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

这样写也行,但我喜欢明确返回的版本,而且也稍微简短一些。

代码清单 9.19 那样修改之后,测试组件应该可以通过了,说明这两个小问题都解决了:

代码清单 9.20GREEN
$ rails test
练习
  1. 代码清单 9.16 中添加的代码注释掉,在已登录状态下打开两个浏览器标签页,在一个标签页中退出,再点击另一个标签页中的“Log out”(退出)链接,确认第一个小问题存在。

  2. 代码清单 9.19 中添加的代码注释掉,然后在一个浏览器中退出,再打开另一个浏览器,确认第二个小问题存在。

  3. 把前两题的注释改回去,确认测试组件又可以通过了。

9.2 “记住我”复选框

至此,我们的应用已经实现了完整且专业的身份验证系统。最后,我们来看一下如何使用“记住我”复选框让用户选择是否记住登录状态。包含这个复选框的登录表单构思图如图 9.3 所示。

为了实现这个构思,我们首先要在登录表单(代码清单 8.4)中添加一个复选框。与标注(label)、文本字段、密码字段和提交按钮一样,复选框也可以使用 Rails 提供的辅助方法创建。不过,为了得到正确的样式,我们要把复选框嵌套在标注中,如下所示:

<%= f.label :remember_me, class: "checkbox inline" do %>
  <%= f.check_box :remember_me %>
  <span>Remember me on this computer</span>
<% end %>

把这段代码添加到登录表单,得到的视图如代码清单 9.21 所示。

login remember me mockup
图 9.3:构思“记住我”复选框
代码清单 9.21:在登录表单中添加“记住我”复选框
app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(:session, url: login_path) do |f| %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>Remember me on this computer</span>
      <% end %>

      <%= f.submit "Log in", class: "btn btn-primary" %>
    <% end %>

    <p>New user? <%= link_to "Sign up now!", signup_path %></p>
  </div>
</div>

代码清单 9.21 中使用了 CSS 类 checkboxinline,Bootstrap 使用这两个类把复选框和文本(“Remember me on this computer”)放在同一行。为了完善样式,我们还要再定义一些 CSS 规则,如代码清单 9.22 所示。得到的登录表单如图 9.4 所示。

login form remember me
图 9.4:添加“记住我”复选框后的登录表单
代码清单 9.22:“记住我”复选框的 CSS 规则
app/assets/stylesheets/custom.scss
.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}

修改登录表单后,当用户勾选这个复选框时,记住用户的登录状态,否则不记住。因为前一节的工作做得很好,现在实现起来只需一行代码就行。提交登录表单后,params 散列中包含一个基于复选框状态的值(你可以使用有效信息填写登录表单,然后提交,看一下页面底部的调试信息)。如果勾选了复选框,params[:session][:remember_me] 的值是 '1',否则是 '0'

我们可以检查 params 散列中相关的值,根据提交的值决定是否记住用户:[14]

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

根据旁注 9.2 中的说明,这种 if-then 分支语句可以使用三元运算符(ternary operator)变成一行:[15]

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

Sessions 控制器 create 动作(代码清单 9.7)中的 remember user 替换成这行代码之后,得到的代码非常简洁,如代码清单 9.23 所示。(现在你应该可以理解代码清单 8.21 中使用三元运算符定义 cost 变量的代码了。)

代码清单 9.23:处理提交的“记住我”复选框
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if user && user.authenticate(params[:session][:password])
      log_in user
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end

至此,我们的登录系统完成了。你可以在浏览器中勾选或不勾选“记住我”确认一下。

练习

  1. 直接在浏览器中查看 cookie,确认“记住我”复选框起作用了。

  2. 在控制台中输入旁注 9.2 中的示例,学习三元运算符的各种行为。

9.3 测试“记住我”功能

“记住我”功能虽然可以使用了,但是我们还得编写一些测试,确认表现正常。测试的目的之一是捕获实现方式中可能出现的错误,这一点前文已经讨论。更重要的原因是,实现持久会话的代码现在完全没有测试。编写测试时要使用一些小技巧,但能得到更强大的测试组件。

9.3.1 测试“记住我”复选框

处理“记住我”复选框时(代码清单 9.23),我最初编写的代码是:

params[:session][:remember_me] ? remember(user) : forget(user)

而正确的代码应该是:

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

params[:session][:remember_me] 的值不是 '0' 就是 '1',都是真值,所以总是返回 true,应用会一直以为勾选了“记住我”。这正是测试能捕获的问题。

因为记住登录状态之前用户要先登录,所以我们首先要定义一个辅助方法,在测试中登入用户。在代码清单 8.23 中,我们使用 post 方法发送有效的 session 散列,登入用户,但是每次都这么做有点麻烦。为了避免不必要的重复,我们要编写一个辅助方法,名为 log_in_as,登入用户。

登入用户的方法在不同类型的测试中有所不同,在控制器测试中可以直接使用 session 方法,把 user.id 赋值给 :user_id 键:

def log_in_as(user)
  session[:user_id] = user.id
end

我们把这个方法命名为 log_in_as,这么做是为了避免与代码清单 8.14 中定义的 log_in 方法混淆。这个方法要在 test_helper 文件中的 ActiveSupport::TestCase 类里定义,与代码清单 8.26 中定义的 is_logged_in? 辅助方法放在一起:

class ActiveSupport::TestCase
  fixtures :all

  # 如果用户已登录,返回 true
  def is_logged_in?
    !session[:user_id].nil?
  end

  # 登入指定的用户
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

现在我们还用不到 log_in_as 方法的这个版本,第 10 章才能用到。

在集成测试中不能直接使用 session 方法,不过可以向 login_path 发送 POST 请求(与代码清单 8.23 类似)。这样定义出来的 log_in_as 方法如下所示:

class ActionDispatch::IntegrationTest

  # 登入指定的用户
  def log_in_as(user, password: 'password', remember_me: '1')
    post login_path, params: { session: { email: user.email,
                                          password: password,
                                          remember_me: remember_me } }
  end
end

这个方法在 ActionDispatch::IntegrationTest 类中定义,正是我们在集成测试中要调用的方法。我们使用相同的名称在两个地方定义方法,这样控制器测试中的代码不经修改就能在集成测试中使用。

下面定义这两个 log_in_as 辅助方法,如代码清单 9.24 所示。

代码清单 9.24:添加 log_in_as 辅助方法
test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # 如果用户已登录,返回 true
  def is_logged_in?
    !session[:user_id].nil?
  end

  # 登入指定的用户
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionDispatch::IntegrationTest

  # 登入指定的用户
  def log_in_as(user, password: 'password', remember_me: '1')
    post login_path, params: { session: { email: user.email,
                                          password: password,
                                          remember_me: remember_me } }
  end
end

注意,为了实现最大的灵活性,代码清单 9.24 中的第二个 log_in_as 方法有两个关键字参数(代码清单 7.13),而且为密码和“记住我”复选框设置了默认值,分别为 'passowrd''1'

为了检查“记住我”复选框的行为,我们要编写两个测试,对应勾选和没勾选复选框两种情况。使用代码清单 9.24 中定义的登录辅助方法很容易实现,分别为:

log_in_as(@user, remember_me: '1')

log_in_as(@user, remember_me: '0')

(因为 remember_me 的默认值是 '1',所以第一种情况可以省略选项。不过我加上了,让两种情况的代码结构一致。)

登录后,我们可以检查 cookiesremember_token 键,确认有没有记住登录状态。理想情况下,我们可以检查 cookie 中的值是否等于用户的记忆令牌,但对目前的设计方式而言,在测试中行不通:控制器中的 user 变量有记忆令牌属性,但测试中的 @user 变量没有(因为 remember_token 是虚拟属性)。这个问题的修正方法留作练习。现在我们只测试 cookie 中相关的值是不是 nil

不过,还有一个小问题,不知是什么原因,在测试中 cookies 方法不能使用符号键,所以:

cookies[:remember_token]

的值始终是 nil。幸好,cookies 可以使用字符串键,因此:

cookies['remember_token']

可以获得我们所需的值。最终写出的测试如代码清单 9.25 所示。(代码清单 8.23 中用过 users(:michael),它的作用是获取代码清单 8.22 中的用户固件。)

代码清单 9.25:测试“记住我”复选框 GREEN
test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies['remember_token']
  end

  test "login without remembering" do
    # 登录,设定 cookie
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # 再次登录,确认 cookie 被删除了
    log_in_as(@user, remember_me: '0')
    assert_empty cookies['remember_token']
  end
end

如果你没犯我曾经犯过的错误,测试应该可以通过:

代码清单 9.26GREEN
$ rails test
练习
  1. 前面说过,由于应用现在的设计方式,代码清单 9.25 中的集成测试无法获取 remember_token 虚拟属性。不过,在测试中使用一个特殊的方法可以获取,这个方法是 assigns。在测试中,可以访问控制器中定义的实例变量,方法是把实例变量的符号形式传给 assigns 方法。例如,如果 create 动作中定义了 @user 变量,在测试中可以使用 assigns(:user) 获取这个变量。现在,Sessions 控制器中的 create 动作定义了一个普通的变量(不是实例变量),名为 user,如果我们把它改成实例变量,就可以测试 cookies 中是否包含用户的记忆令牌。填写代码清单 9.27代码清单 9.28 中缺少的内容(?FILL_IN),完成改进后的“记住我”复选框测试。

代码清单 9.27:在 create 动作中使用实例变量的模板
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    ?user = User.find_by(email: params[:session][:email].downcase)
    if ?user && ?user.authenticate(params[:session][:password])
      log_in ?user
      params[:session][:remember_me] == '1' ? remember(?user) : forget(?user)
      redirect_to ?user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end
代码清单 9.28:改进后的“记住我”复选框测试模板 GREEN
test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal FILL_IN, assigns(:user).FILL_IN
  end

  test "login without remembering" do
    # 登录,设定 cookie
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # 再次登录,确认 cookie 被删除了
    log_in_as(@user, remember_me: '0')
    assert_empty cookies['remember_token']
  end
  .
  .
  .
end

测试“记住”分支

9.1.2 节,我们自己动手确认了前面实现的持久会话可以正常使用,但是 current_user 方法的相关分支完全没有测试。针对这种情况,我最喜欢在未测试的代码块中抛出异常:如果没覆盖这部分代码,测试能通过;如果覆盖了,失败消息中会标识出相应的测试。如代码清单 9.29 所示。

代码清单 9.29:在未测试的分支中抛出异常 GREEN
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 返回 cookie 中记忆令牌对应的用户
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      raise       # 测试仍能通过,所以没有覆盖这个分支
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

现在,测试应该可以通过:

代码清单 9.30GREEN
$ rails test

这显然是个问题,因为代码清单 9.29 会导致应用无法正常使用。而且,手动测试持久会话很麻烦,所以,如果以后想重构 current_user 方法的话(第 11 章将重构),现在就要测试。

因为代码清单 9.24 中的 log_in_as 辅助方法自动设定了 session[:user_id],所以在集成测试中测试 current_user 方法的“记住”分支很难。不过,幸好我们可以跳过这个限制,在 Sessions 辅助模块的测试中直接测试 current_user 方法。我们要手动创建这个测试文件:

$ touch test/helpers/sessions_helper_test.rb

测试的步骤很简单:

  1. 使用固件定义一个 user 变量;

  2. 调用 remember 方法记住这个用户;

  3. 确认 current_user 就是这个用户。

因为 remember 方法没有设定 session[:user_id],所以上述步骤能测试“记住”分支。测试如代码清单 9.31 所示。

代码清单 9.31:测试持久会话
test/helpers/sessions_helper_test.rb
require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

  def setup
    @user = users(:michael)
    remember(@user)
  end

  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end

注意,我们还写了一个测试,确认记忆摘要和记忆令牌不匹配时当前用户是 nil,由此测试嵌套的 if 语句中 authenticated? 方法的行为:

if user && user.authenticated?(cookies[:remember_token])

代码清单 9.31 中,我们可能会不小心这样写:

assert_equal current_user, @user

这样可能没什么问题,但是 assert_equal 方法的参数习惯先写“预期值”再写“实际值”:

assert_equal <expected>, <actual>

因此在代码清单 9.31 中要写成:

assert_equal @user, current_user

代码清单 9.31 中的测试应该失败:

代码清单 9.32RED
$ rails test test/helpers/sessions_helper_test.rb

我们要删除 raise,把 current_user 方法恢复原样,如代码清单 9.33 所示,这样测试就能通过了。

代码清单 9.33:删除抛出异常的代码 GREEN
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  # 返回 cookie 中记忆令牌对应的用户
  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
      user = User.find_by(id: user_id)
      if user && user.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end
  end
  .
  .
  .
end

现在,测试组件应该可以通过:

代码清单 9.34GREEN
$ rails test

现在,current_user 方法中的“记住”分支有了测试,我们不用手动检查了,还且测试还能捕获回归。

练习
  1. 代码清单 9.33 中的 authenticated? 删除,看看代码清单 9.31 中的第二个测试是否失败,从而确认测试编写的是否正确。

9.4 小结

这三章我们介绍了很多基础知识,也为稍显简陋的应用实现了注册和登录功能。实现身份验证功能后,我们可以根据登录状态和用户的身份限制对特定页面的访问权限。我们将在第 10 章实现编辑用户个人信息的功能。

在继续之前,先把本章的改动合并到主分支:

$ rails test
$ git add -A
$ git commit -m "Implement advanced login"
$ git checkout master
$ git merge advanced-login

部署到 Heroku 之前要注意一个问题:推送之后,迁移完成之前,应用基本上处于不可用状态。在拥有巨大流量的线上网站中,更新前最好开启维护模式

$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off

这样,在部署和执行迁移期间会显示一个标准的错误页面。(以后不会再做这一步,不过至少亲见一次总是好的。)详情参见 Heroku 文档对错误页面的说明。

9.4.1 本章所学

  • Rails 使用 cookies 方法在持久 cookie 中维护页面之间的状态;

  • 为了实现持久会话,我们为每个用户生成了记忆令牌和对应的记忆摘要;

  • 使用 cookies 方法可以在浏览器的 cookie 中存储一个长久的记忆令牌,实现持久会话;

  • 登录状态取决于有没有当前用户,而当前用户通过临时会话中的用户 ID 或持久会话中唯一的记忆令牌获取;

  • 退出功能通过删除会话中的用户 ID 和浏览器中的持久 cookie 实现;

  • 三元运算符是编写简单的 if-else 语句的简洁方式。

  1. 会话劫持大都可由 Firesheep 应用发现。连接公共 Wi-Fi 时,使用这个应用能看到很多知名网站的记忆令牌。
  2. Rails 5 提供了 has_secure_token 方法,用于生成随机的令牌,但是它在数据库中存储的是未经哈希的值,因此不符合这里的需求。
  3. 之所以使用这个方法,是因为我看了 RailsCast 中对记住我功能的讲解
  4. 何止“可以”,因为 bcrypt 会在哈希值中加盐,其实没有办法判别两个用户的密码是否相同。
  5. 如果记忆令牌是唯一的,攻击者必须同时拥有用户的 ID 和 cookie 中的记忆令牌才能劫持会话。
  6. 这还不足以让某些开发者信服,他们还是想确认没有碰撞,但是 10-40 概率太小了,这根本就是徒劳无益。如果整个宇宙一秒钟生成 10 亿个令牌,发生碰撞的几率仍是 10-23 数量级。
  7. 一般的规则是,如果方法不需要访问类的实例,就应该定义为类方法。到 11.2 节你会发现,这个决定很重要。
  8. 一般来说,签名加密是两个不同的操作,但是从 Rails 4 开始,signed 方法默认既签名也加密
  9. 6.3.1 节说过,“unencrypted password”(未加密的密码)用词不当,因为安全密码是哈希值,并不是加密后得到的值。
  10. 我一般会把这种赋值语句放在括号内,从视觉上提醒自己,这不是比较。
  11. 若想知道怎么在你的系统中查看 cookie,请在谷歌中搜索“<你的浏览器名> inspect cookies”。
  12. 感谢读者 Paulo Célio Júnior 指出这个问题。
  13. 感谢读者 Niels de Ron 指出这个问题。
  14. 注意,这意味着如果不勾选记住我复选框,任何电脑中的任何浏览器都会退出用户。分别在各个浏览器中记住用户登录会话的实现方式对用户来说更便利,但是安全性低,而且实现方式复杂。有能力的读者可以自行实现。
  15. 前面我们写成 remember user,没有括号,但是在三元运算符中,如果不加括号会导致句法错误。