8.4 记住我

8.2 节实现的登录系统自成一体且功能完整,不过大多数网站还会提供一种功能——用户关闭浏览器后仍能记住用户的会话。本节,我们首先实现自动记住用户会话的功能,只有用户明确退出后会话才会失效。8.4.5 节实现另一种常用方式:提供一个“记住我”复选框,让用户选择是否记住会话。这两种方式都很专业,GitHubBitbucket 等网站使用第一种,FacebookTwitter 等网站使用第二种。

8.4.1 记忆令牌和摘要

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

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

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

  1. 生成随机字符串,当做记忆令牌;

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

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

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

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

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

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

user model remember digest图 8.9:添加 remember_digest 属性后的用户模型

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

$ rails generate migration add_remember_digest_to_users remember_digest:string

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

代码清单 8.30:生成的迁移,用来添加记忆摘要

db/migrate/[timestamp]_add_remember_digest_to_users.rb

class AddRememberDigestToUsers < ActiveRecord::Migration
  def change
    add_column :users, :remember_digest, :string
  end
end

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

$ bundle exec rake db:migrate

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

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

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

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

代码清单 8.31:添加生成令牌的方法

app/models/user.rb

class User < ActiveRecord::Base
  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 方法把记忆令牌和用户关联起来,并且把相应的记忆摘要存入数据库。代码清单 8.30中的迁移已经添加了 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 < ActiveRecord::Base
 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.31)。)remember 方法的第二行代码使用 update_attribute 方法更新记忆摘要。(6.1.5 节说过,这个方法会跳过验证。这里必须跳过验证,因为我们无法获取用户的密码和密码确认。)

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

代码清单 8.32:在用户模型中添加 remember 方法 GREEN

app/models/user.rb

class User < ActiveRecord::Base
 attr_accessor :remember_token  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

  # 为了持久会话,在数据库中记住用户
  def remember
 self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token))  end
end

8.4.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 时间辅助方法,参见旁注 8.2。 )Rails 应用中经常使用 20 年后过期的 cookie,所以 Rails 提供了一个特殊的方法 permanent,用于创建这种 cookie,所以上述代码可以简写为:

cookies.permanent[:remember_token] = remember_token

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

旁注 8.2:cookie 在 20.years.from_now 之后过期

你可能还记得,4.4.2 节说过,可以向任何 Ruby 类,甚至是内置的类中添加自定义的方法。那一节,我们向 String 类添加了 palindrome? 方法(而且还发现了 "deified" 是回文)。我们还介绍过,Rails 为 Object 类添加了 blank? 方法(所以,"".blank?" ".blank?nil.blank? 的返回值都是 true)。创建 20.years.from_now 之后过期的 cookie 的 cookies.permanent 方法又是一例。permanent 方法使用了 Rails 提供的一个时间辅助方法。时间辅助方法添加到 Fixnum 类(整数的基类)中:

$ rails console
>> 1.year.from_now
=> Sun, 09 Aug 2015 16:48:17 UTC +00:00
>> 10.weeks.ago
=> Sat, 31 May 2014 16:48:45 UTC +00:00

Rails 还在 Fixnum 类中添加了其他辅助方法:

>> 1.kilobyte
=> 1024
>> 5.megabytes
=> 5242880

这几个辅助方法可用于验证文件上传,例如,限制上传的图片最大不超过 5.megabytes

这种为内置类添加方法的特性很灵便,可以扩展 Ruby 的功能,不过使用时要小心一些。其实 Rails 的很多优雅之处正是基于 Ruby 语言的这一特性实现的。

我们可以参照 session 方法,使用下面的方式把用户的 ID 存入 cookie:

cookies[:user_id] = user.id

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

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]代码清单 8.32 生成的 remember_digest 是否匹配。(你可能想知道为什么不能只使用签名的用户 ID。如果没有记忆令牌,攻击者一旦知道加密的 ID,就能以这个用户的身份登录。但是按照我们目前的设计方式,就算攻击者同时获得了用户 ID 和记忆令牌,也要等到用户退出后才能登录。)

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

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? 方法进行比较。因为这么写意思更明确,所以,在应用代码中我们就这么写。

基于上述分析,我们可以在用户模型中定义 authenticated? 方法,比较摘要和令牌。这个方法的作用类似于 has_secure_password 提供用来认证用户的 authenticate 方法(代码清单 8.13)。authenticated? 方法的定义如代码清单 8.33 所示。

代码清单 8.33:在用户模型中添加 authenticated? 方法

app/models/user.rb

class User < ActiveRecord::Base
  attr_accessor :remember_token
  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

  # 为了持久会话,在数据库中记住用户
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(remember_token)
 BCrypt::Password.new(remember_digest).is_password?(remember_token)  end
end

虽然代码清单 8.33 中的 authenticated? 方法和记忆令牌联系紧密,不过在其他情况下也很有用,第 10 章会改写这个方法,让它的使用范围更广。

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

代码清单 8.34:登录并记住登录状态

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

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

代码清单 8.35:记住用户

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
    @current_user ||= User.find_by(id: session[:user_id])
  end

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

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

现在,用户登录后会被记住,因为在浏览器中存储了有效的记忆令牌。但是还没有什么实际作用,因为代码清单 8.14中定义的 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.5 中使用的 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”。[20]

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

代码清单 8.36:更新 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,如图 8.10 所示。[21]

cookie in browser chrome图 8.10:本地浏览器 cookie 中存储的记忆令牌

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

代码清单 8.37:RED
$ bundle exec rake test

8.4.3 忘记用户

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

代码清单 8.38:在用户模型中添加 forget 方法

app/models/user.rb

class User < ActiveRecord::Base
  attr_accessor :remember_token
  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

  # 为了持久会话,在数据库中记住用户
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 如果指定的令牌和摘要匹配,返回 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,如代码清单 8.39 所示。forget 方法先调用 user.forget,然后再从 cookie 中删除 user_idremember_token

代码清单 8.39:退出持久会话

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

8.4.4 两个小问题

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

第二个问题,用户可能会在不同的浏览器中登录(登录状态也被记住),例如 Chrome 和 Firefox,如果用户在一个浏览器中退出,而另一个浏览器中没有退出,就会导致问题。[23]假如用户在 Firefox 中退出了,那么记忆摘要的值变成了 nil(通过代码清单 8.38 中的 user.forget)。在 Firefox 中没什么问题,因为代码清单 8.39 中的 log_out 方法删除了用户的 ID,所以在 current_user 方法中,user 变量的值是 nil

# 返回 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

那么,基于短路计算原则,表达式

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

的值是 false。(因为 usernil,是假值,所以不会再执行第二个表达式。)而在 Chrome 中,用户 ID 没被删除,所以 user 的值不是 nil,所以会执行第二个表达式。这意味着,在 authenticated? 方法(代码清单 8.33)中

def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

remember_digest 的值是 nil,所以调用 BCrypt::Password.new(remember_digest) 时会抛出异常。而遇到这种情况时,我们希望 authenticated? 方法返回 false

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

代码清单 8.40:测试用户退出 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, 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 会抛出异常,因为没有当前用户,由此导致测试组件无法通过:

代码清单 8.41:RED
$ bundle exec rake test

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

代码清单 8.42:只有登录后才能退出 GREEN

app/controllers/sessions_controller.rb

class SessionsController < ApplicationController
  .
  .
  .
  def destroy
 log_out if logged_in?    redirect_to root_url
  end
end

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

代码清单 8.43:测试没有摘要时 authenticated? 方法的表现 RED

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "[email protected]",
                     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) 会抛出异常,所以测试组件不能通过:

代码清单 8.44:RED
$ bundle exec rake test

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

代码清单 8.45:更新 authenticated?,处理没有记忆摘要的情况 GREEN

app/models/user.rb

class User < ActiveRecord::Base
  .
  .
  .
  # 如果指定的令牌和摘要匹配,返回 true
  def authenticated?(remember_token)
 return false if remember_digest.nil?    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

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

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

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

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

代码清单 8.46:GREEN
$ bundle exec rake test

8.4.5 “记住我”复选框

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

login remember me mockup图 8.11:构思“记住我”复选框

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

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

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

代码清单 8.47:在登录表单中添加“记住我”复选框

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 %>

  <%= f.label :password %>
  <%= f.password_field :password %>

  <%= 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>

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

代码清单 8.48:“记住我”复选框的 CSS 规则

app/assets/stylesheets/custom.css.scss

.
.
.
/* forms */
.
.
.
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

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

login form remember me图 8.12:添加“记住我”复选框后的登录表单

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

我们可以检查 params 哈希中的相关值,根据提交的值决定是否记住用户:

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

根据旁注 8.3 中的说明,这种 if-then 分支语句可以使用“三元操作符”变成一行:[24]

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

在会话控制器的 create 动作中加入这行代码后,得到的是非常简洁的代码,如代码清单 8.49 所示。(现在你应该可以理解代码清单 8.18中使用三元操作符定义 cost 变量的代码了。)

代码清单 8.49:处理提交的“记住我”复选框

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

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

旁注 8.3:世界上有 10 种人

有一个老笑话,说世界上有 10 种人,懂二进制的人和不懂二进制的人。(这里的 10,在二进制中是 2)同理,我们可以说,世界上有 11 种人,一种人喜欢三元操作符,一种人不喜欢,还有一种人不知道三元操作符是什么。(如果你碰巧是第三种人,稍后就不是了。)

编程一段时间之后,你会发现,最常使用的流程控制之一是下面这种:

if boolean?
  do_one_thing
else
  do_something_else
end

Ruby 和其他很多语言一样(包括 C/C++,Perl,PHP 和 Java),提供了一种更为简单的表达式来替代这种流程控制结构——三元操作符(之所以这么叫,是因为三元操作符包括三部分):

boolean? ? do_one_thing : do_something_else

三元操作符甚至还可以用来替代赋值操作,所以

if boolean?
  var = foo
else
  var = bar
end

可以写成:

var = boolean? ? foo : bar

而且,为了方便,函数的返回值也经常使用三元操作符:

def foo
  do_stuff
  boolean? ? "bar" : "baz"
end

因为 Ruby 函数的默认返回值是定义体中的最后一个表达式,所以 foo 方法的返回值会根据 boolean? 的结果而不同,不是 "bar" 就是 "baz"

8.4.6 记住登录状态功能的测试

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

测试“记住我”复选框

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

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.20 中,我们使用 post 方法发送有效的 session 哈希,登入用户,但是每次都这么做有点麻烦。为了避免不必要的重复,我们要编写一个辅助方法,名为 log_in_as,登入用户。

登入用户的方法在不同类型的测试中有所不同,在集成测试中我们可以按照代码清单 8.20 中的方式向登录地址发送数据,但是在其他测试中,例如控制器和模型测试,这么做不行,我们要直接使用 session 方法。因此,log_in_as 要检测测试的类型,然后使用相应的处理方式。我们可以使用 Ruby 中的 defined? 方法区分集成测试和其他测试。如果定义了指定的参数,defined? 方法返回 true,否则返回 false。对现在的需求来说,post_via_redirect 方法只能在集成测试中使用,所以

defined?(post_via_redirect) ...

在集成测试中返回 true,在其他类型的测试中返回 false。由此,我们可以定义一个名为 integration_test? 的方法,返回布尔值,然后使用 if-else 语句按照下面的方式编写代码:

if integration_test?
  # 向登录地址发送数据登入用户
else
  # 使用 session 方法登入用户
end

把上面的注释换成代码后得到的 log_in_as 辅助方法如代码清单 8.50 所示。(这个方法相当高级,如果不能完全理解也没事。)

代码清单 8.50:添加 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, options = {})
    password    = options[:password]    || 'password'
    remember_me = options[:remember_me] || '1'
 if integration_test?      post login_path, session: { email:       user.email,
                                  password:    password,
                                  remember_me: remember_me }
    else
      session[:user_id] = user.id
    end
  end

  private

    # 在集成测试中返回 true
    def integration_test?
 defined?(post_via_redirect)    end
end

注意,为了实现最大的灵活性,代码清单 8.50 中的 log_in_as 方法有一个 options 哈希参数,而且为密码和“记住我”复选框设置了默认值,分别为 'passowrd''1'。因为哈希中未出现的键对应的值是 nil,所以:

remember_me = options[:remember_me] || '1'

如果传入了参数就使用指定的值,否则使用默认值(遵照旁注 8.1 中说明的短路计算法则)。

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

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']

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

代码清单 8.51:测试“记住我”复选框 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_nil cookies['remember_token']  end

  test "login without remembering" do
 log_in_as(@user, remember_me: '0') assert_nil cookies['remember_token']  end
end

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

代码清单 8.52:GREEN
$ bundle exec rake test

测试“记住”分支

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

代码清单 8.53:在未测试的分支中抛出异常 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

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

代码清单 8.54:GREEN
$ bundle exec rake test

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

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

$ touch test/helpers/sessions_helper_test.rb

测试的步骤很简单:

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

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

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

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

代码清单 8.55:测试持久会话

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])

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

代码清单 8.56:RED
$ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb

我们要删除 raise,把 current_user 方法恢复原样,如代码清单 8.57 所示,这样测试就能通过了。(你还可以把代码清单 8.57 中的 authenticated? 删除,看看代码清单 8.55 中的测试是否失败,从而确认第二个测试编写的是否正确。)

代码清单 8.57:删除抛出异常的代码 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

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

代码清单 8.58:GREEN
$ bundle exec rake test

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