8.4 记住我

最后更新于:2022-04-01 22:29:39

# 8.4 记住我 [8.2 节](#logging-in)实现的登录系统自成一体且功能完整,不过大多数网站还会提供一种功能——用户关闭浏览器后仍能记住用户的会话。本节,我们首先实现自动记住用户会话的功能,只有用户明确退出后会话才会失效。[8.4.5 节](#remember-me-checkbox)实现另一种常用方式:提供一个“记住我”复选框,让用户选择是否记住会话。这两种方式都很专业,[GitHub](http://github.com/) 和 [Bitbucket](http://bitbucket.org/) 等网站使用第一种,[Facebook](http://www.facebook.com/) 和 [Twitter](http://twitter.com/) 等网站使用第二种。 ## 8.4.1 记忆令牌和摘要 [8.2 节](#logging-in)使用 Rails 中的 `session` 方法存储用户的 ID,但是浏览器关闭后这个信息就不见了。本节,我们迈出实现持久会话的第一步:生成使用 `cookies` 方法创建持久 cookie 所需的“记忆令牌”,以及认证令牌所需的安全记忆摘要。 [8.2.1 节](#the-log-in-method)说过,使用 `session` 方法存储的信息默认情况下就是安全的,但使用 `cookies` 方法存储的信息则不然。具体而言,持久 cookie 有被[会话劫持](http://en.wikipedia.org/wiki/Session_hijacking)的风险,攻击者可以使用盗取的记忆令牌以某个用户的身份登录。盗取 cookie 中的信息主要有四种方法:(1)使用[包嗅探](https://en.wikipedia.org/wiki/Packet_analyzer)工具截获不安全网络中传输的 cookie;[[14](#fn-14)](2)获取包含记忆令牌的数据库;(3)使用“跨站脚本”(Cross-Site Scripting,简称 XSS)攻击;(4)获取已登录用户的设备访问权。我们在 [7.5 节](chapter7.html#professional-grade-deployment)启用了全站 SSL,避免嗅探网络中传输的数据,因此解决了第一个问题。为了解决第二个问题,我们不会存储记忆令牌本身,而是存储令牌的哈希摘要——这种方法和 [6.3 节](chapter6.html#adding-a-secure-password)一样,不存储原始密码,而是存储密码摘要。Rails 会转义插入视图模板中的内容,所以自动解决了第三个问题。对于最后一个问题,虽然没有万无一失的方法能避免攻击者获取已登录用户电脑的访问权,不过我们可以在每次用户退出后修改令牌,以及签名加密存储在浏览器中的敏感信息,尽量减少第四个问题发生的几率。 经过上述分析,我们计划按照下面的方式实现持久会话: 1. 生成随机字符串,当做记忆令牌; 2. 把这个令牌存入浏览器的 cookie 中,并把过期时间设为未来的某个日期; 3. 在数据库中存储令牌的摘要; 4. 在浏览器的 cookie 中存储加密后的用户 ID; 5. 如果 cookie 中有用户的 ID,就用这个 ID 在数据库中查找用户,并且检查 cookie 中的记忆令牌和数据库中的哈希摘要是否匹配。 注意,最后一步和登入用户很相似:使用电子邮件地址取回用户,然后(使用 `authenticate` 方法)验证提交的密码和密码摘要是否匹配([代码清单 8.5](#listing-find-authenticate-user))。所以,我们的实现方式和 `has_secure_password` 差不多。 首先,我们把所需的 `remember_digest` 属性加入用户模型,如[图 8.9](#fig-user-model-remember-digest) 所示。 ![user model remember digest](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_573330581f375.png)图 8.9:添加 `remember_digest` 属性后的用户模型 为了把[图 8.9](#fig-user-model-remember-digest) 中的数据模型添加到应用中,我们要生成一个迁移: ``` $ rails generate migration add_remember_digest_to_users remember_digest:string ``` (可以和 [6.3.1 节](chapter6.html#a-hashed-password)添加密码摘要的迁移比较一下。)和之前的迁移一样,迁移的名字以 `_to_users` 结尾,这么做是为了告诉 Rails 这个迁移是用来修改 `users` 表的。因为我们还指定了属性和类型,所以 Rails 会自动为我们生成迁移代码,如[代码清单 8.30](#listing-add-remember-digest-to-users-generated) 所示。 ##### 代码清单 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](#fn-15)]这个方法返回长度为 22 的随机字符串,包含字符 A-Z、a-z、0-9、“-”和“_”(每一位都有 64 种可能,因此方法名中有“[base64](http://en.wikipedia.org/wiki/Base64)”)。典型的 base64 字符串如下所示: ``` $ rails console >> SecureRandom.urlsafe_base64 => "q5lt38hQDc_959PVoo6b7A" ``` 就像两个用户可以使用相同的密码一样,[[16](#fn-16)]记忆令牌也没必要是唯一的,不过如果唯一的话,安全性更高。[[17](#fn-17)]对 base64 字符串来说,22 个字符中的每一个都有 64 种取值可能,所以两个记忆令牌“碰撞”的几率小到可以忽略,只有 1/6422 = 2-132 ≈ 10-40。而且,使用可在 URL 中安全使用的 base64 字符串(`urlsafe_base64` 方法的名字所示),我们还能在账户激活和密码重设链接中使用类似的令牌([第 10 章](chapter10.html#account-activation-and-password-reset))。 记住用户的登录状态要创建一个记忆令牌,并且在数据库中存储这个令牌的摘要。我们已经定义了 `digest` 方法,并且在测试固件中用过([代码清单 8.18](#listing-digest-method))。基于上述分析,现在我们可以定义一个 `new_token` 方法,创建一个新令牌。和 `digest` 方法一样,新建令牌的方法也不需要用户对象,所以也定义为类方法,[[18](#fn-18)]如[代码清单 8.31](#listing-token-method) 所示。 ##### 代码清单 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](#listing-add-remember-digest-to-users-generated)中的迁移已经添加了 `remember_digest` 属性,但是还没有 `remember_token` 属性。我们要找到一种方法,通过 `user.remember_token` 获取令牌(为了存入 cookie),但又不在数据库中存储令牌。[6.3 节](chapter6.html#adding-a-secure-password)解决过类似的问题——使用虚拟属性 `password` 和数据库中的 `password_digest` 属性。其中,虚拟属性 `password` 由 `has_secure_password` 方法自动创建。但是,我们要自己编写代码创建 `remember_token` 属性,方法是使用 [4.4.5 节](chapter4.html#a-user-class)用过的 `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](chapter6.html#listing-email-downcase))。)`remember` 方法的第二行代码使用 `update_attribute` 方法更新记忆摘要。([6.1.5 节](chapter6.html#updating-user-objects)说过,这个方法会跳过验证。这里必须跳过验证,因为我们无法获取用户的密码和密码确认。) 基于上述分析,创建有效令牌和摘要的方法是:首先使用 `User.new_token` 创建一个新记忆令牌,然后使用 `User.digest` 生成摘要,再更新数据库中的记忆摘要。实现这个步骤的 `remember` 方法如[代码清单 8.32](#listing-user-model-remember) 所示。 ##### 代码清单 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](#aside-time-helpers)。 )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 节](chapter4.html#class-inheritance)说过,可以向任何 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。为此,我们可以串联调用 `signed` 和 `permanent` 方法: ``` 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](#listing-user-model-remember) 生成的 `remember_digest` 是否匹配。(你可能想知道为什么不能只使用签名的用户 ID。如果没有记忆令牌,攻击者一旦知道加密的 ID,就能以这个用户的身份登录。但是按照我们目前的设计方式,就算攻击者同时获得了用户 ID 和记忆令牌,也要等到用户退出后才能登录。) 最后一步是,确认记忆令牌匹配用户的记忆摘要。对现在这种情况来说,使用 bcrypt 确认是否匹配有很多等效的方法。如果查看[安全密码的源码](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb),会发现下面这个比较语句:[[19](#fn-19)] ``` BCrypt::Password.new(password_digest) == unencrypted_password ``` 这里,我们需要的代码如下: ``` BCrypt::Password.new(remember_digest) == remember_token ``` 仔细想一想,这行代码有点儿奇怪:看起来是直接比较 bcrypt 计算得到的密码哈希和令牌,那么,要使用 `==` 就得解密摘要。可是,使用 bcrypt 的目的是为了得到不可逆的哈希值,所以这么想是不对的。研究 [bcrypt gem 的源码](https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb)后,你会发现 bcrypt 重定义了 `==`,上述代码其实等效于: ``` BCrypt::Password.new(remember_digest).is_password?(remember_token) ``` 这种写法没使用 `==`,而是使用返回布尔值的 `is_password?` 方法进行比较。因为这么写意思更明确,所以,在应用代码中我们就这么写。 基于上述分析,我们可以在用户模型中定义 `authenticated?` 方法,比较摘要和令牌。这个方法的作用类似于 `has_secure_password` 提供用来认证用户的 `authenticate` 方法([代码清单 8.13](#listing-log-in-success))。`authenticated?` 方法的定义如[代码清单 8.33](#listing-authenticated-p) 所示。 ##### 代码清单 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](#listing-authenticated-p) 中的 `authenticated?` 方法和记忆令牌联系紧密,不过在其他情况下也很有用,[第 10 章](chapter10.html#account-activation-and-password-reset)会改写这个方法,让它的使用范围更广。 现在可以记住用户的登录状态了。我们要在 `log_in` 后面调用 `remember` 辅助方法,如[代码清单 8.34](#listing-log-in-with-remember) 所示。 ##### 代码清单 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](#listing-log-in-with-remember) 把真正的工作交给会话辅助方法完成。在会话辅助方法模块中,我们要定义一个名为 `remember` 的方法,调用 `user.remember`,从而生成一个记忆令牌,并把对应的摘要存入数据库;然后使用 `cookies` 创建永久 cookie,保存用户 ID 和记忆令牌。结果如[代码清单 8.35](#listing-remember-method) 所示。 ##### 代码清单 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](#listing-current-user)中定义的 `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](#listing-find-authenticate-user) 中使用的 `user && user.authenticated` 模式。)上述代码可以使用,但注意,其中重复使用了 `session` 和 `cookies`。我们可以去除重复,写成这样: ``` 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](#fn-20)] 按照上述分析定义 `current_user` 辅助方法,如[代码清单 8.36](#listing-persistent-current-user) 所示。 ##### 代码清单 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](#fig-cookie-in-browser) 所示。[[21](#fn-21)] ![cookie in browser chrome](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333058367c6.png)图 8.10:本地浏览器 cookie 中存储的记忆令牌 现在我们的应用还有一个问题:无法清除浏览器中的 cookie(除非等到 20 年后),因此用户无法退出。这正是测试应该捕获的问题,而且目前测试的确无法通过: ##### 代码清单 8.37:**RED** ``` $ bundle exec rake test ``` ## 8.4.3 忘记用户 为了让用户退出,我们要定义一些和记住用户相对的方法,忘记用户。最终实现的 `user.forget` 方法,把记忆摘要的值设为 `nil`,即撤销 `user.remember` 的操作,如[代码清单 8.38](#listing-user-model-forget) 所示。 ##### 代码清单 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](#listing-log-out-with-forget) 所示。`forget` 方法先调用 `user.forget`,然后再从 cookie 中删除 `user_id` 和 `remember_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](#listing-log-out-with-forget) 中使用了 `current_user`。[[22](#fn-22)]我们可以限制只有已登录的用户才能退出,解决这个问题。 第二个问题,用户可能会在不同的浏览器中登录(登录状态也被记住),例如 Chrome 和 Firefox,如果用户在一个浏览器中退出,而另一个浏览器中没有退出,就会导致问题。[[23](#fn-23)]假如用户在 Firefox 中退出了,那么记忆摘要的值变成了 `nil`(通过[代码清单 8.38](#listing-user-model-forget) 中的 `user.forget`)。在 Firefox 中没什么问题,因为[代码清单 8.39](#listing-log-out-with-forget) 中的 `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`。(因为 `user` 是 `nil`,是假值,所以不会再执行第二个表达式。)而在 Chrome 中,用户 ID 没被删除,所以 `user` 的值不是 `nil`,所以会执行第二个表达式。这意味着,在 `authenticated?` 方法([代码清单 8.33](#listing-authenticated-p))中 ``` 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](#listing-user-logout-test) 中的集成测试失败,如[代码清单 8.40](#listing-test-double-logout) 所示。 ##### 代码清单 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](#listing-destroy-forget) 所示。 ##### 代码清单 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](#listing-test-authenticated-invalid-token) 所示。(注意,我们直接使用空记忆令牌,因为还没用到这个值之前就会发生错误。) ##### 代码清单 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: "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)` 会抛出异常,所以测试组件不能通过: ##### 代码清单 8.44:**RED** ``` $ bundle exec rake test ``` 为了修正这个问题,让测试通过,记忆摘要的值为 `nil` 时,`authenticated?` 要返回 `false`,如[代码清单 8.45](#listing-authenticated-p-fixed) 所示。 ##### 代码清单 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](#listing-authenticated-p-fixed) 修改之后,测试组件应该可以通过了,说明这两个小问题都解决了: ##### 代码清单 8.46:**GREEN** ``` $ bundle exec rake test ``` ## 8.4.5 “记住我”复选框 至此,我们的应用已经实现了完整且专业的认证系统。最后一步,我们来看一下如何使用“记住我”复选框让用户选择是否记住登录状态。包含这个复选框的登录表单构思图如[图 8.11](#fig-login-remember-me-mockup) 所示。 ![login remember me mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305849e23.png)图 8.11:构思“记住我”复选框 为了实现这个构思,我们首先要在登录表单([代码清单 8.2](#listing-login-form))中添加一个复选框。和标注(label)、文本字段、密码字段和提交按钮一样,复选框也可以使用 Rails 辅助方法创建。不过,为了得到正确的样式,我们要把复选框嵌套在标注中,如下所示: ``` <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> Remember me on this computer <% end %> ``` 把这段代码添加到登录表单后,得到的视图如[代码清单 8.47](#listing-remember-me-checkbox) 所示。 ##### 代码清单 8.47:在登录表单中添加“记住我”复选框 app/views/sessions/new.html.erb ``` <% provide(:title, "Log in") %>

Log in

<%= 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 %> Remember me on this computer <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %>

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

``` [代码清单 8.47](#listing-remember-me-checkbox) 中使用了 CSS 类 `checkbox` 和 `inline`,Bootstrap 使用这两个类把复选框和文本(“Remember me on this computer”)放在同一行。为了完善样式,我们还要再定义一些 CSS 规则,如[代码清单 8.48](#listing-remember-me-css) 所示。得到的登录表单如[图 8.12](#fig-login-form-remember-me) 所示。 ##### 代码清单 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](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_5733305861d1f.png)图 8.12:添加“记住我”复选框后的登录表单 修改登录表单后,当用户勾选这个复选框后,要记住用户的登录状态,否则不记住。因为前一节的工作做得很好,现在实现起来只需一行代码就行。提交登录表单后,`params` 哈希中包含一个基于复选框状态的值(你可以使用有效信息填写登录表单,然后提交,看一下页面底部的调试信息)。如果勾选了复选框,`params[:session][:remember_me]` 的值是 `'1'`,否则是 `'0'`。 我们可以检查 `params` 哈希中的相关值,根据提交的值决定是否记住用户: ``` if params[:session][:remember_me] == '1' remember(user) else forget(user) end ``` 根据[旁注 8.3](#aside-ternary-operator) 中的说明,这种 `if-then` 分支语句可以使用“三元操作符”变成一行:[[24](#fn-24)] ``` params[:session][:remember_me] == '1' ? remember(user) : forget(user) ``` 在会话控制器的 `create` 动作中加入这行代码后,得到的是非常简洁的代码,如[代码清单 8.49](#listing-remember-me-ternary) 所示。(现在你应该可以理解[代码清单 8.18](#listing-digest-method)中使用三元操作符定义 `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](#listing-remember-me-ternary)),我最初编写的代码是: ``` 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](#listing-user-login-test-valid-information) 中,我们使用 `post` 方法发送有效的 `session` 哈希,登入用户,但是每次都这么做有点麻烦。为了避免不必要的重复,我们要编写一个辅助方法,名为 `log_in_as`,登入用户。 登入用户的方法在不同类型的测试中有所不同,在集成测试中我们可以按照[代码清单 8.20](#listing-user-login-test-valid-information) 中的方式向登录地址发送数据,但是在其他测试中,例如控制器和模型测试,这么做不行,我们要直接使用 `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](#listing-test-helper-log-in) 所示。(这个方法相当高级,如果不能完全理解也没事。) ##### 代码清单 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](#listing-test-helper-log-in) 中的 `log_in_as` 方法有一个 `options` 哈希参数,而且为密码和“记住我”复选框设置了默认值,分别为 `'passowrd'` 和 `'1'`。因为哈希中未出现的键对应的值是 `nil`,所以: ``` remember_me = options[:remember_me] || '1' ``` 如果传入了参数就使用指定的值,否则使用默认值(遵照[旁注 8.1](#aside-or-equals) 中说明的短路计算法则)。 为了检查“记住我”复选框的行为,我们要编写两个测试,对应勾选和没勾选复选框两种情况。使用[代码清单 8.50](#listing-test-helper-log-in) 中定义的登录辅助方法很容易实现,分别为: ``` log_in_as(@user, remember_me: '1') ``` 和 ``` log_in_as(@user, remember_me: '0') ``` (因为 `remember_me` 的默认值是 `'1'`,所以第一种情况可以省略这个选项。不过我加上了,让两种情况的代码结构一致。) 登录后,我们可以检查 `cookies` 的 `remember_token` 键,确认有没有记住登录状态。理想情况下,我们可以检查 cookie 中的值是否等于用户的记忆令牌,但对目前的设计方式而言,在测试中行不通:控制器中的 `user` 变量有记忆令牌属性,但测试中的 `@user` 变量没有(因为 `remember_token` 是虚拟属性)。这个问题的修正方法留作[练习](#log-in-log-out-exercises)。现在我们只测试 cookie 中相关的值是不是 `nil`。 不过,还有一个小问题,不知是什么原因,在测试中 `cookies` 方法不能使用符号键,所以: ``` cookies[:remember_token] ``` 的值始终是 `nil`。幸好,`cookies` 可以使用字符串键,因此: ``` cookies['remember_token'] ``` 可以获得我们所需的值。写出的测试如[代码清单 8.51](#listing-remember-me-test) 所示。([代码清单 8.20](#listing-user-login-test-valid-information) 中用过 `users(:michael)`,它的作用是获取[代码清单 8.19](#listing-real-user-fixture) 中的用户固件。) ##### 代码清单 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 节](#login-with-remembering),我们自己动手确认了前面实现的持久会话可以正常使用,但是 `current_user` 方法的相关分支完全没有测试。针对这种情况,我最喜欢在未测试的代码块中抛出异常:如果没覆盖这部分代码,测试能通过;如果覆盖了,失败消息中会标识出相应的测试。如[代码清单 8.53](#listing-branch-raise) 所示。 ##### 代码清单 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](#listing-branch-raise) 会导致应用无法正常使用。而且,手动测试持久会话很麻烦,所以,如果以后想重构 `current_user` 方法的话([第 10 章](chapter10.html#account-activation-and-password-reset)),现在就要测试。 因为[代码清单 8.50](#listing-test-helper-log-in) 中的 `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](#listing-persistent-sessions-test) 所示。 ##### 代码清单 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](#listing-persistent-sessions-test) 中的测试应该失败: ##### 代码清单 8.56:**RED** ``` $ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb ``` 我们要删除 `raise`,把 `current_user` 方法恢复原样,如[代码清单 8.57](#listing-branch-no-raise) 所示,这样测试就能通过了。(你还可以把[代码清单 8.57](#listing-branch-no-raise) 中的 `authenticated?` 删除,看看[代码清单 8.55](#listing-persistent-sessions-test) 中的测试是否失败,从而确认第二个测试编写的是否正确。) ##### 代码清单 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` 方法中的“记住”分支有了测试,我们不用手动检查了,还且测试还能捕获回归。
';