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

第 8 章 基本登录功能

第 7 章实现了用户注册功能,接下来该实现登录和退出功能了。本章实现基本的登录系统,不过完全可用:应用维持登录状态,直到用户关闭浏览器为止。本章开发的身份验证系统可用于定制网站的内容,还能基于登录状态和用户的身份实现权限机制。例如,本章我们会更新网站的页头,加入“登录”或“退出”链接,以及指向个人资料页面的链接。

第 10 章将实现一种安全机制,只有已登录的用户才能访问用户列表页面,只有用户自己才能编辑自己的信息,只有管理员才能从数据库中删除其他用户。第 13 章将使用已登录用户的身份发布他自己的微博。第 14 章将让当前登录的用户关注网站中的其他用户(查看所关注用户的动态流)。

本章开发的身份验证系统还将为第 9 章开发的高级登录系统奠定基础。那个高级系统不会在用户关闭浏览器后清除登录状态,我们将先自动记住用户登录状态,然后为用户提供选择,当他们勾选“记住我”复选框时才记住登录状态。第 8 章第 9 章实现的登录系统是网上最常见的三种。

8.1 会话

HTTP 协议没有状态,每个请求都是独立的事务,无法使用之前请求中的信息。所以,在 HTTP 协议中无法在两个页面之间记住用户的身份。需要用户登录的应用必须使用会话(session)。会话是两台电脑(例如运行 Web 浏览器的客户端电脑和运行 Rails 的服务器)之间的半永久性连接。

在 Rails 中实现会话最常见的方式是使用 cookie。cookie 是存储在用户浏览器中的少量文本。访问其他页面时,cookie 中存储的信息仍在,所以可以在 cookie 中存储一些信息,例如用户的 ID,让应用从数据库中检索已登录的用户。这一节和 8.2 节会使用 Rails 提供的 session 方法实现临时会话,浏览器关闭后会话自动失效。[1]第 9 章将使用 Rails 提供的 cookies 方法让会话持续的时间久一些。

把会话看成 REST 式资源便于操作,访问登录页面时渲染一个用于创建会话的表单,登录时创建会话,退出时再把会话销毁。不过,会话和 Users 资源不同,Users 资源(通过 User 模型)使用数据库存储数据,而会话使用 cookie。所以,登录功能的大部分工作是实现基于 cookie 的身份验证机制。这一节和下一节要为登录功能做些准备工作,包括创建 Sessions 控制器、登录表单和相关的控制器动作。8.2 节再添加所需的代码处理会话,完成登录功能。

与前面的章节一样,我们要在主题分支中工作,本章结束时再合并到主分支:

$ git checkout -b basic-login

8.1.1 Sessions 控制器

登录和退出功能由 Sessions 控制器中相应的 REST 动作处理:登录表单在 new 动作中处理(本节的内容),登录的过程是向 create 动作发送 POST 请求(8.2 节),退出则是向 destroy 动作发送 DELETE 请求(8.3 节)。(HTTP 请求与 REST 动作之间的对应关系参见表 7.1。)

首先,生成 Sessions 控制器,以及其中的 new 动作。

代码清单 8.1:生成 Sessions 控制器
$ rails generate controller Sessions new

(参数中指定 new 的话,还会生成对应的视图,不过我们没指定 createdestroy,因为这两个动作没有视图。)参照 7.2 节创建注册页面的方式,我们要创建一个登录表单,用于创建会话,构思如图 8.1 所示。

login mockup
图 8.1:登录表单的构思图

Users 资源使用特殊的 resources 方法自动获得 REST 式路由(代码清单 7.3),而 Sessions 资源则只能使用具名路由,处理发给 /login 地址的 GETPOST 请求,以及发给 /logout 地址的 DELETE 请求,如代码清单 8.2 所示(删除了 rails generate controller 生成的无用路由)。

代码清单 8.2:添加一个资源,获得会话的标准 REST 式动作 RED
config/routes.rb
Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
end

添加代码清单 8.2 中的路由规则之后,还要更新代码清单 8.1 生成的测试,使用新的登录路由,如代码清单 8.3 所示。

代码清单 8.3:更新 Sessions 控制器的测试,使用新的登录路由 GREEN
test/controllers/sessions_controller_test.rb
require 'test_helper'

class SessionsControllerTest < ActionDispatch::IntegrationTest

  test "should get new" do
    get login_path
    assert_response :success
  end
end

代码清单 8.2 中的路由规则会把 URL 和动作对应起来,就像表 7.1 那样,如表 8.1 所示。

表 8.1代码清单 8.2 中会话相关的规则生成的路由
HTTP 请求 URL 具名路由 动作 作用

GET

/login

login_path

new

创建新会话的页面(登录)

POST

/login

login_path

create

创建新会话(登录)

DELETE

/logout

logout_path

destroy

删除会话(退出)

至此,我们添加了好几个自定义的具名路由,现在最好看一下完整的路由列表。我们可以执行 rails routes 命令生成路由列表:

$ rails routes
   Prefix Verb   URI Pattern               Controller#Action
     root GET    /                         static_pages#home
     help GET    /help(.:format)           static_pages#help
    about GET    /about(.:format)          static_pages#about
  contact GET    /contact(.:format)        static_pages#contact
   signup GET    /signup(.:format)         users#new
    login GET    /login(.:format)          sessions#new
          POST   /login(.:format)          sessions#create
   logout DELETE /logout(.:format)         sessions#destroy
    users GET    /users(.:format)          users#index
          POST   /users(.:format)          users#create
 new_user GET    /users/new(.:format)      users#new
edit_user GET    /users/:id/edit(.:format) users#edit
     user GET    /users/:id(.:format)      users#show
          PATCH  /users/:id(.:format)      users#update
          PUT    /users/:id(.:format)      users#update
          DELETE /users/:id(.:format)      users#destroy

你没必要完全理解输出的这些路由。像这样查看路由能对应用支持的动作有个整体认识。

练习
  1. GET login_pathPOST login_path 之间有什么区别?

  2. rails routes 命令的输出通过管道传给 grep,列出与 Users 资源相关的全部路由。以同样的方法列出 Sessions 资源相关的全部路由。这两个资源各有多少路由?

8.1.2 登录表单

定义好相关的控制器和路由之后,我们要编写新建会话的视图,也就是登录表单。比较图 8.1图 7.11 之后发现,登录表单和注册表单的外观类似,不过登录表单只有两个输入框(电子邮件地址和密码),而注册表单有四个输入框。

图 8.2 所示,如果提交的登录信息无效,要重新渲染登录页面,并显示一个错误消息。在 7.3.3 节,我们使用错误消息局部视图显示错误消息,但是那些消息由 Active Record 自动提供,而错误消息局部视图不能显示创建会话时的错误,因为会话不是 Active Record 对象,因此我们要使用闪现消息渲染登录时的错误消息。

login failure mockup
图 8.2:登录失败后显示的页面构思图

代码清单 7.15 中的注册表单使用 form_for 辅助方法,并且把表示用户的 @user 实例变量作为参数传给 form_for

<%= form_for(@user) do |f| %>
  .
  .
  .
<% end %>

登录表单和注册表单之间的主要区别是,会话不是模型,因此不能创建类似 @user 的变量。所以,构建登录表单时,我们要为 form_for 稍微多提供一些信息。

form_for(@user) 的作用是让表单向 /users 发起 POST 请求。对会话来说,我们需要指明资源的名称以及相应的 URL:[2]

form_for(:session, url: login_path)

知道怎么调用 form_for 之后,参照注册表单(代码清单 7.15)编写图 8.1 中构思的登录表单就容易了,如代码清单 8.4 所示。

代码清单 8.4:登录表单的代码
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.submit "Log in", class: "btn btn-primary" %>
    <% end %>

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

注意,为了操作方便,我们还加入了指向“注册”页面的链接。代码清单 8.4 中的登录表单如图 8.3 所示。(导航栏中的“Log in”还没填写地址,所以你要在地址栏中输入 /login。8.2.3 节会修正这个问题。)

生成的表单 HTML 如代码清单 8.5 所示。

代码清单 8.5代码清单 8.4 中的登录表单生成的 HTML
<form accept-charset="UTF-8" action="/login" method="post">
  <input name="utf8" type="hidden" value="&#x2713;" />
  <input name="authenticity_token" type="hidden"
         value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
  <label for="session_email">Email</label>
  <input class="form-control" id="session_email"
         name="session[email]" type="text" />
  <label for="session_password">Password</label>
  <input id="session_password" name="session[password]"
         type="password" />
  <input class="btn btn-primary" name="commit" type="submit"
       value="Log in" />
</form>

对比一下代码清单 8.5代码清单 7.17,你可能已经猜到了,提交登录表单后会生成一个 params 散列,其中 params[:session][:email]params[:session][:password] 分别对应电子邮件地址和密码字段。

login form
图 8.3:登录表单
练习
  1. 提交代码清单 8.4 中的表单后,应用会转向 Sessions 控制器的 create 动作。Rails 怎么知道要这么做的?提示:参考表 8.1代码清单 8.5 中的第一行。

8.1.3 查找并验证用户的身份

与创建用户(注册)类似,创建会话(登录)时要先处理提交无效数据的情况。我们将先分析提交表单后会发生什么,想办法在登录失败时显示有帮助的错误消息(如图 8.2 中的构思)。然后,以此为基础,验证提交的电子邮件地址和密码组合,处理登录成功的情况(8.2 节)。

首先,我们要为 Sessions 控制器编写一个最简单的 create 动作,以及空的 new 动作和 destroy 动作,如代码清单 8.6 所示。create 动作现在只渲染 new 视图,不过这为后续工作做好了准备。提交 /login 页面中的表单后,显示的页面如图 8.4 所示。

代码清单 8.6Sessions 控制器中 create 动作的初始版本
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    render 'new'
  end

  def destroy
  end
end
initial failed login 3rd edition
图 8.4:添加代码清单 8.6 中的 create 动作后,登录失败时显示的页面

仔细看一下图 8.4 中显示的调试信息,你会发现,正如 8.1.2 节末尾所说,提交表单后会生成 params 散列,电子邮件地址和密码都在 :session 键中(下述代码省略了一些 Rails 内部使用的信息):

---
session:
  email: 'user@example.com'
  password: 'foobar'
commit: Log in
action: create
controller: sessions

与注册表单类似(图 7.15),这些参数是一个嵌套散列,在代码清单 4.13 中见过。具体而言,params 包含了如下的嵌套散列:

{ session: { password: "foobar", email: "user@example.com" } }

也就是说

params[:session]

本身就是一个散列:

{ password: "foobar", email: "user@example.com" }

所以,

params[:session][:email]

是提交的电子邮件地址,而

params[:session][:password]

是提交的密码。

也就是说,在 create 动作中,params 散列包含了使用电子邮件地址和密码验证用户身份所需的全部数据。其实,我们已经有了所需的方法:Active Record 提供的 User.find_by 方法(6.1.4 节)和 has_secure_password 提供的 authenticate 方法(6.3.4 节)。前面说过,如果身份验证失败,authenticate 方法返回 false。基于上述分析,我们计划按照代码清单 8.7 中的方式实现用户登录功能。

代码清单 8.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])
      # 登入用户,然后重定向到用户的资料页面
    else
      # 创建一个错误消息
      render 'new'
    end
  end

  def destroy
  end
end

代码清单 8.7 中高亮显示的第一行使用提交的电子邮件地址从数据库中取出相应的用户。(我们在 6.2.5 节说过,电子邮件地址都是以小写字母形式保存的,所以这里调用了 downcase 方法,确保提交有效的地址后能查到相应的记录。)高亮显示的第二行看起来很怪,但在 Rails 中经常使用:

user && user.authenticate(params[:session][:password])

我们使用 &&(逻辑与)检测获取的用户是否有效。因为除了 nilfalse 之外的所有对象都被视作真值,所以上面这个语句可能出现的结果如表 8.2所示。从表中可以看出,当且仅当数据库中存在提交的电子邮件地址,而且对应的密码和提交的密码匹配时,这个语句才会返回 true

表 8.2user && user.authenticate(…​) 可能得到的结果
用户 密码 a && b

不存在

任意值

(nil && [anything]) == false

有效用户

错误的密码

(true && false) == false

有效用户

正确的密码

(true && true) == true

练习
  1. 在 Rails 控制台中确认表 8.2 中的各个值。先从 user = nil 开始,然后使用 user = User.first。提示:为了把结果转换成布尔值,要使用 4.2.3 节讲过的两个感叹号,例如 !!(user && user.authenticate('foobar'))

8.1.4 渲染闪现消息

7.3.3 节,我们使用 User 模型的验证错误显示注册失败时的错误消息。这些错误关联在某个 Active Record 对象上,不过现在不能使用这种方式了,因为会话不是 Active Record 模型。我们要采取的方法是,登录失败时,在闪现消息中显示消息。代码清单 8.8 是我们首次尝试实现写出的代码,其中有个小小的错误。

代码清单 8.8:尝试处理登录失败(有个小小的错误)
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])
      # 登入用户,然后重定向到用户的资料页面
    else
      flash[:danger] = 'Invalid email/password combination' # 不完全正确
      render 'new'
    end
  end

  def destroy
  end
end
failed login flash 3rd edition
图 8.5:登录失败后显示的闪现消息
flash persistence 3rd edition
图 8.6:闪现消息一直存在

布局中已经加入了显示闪现消息的局部视图(代码清单 7.31),所以无需其他修改,flash[:danger] 消息就能显示出来;而且因为使用了 Bootstrap 提供的 CSS,消息的样式也很美观,如图 8.5 所示。

不过,就像代码清单 8.8 中的注释所说,代码不完全正确。显示的页面看起来很正常啊,有什么问题呢?问题在于,闪现消息在一个请求的生命周期内是持续存在的,而重新渲染页面(使用 render 方法)与代码清单 7.29 中的重定向不同,不算是一次新请求,所以你会发现这个闪现消息存在的时间比预期的要长很多。例如,提交无效的登录信息,然后访问首页,还会显示这个闪现消息,如图 8.6 所示。8.1.5 节会修正这个问题。

8.1.5 测试闪现消息

闪现消息的错误表现是应用的一个小 bug。根据旁注 3.3 中的测试指导方针,遇到这种情况应该编写测试,捕获错误,防止以后再发生。因此,在继续之前,我们要为登录表单的提交操作编写一个简短的集成测试。测试能捕获这个问题,也能避免回归,而且还能为后面的登录和退出功能的集成测试奠定好的基础。

首先,为应用的登录功能生成一个集成测试文件:

$ rails generate integration_test users_login
      invoke  test_unit
      create    test/integration/users_login_test.rb

然后,我们要编写一个测试,模拟图 8.5图 8.6 中的连续操作。基本的步骤如下:

  1. 访问登录页面;

  2. 确认正确渲染了登录表单;

  3. 提交无效的 params 散列,向登录路径发起 post 请求;

  4. 确认重新渲染了登录表单,而且显示了一个闪现消息;

  5. 访问其他页面(例如首页);

  6. 确认这个页面中没显示前面那个闪现消息。

实现上述步骤的测试如代码清单 8.9 所示。

代码清单 8.9:捕获继续显示闪现消息的测试 RED
test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  test "login with invalid information" do
    get login_path
    assert_template 'sessions/new'
    post login_path, params: { session: { email: "", password: "" } }
    assert_template 'sessions/new'
    assert_not flash.empty?
    get root_path
    assert flash.empty?
  end
end

添加上述测试之后,登录测试应该失败:

代码清单 8.10RED
$ rails test test/integration/users_login_test.rb

为了只运行一个测试文件,执行 rails test 命令时我们指定了文件的完整路径。

代码清单 8.9 中的测试通过的方法是,把 flash 换成特殊的 flash.nowflash.now 专门用于在重新渲染的页面中显示闪现消息。与 flash 不同的是,flash.now 中的内容会在下次请求时消失——这正是代码清单 8.9 中的测试所需的行为。替换之后,正确的应用代码如代码清单 8.11 所示。

代码清单 8.11:处理登录失败正确的代码 GREEN
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])
      # 登入用户,然后重定向到用户的资料页面
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

然后,我们可以确认登录功能的集成测试和整个测试组件都能通过:

代码清单 8.12GREEN
$ rails test test/integration/users_login_test.rb
$ rails test
练习
  1. 在你的浏览器中确认前面的步骤是正确的,即访问另一个页面时没有显示闪现消息。

8.2 登录

登录表单已经可以处理无效提交,下一步要正确处理有效提交,登入用户。本节通过临时会话让用户登录,浏览器关闭后会话自动失效。9.1 节将实现持久会话,即便浏览器关闭,用户依然处于登录状态。

实现会话的过程中要定义很多相关的函数,在多个控制器和视图中使用。4.2.5 节说过,Ruby 支持使用模块把这些函数集中放在一处。Rails 生成器很人性化,生成 Sessions 控制器时(8.1.1 节)自动生成了一个 Sessions 辅助模块。而且,其中的辅助方法会自动引入 Rails 视图。如果在控制器的基类(ApplicationController)中引入辅助方法模块,还可以在控制器中使用,如代码清单 8.13 所示。[3]

代码清单 8.13:在 Application 控制器中引入 Sessions 辅助模块
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

做好这些准备工作后,现在可以开始编写代码登入用户了。

8.2.1 log_in 方法

有 Rails 提供的 session 方法协助,登入用户很简单。(session 方法与 8.1.1 节生成的 Sessions 控制器没有关系。)我们可以把 session 视作一个散列,按照下面的方式赋值:

session[:user_id] = user.id

这么做会在用户的浏览器中创建一个临时 cookie,内容是加密后的用户 ID。在后续的请求中,可以使用 session[:user_id] 取回这个 ID。9.1 节使用的 cookies 方法创建的是持久 cookie,而 session 方法创建的是临时会话,浏览器关闭后立即失效。

我们想在多个不同的地方使用这个登录方式,所以在 Sessions 辅助模块中定义一个名为 log_in 的方法,如代码清单 8.14 所示。

代码清单 8.14log_in 方法
app/helpers/sessions_helper.rb
module SessionsHelper

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

session 方法创建的临时 cookie 会自动加密,所以代码清单 8.14 中的代码是安全的,攻击者无法使用会话中的信息以该用户的身份登录。不过,只有 session 方法创建的临时 cookie 是这样,cookies 方法创建的持久 cookie 则有可能会受到会话劫持(session hijacking)攻击。所以在 9.1 节我们会小心处理存入用户浏览器中的信息。

定义好 log_in 方法后,我们可以完成 Sessions 控制器中的 create 动作,登入用户,然后重定向到用户的资料页面,如代码清单 8.15 所示。[4]

代码清单 8.15:登入用户
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
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
  end
end

注意简洁的重定向代码

redirect_to user

我们在 7.4.1 节见过。Rails 会自动转换成用户资料页的地址:

user_url(user)

定义好 create 动作后,代码清单 8.4 中的登录表单就可以使用了。不过从应用的外观上看不出什么区别,除非直接查看浏览器中的会话,否则没有方法判断用户是否已经登录。8.2.2 节会使用会话中的用户 ID 从数据库中取回当前用户,做些视觉上的变化。8.2.3 节会修改网站布局中的链接,添加一个指向当前用户资料页面的链接。

练习
  1. 使用有效的信息登录,然后查看浏览器中的 cookies。会话的内容是什么?提示:如果不知道怎么在浏览器中查看 cookies,使用 Google 搜索。

  2. cookies 中 Expires 字段的值是什么?

8.2.2 当前用户

把用户 ID 安全地存储在临时会话中之后,在后续的请求中可以将其读取出来。我们要定义一个名为 current_user 的方法,从数据库中取出用户 ID 对应的用户。current_user 方法可用于编写类似下面的代码:

<%= current_user.name %>

或是:

redirect_to current_user

查找用户的方法之一是使用 find 方法,在用户资料页面就是这么做的(代码清单 7.5):

User.find(session[:user_id])

6.1.4 节说过,如果用户 ID 不存在,find 方法会抛出异常。在用户的资料页面可以使用这种行为,因为必须有相应的用户才能显示他的信息。但 session[:user_id] 的值经常是 nil(表示用户未登录),所以我们要使用 create 动作中通过电子邮件地址查找用户的 find_by 方法,通过 id 查找用户:

User.find_by(id: session[:user_id])

如果 ID 无效,find_by 方法返回 nil,而不会抛出异常。

因此,我们可以按照下面的方式定义 current_user 方法:

def current_user
  if session[:user_id]
    User.find_by(id: session[:user_id])
  end
end

(如果会话中没有用户 ID,这个方法直接结束,返回 nil,而这正是我们需要的行为。)这样定义应该可以,不过如果在一个页面中多次调用 current_user 方法,会多次查询数据库。所以,我们要使用一种 Ruby 习惯写法,把 User.find_by 的结果存储在实例变量中,只在第一次调用时查询数据库,后续再调用直接返回实例变量中存储的值:[5]

if @current_user.nil?
  @current_user = User.find_by(id: session[:user_id])
else
  @current_user
end

使用 4.2.3 节中介绍的“或”运算符 ||,可以把这段代码改写成:

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

User 对象是真值,所以仅当 @current_user 没有赋值时才会执行 find_by 方法。

上述代码虽然可以使用,但并不符合 Ruby 的习惯。@current_user 赋值语句的正确写法是这样:

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

这种写法用到了容易让人困惑的 ||=(或等)运算符,参见旁注 8.1 中的说明。

综上所述,current_user 方法更简洁的定义方式如代码清单 8.16 所示。

代码清单 8.16:在会话中查找当前用户
app/helpers/sessions_helper.rb
module SessionsHelper

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

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

定义好 current_user 方法之后,可以根据用户的登录状态修改应用的布局了。

练习
  1. 打开 Rails 控制台,确认用户不存在时 User.find_by(id: …​) 返回 nil

  2. 在 Rails 控制台中创建 session 散列,有个键为 :user_id。按照代码清单 8.17 中的步骤,确认 ||= 运算符的行为符合预期。

代码清单 8.17:在控制台中模拟 session
>> session = {}
>> session[:user_id] = nil
>> @current_user ||= User.find_by(id: session[:user_id])
<What happens here?>
>> session[:user_id]= User.first.id
>> @current_user ||= User.find_by(id: session[:user_id])
<What happens here?>
>> @current_user ||= User.find_by(id: session[:user_id])
<What happens here?>

8.2.4 测试布局中的变化

我们自己动手验证了成功登录后应用的表现正常,在继续之前,还要编写集成测试检查这些行为,以及捕获回归。我们将在代码清单 8.9 的基础上,再添加一些测试,检查下面的操作步骤:

  1. 访问登录页面;

  2. 通过 post 请求发送有效的登录信息;

  3. 确认登录链接消失了;

  4. 确认出现了退出链接;

  5. 确认出现了资料页面链接。

为了检查这些变化,在测试中要登入已经注册的用户,也就是说数据库中必须有一个用户。Rails 默认使用固件实现这种需求。固件是一种组织数据的方式,这些数据会载入测试数据库。6.2.5 节删除了默认生成的固件(代码清单 6.31),目的是让检查电子邮件地址的测试通过。现在,我们要在这个空文件中加入自定义的固件。

目前,我们只需要一个用户,它的名字和电子邮件地址应该是有效的。因为我们要登入这个用户,所以还要提供正确的密码,与提交给 Sessions 控制器中 create 动作的密码比较。参照图 6.8 中的数据模型,可以看出,我们要在用户固件中定义 password_digest 属性。我们将定义 digest 方法计算这个属性的值。

6.3.1 节说过,密码摘要使用 bcrypt 生成(通过 has_secure_password 方法),所以固件中的密码摘要也要使用这种方式生成。查看安全密码的源码后,我们发现生成摘要的方法是:

BCrypt::Password.create(string, cost: cost)

其中,string 是要计算哈希值的字符串;cost 是耗时因子,决定计算哈希值时消耗的资源。耗时因子的值越大,由哈希值破解出原密码的难度越大。这个值对生产环境的安全防护很重要,但在测试中我们希望 digest 方法的执行速度越快越好。安全密码的源码中还有这么一行:

cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                              BCrypt::Engine.cost

这行代码相当难懂,你无须完全理解;它的作用是严格实现前面的分析:在测试中耗时因子使用最小值,在生产环境则使用普通(最大)值。(9.2 节会深入介绍奇怪的 ?-: 句法。)

digest 方法可以放在几个不同的地方,但 9.1.1 节会在 User 模型中使用,所以建议放在 user.rb 文件中。因为计算摘要时不用获取用户对象,所以我们要把 digest 方法附在 User 类上,也就是定义为类方法(4.4.1 节简要介绍过)。结果如代码清单 8.21 所示。

代码清单 8.21:定义固件中要使用的 digest 方法
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
end

定义好 digest 方法后,我们可以创建一个有效的用户固件了,如代码清单 8.22 所示。[12]

代码清单 8.22:测试用户登录所需的固件
test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

特别注意一下,固件中可以使用嵌入式 Ruby。因此,我们可以使用

<%= User.digest('password') %>

生成测试用户正确的密码摘要。

我们虽然定义了 has_secure_password 所需的 password_digest 属性,但有时也需要使用密码的原始值。可是,在固件中无法实现,如果在代码清单 8.22 中添加 password 属性,Rails 会提示数据库中没有这个列(确实没有)。所以,我们约定固件中所有用户的密码都一样,即 'password'

创建了一个有效用户固件后,在测试中可以使用下面的方式获取这个用户:

user = users(:michael)

其中,users 对应固件文件 users.yml 的文件名,:michael代码清单 8.22 中定义的用户。

定义好用户固件之后,现在可以把本节开头列出的操作步骤转换成代码了,如代码清单 8.23 所示。

代码清单 8.23:测试使用有效信息登录的情况 GREEN
test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with valid information" do
    get login_path
    post login_path, params: { session: { email:    @user.email,
                                          password: 'password' } }
    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)
  end
end

在这段代码中,我们使用 assert_redirected_to @user 检查重定向的地址是否正确;使用 follow_redirect! 访问重定向的目标地址。还确认页面中有零个登录链接,从而证实登录链接消失了:

assert_select "a[href=?]", login_path, count: 0

count: 0 参数的目的是告诉 assert_select,我们期望页面中有零个匹配指定模式的链接。(代码清单 5.32中使用的是 count: 2,指定必须有两个匹配模式的链接。)

因为应用代码已经能正常运行,所以这个测试应该可以通过:

代码清单 8.24GREEN
$ rails test test/integration/users_login_test.rb
  1. 删除 Sessions 辅助模块里 logged_in? 方法中的 !,确认代码清单 8.23 中的测试无法通过。

  2. ! 添加回去,让测试通过。

8.2.5 注册后直接登录

虽然现在基本完成了身份验证功能,但是新注册的用户可能还是会困惑,为什么注册后没有登录呢?注册后立即要求用户登录是很奇怪的,所以我们要在注册的过程中自动登入用户。为了实现这一功能,我们只需在 User 控制器的 create 动作中调用 log_in 方法,如代码清单 8.25 所示。[13]

代码清单 8.25:注册后登入用户
app/controllers/users_controller.rb
class UsersController < ApplicationController

  def show
    @user = User.find(params[:id])
  end

  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end

  private

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

为了测试这个功能,我们可以在代码清单 7.33 中添加一行代码,检查用户是否已经登录。我们可以定义一个 is_logged_in? 辅助方法,功能和代码清单 8.18 中的 logged_in? 方法一样,如果(测试环境的)会话中有用户的 ID 就返回 true,否则返回 false,如代码清单 8.26 所示。(我们不能像代码清单 8.18 那样使用 current_user,因为在测试中不能使用 current_user 方法,但是可以使用 session 方法。)我们定义的方法不是 logged_in?,而是 is_logged_in?,以免混淆。[14]

代码清单 8.26:在测试中定义检查登录状态的方法,返回布尔值
test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

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

然后,我们可以使用代码清单 8.27 中的测试检查注册后用户有没有登录。

代码清单 8.27:测试注册后有没有登入用户 GREEN
test/integration/users_signup_test.rb
require 'test_helper'

class UsersSignupTest < ActionDispatch::IntegrationTest
  .
  .
  .
  test "valid signup information" do
    get signup_path
    assert_difference 'User.count', 1 do
      post users_path, params: { user: { name:  "Example User",
                                         email: "user@example.com",
                                         password:              "password",
                                         password_confirmation: "password" } }
    end
    follow_redirect!
    assert_template 'users/show'
    assert is_logged_in?
  end
end

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

代码清单 8.28GREEN
$ rails test
练习
  1. 如果把代码清单 8.25 中高亮的 log_in 那行注释掉,测试组件能通过吗?

8.3 退出

8.1 节说过,我们要实现的身份验证系统会记住用户的登录状态,直到用户自行退出为止。本节,我们就要实现退出功能。退出链接已经定义好了(代码清单 8.19),所以我们只需编写一个正确的控制器动作,销毁用户会话。

目前为止,Sessions 控制器的动作都遵从 REST 架构,new 动作用于登录页面,create 动作完成登录操作。我们要继续使用 REST 架构,添加一个 destroy 动作,删除会话,实现退出功能。登录功能在代码清单 8.15代码清单 8.25 中都用到了,但退出功能不同,只在一处使用,所以我们会直接把相关的代码写在 destroy 动作中。9.3 节会看到,这么做(稍微重构后)易于测试身份验证系统。

退出要撤销 log_in代码清单 8.14)完成的操作,即从会话中删除用户的 ID。为此,我们要使用 delete 方法,如下所示:

session.delete(:user_id)

我们还要把当前用户设为 nil。不过现在这种情况下做不做这一步都没关系,因为退出后会立即转向根地址。[15]log_in 及相关的方法一样,我们要把 log_out 方法放在 Sessions 辅助模块中,如代码清单 8.29 所示。

代码清单 8.29log_out 方法
app/helpers/sessions_helper.rb
module SessionsHelper

  # 登入指定的用户
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 退出当前用户
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

然后,在 Sessions 控制器的 destroy 动作中调用 log_out 方法,如代码清单 8.30 所示。

代码清单 8.30:销毁会话(退出用户)
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
      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.23 中的用户登录测试中添加一些步骤,测试退出功能。登录后,使用 delete 方法向退出地址(表 8.1)发起 DELETE 请求,然后确认用户已经退出,而且重定向到了根地址。我们还要确认出现了登录链接,而且退出和资料页面的链接消失了。测试中新加入的步骤如代码清单 8.31 所示。

代码清单 8.31:测试用户退出功能 GREEN
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
    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

(现在可以在测试中使用 is_logged_in? 了,所以向登录地址发送有效信息之后,我们添加了 assert is_logged_in?。)

定义并测试了 destroy 动作之后,注册、登录和退出三大功能就都实现了。现在测试组件应该可以通过:

代码清单 8.32GREEN
$ rails test

练习

  1. 在浏览器中确认点击“Log out”(退出)链接后网站布局能正确地变化。这些变化与代码清单 8.31 中最后三步有什么关系?

  2. 用户退出后查看网站的 cookie,确认会话被删除了。

8.4 小结

本章为演示应用实现了完整的登录和身份验证系统。下一章将更进一步,添加记住用户功能,让会话持久一些,关闭浏览器后不会被清除。

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

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

然后推送到远程仓库:

$ rails test
$ git push

最后,像往常一样部署到 Heroku 中:

git push heroku

8.4.1 本章所学

  • Rails 使用 session 方法在临时 cookie 中维护页面之间的状态;

  • 登录表单的目的是创建新会话,登入用户;

  • flash.now 方法用于在重新渲染的页面中显示闪现消息;

  • 在测试中重现问题时可以使用测试驱动开发;

  • 使用 session 方法可以安全地在浏览器中存储用户 ID,创建临时会话;

  • 可以根据登录状态修改功能,例如布局中显示的链接;

  • 集成测试可以检查路由、数据库更新和对布局的修改;

  1. 有些浏览器提供了恢复这种会话的功能,可以继续使用离开时的状态。当然,Rails 不会禁止这种行为。
  2. 另一种方法是不用 form_for,换用 form_tag,这样更符合 Rails 的习惯做法。不过,换用 form_tag 后,登录表单和注册表单的共同点就少了,现阶段我想强调二者之间的共通结构。
  3. 我喜欢使用这种方式,因为它借助的是 Ruby 引入模块的方式;不过,Rails 4 引入了一种方式叫做 concern,也可以做这个用途使用。如果想学习如何使用 concern,请搜索“how to use concerns in Rails”。
  4. 因为代码清单 8.13 引入了辅助方法模块,所以在 Sessions 控制器中可以使用 log_in 方法。
  5. 在多次方法调用之间记住返回值的方式叫备忘(memoization)。(注意,这是一个技术术语,不是“memorization”的错误拼写。)
  6. 头像出处:https://www.flickr.com/photos/elevy/14730820387。Copyright © 2014 by Elias Levy。未经改动,基于“知识共享 署名 2.0 通用”许可证使用。
  7. Web 浏览器其实不能发送 DELETE 请求,Rails 使用 JavaScript 模拟实现。
  8. 详情参见 Bootstrap 组件文档
  9. Rails 的早期版本自动引入 jQuery,但是从 Rails 5.1 起不再是 Rails 的依赖了。
  10. 可能要重启 Web 服务器。
  11. 如果使用云端 IDE,建议你使用另一个浏览器测试登录,这样就不用关闭运行云端 IDE 的浏览器了。
  12. 注意,固件文件中的缩进必须使用空格,不能使用制表符。复制代码(如代码清单 8.22)时要留意这一点。
  13. 因为在代码清单 8.13 中引入了辅助模块,所以 User 控制器和 Sessions 控制器一样,也可以调用 log_in 方法。
  14. 有一次我不小心把 Sessions 辅助模块中的 log_in 方法删掉了,但是测试组件仍能通过,因为测试使用了同名辅助方法,就算应用完全不能运行,测试还是可以通过。与 is_logged_in? 一样,为了避免这种问题,代码清单 9.24 中还会定义一个名为 log_in_as 的测试辅助方法。
  15. 如果在执行 destroy 动作之前创建了 @current_user(这里没有创建),并且没有立即重定向,就要把 @current_user 设为 nil(这里立即重定向了)。这两种情况不可能同时发生,而且根据这个应用目前的架构,也没必要这么做。不过这涉及到安全问题,所以以防万一,我在这里把当前用户设为了 nil