8.2 登录

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

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

代码清单 8.11:在 ApplicationController 中引入会话辅助方法模块

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 节生成的会话控制器没有关系。)我们可以把 session 视作一个哈希,可以按照下面的方式赋值:

session[:user_id] = user.id

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

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

代码清单 8.12:log_in 方法

app/helpers/sessions_helper.rb

module SessionsHelper

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

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

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

代码清单 8.13:登入用户

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.2 中的登录表单就可以使用了。不过从应用的外观上看不出什么区别,除非直接查看浏览器中的会话,否则没有方法判断用户是否已经登录。8.2.2 节会使用会话中的用户 ID 从数据库中取回当前用户,做些视觉上的变化。8.2.3 节会修改网站布局中的链接,还会添加一个指向当前用户资料页面的链接。

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

这样定义应该可以,不过如果页面中多次调用 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 中的说明。

旁注 8.1:||= 操作符简介

||=(或等)赋值操作符在 Ruby 中常用,因此有追求的 Rails 开发者要学会使用。初学时可能会觉得 ||= 很神秘,不过和其他操作符对比之后,你会发现也不难理解。

我们先来看一下常见的变量自增一赋值:

x = x + 1

很多编程语言都为这种操作提供了简化的操作符,在 Ruby 中(C、C++、Perl、Python、Java 等也可以),可以写成下面这样:

x += 1

其他操作符也有类似的简化形式:

$ rails console
>> x = 1
=> 1
>> x += 1
=> 2
>> x *= 3
=> 6
>> x -= 8
=> -2
>> x /= 2
=> -1

通过上面的例子可以得知,x = x O yx O=y 是等效的,其中 O 表示操作符。

在 Ruby 中还经常会遇到这种情况,如果变量的值为 nil 则给它赋值,否则就不改变这个变量的值。我们可以使用 4.2.3 节介绍的或操作符(||)编写下面的代码:

>> @foo
=> nil
>> @foo = @foo || "bar"
=> "bar"
>> @foo = @foo || "baz"
=> "bar"

因为 nil 是“假值”,所以第一个赋值语句等同于 nil || "bar",得到的结果是 "bar"。同样,第二个赋值操作等同于 "bar" || "baz",得到的结果还是 "bar"。这是因为除了 nilfalse 之外,其他值都是“真值”,而如果第一个表达式的值是真值,|| 会终止执行。(或操作的执行顺序从左至右,只要出现真值就会终止语句的执行,这种方式叫“短路计算”(short-circuit evaluation)。)

和前面的控制台会话对比之后,我们发现 @foo = @foo || "bar" 符合 x = x O y 形式,其中 || 就是 O

x    =   x   +   1      ->     x     +=   1
x    =   x   *   3      ->     x     *=   3
x    =   x   -   8      ->     x     -=   8
x    =   x   /   2      ->     x     /=   2
@foo = @foo || "bar"    ->     @foo ||= "bar"

因此,@foo = @foo || "bar"@foo ||= "bar" 两种写法是等效的。在获取当前用户时,建议使用下面的写法:

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

不难理解吧![6]

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

代码清单 8.14:在会话中查找当前用户

app/helpers/sessions_helper.rb

module SessionsHelper

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

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

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

8.2.3 修改布局中的链接

实现登录功能后,我们要根据登录状态修改布局中的链接。具体而言,我们要添加退出链接、用户设置页面的链接、用户列表页面的链接和当前用户的资料页面链接,构思图如图 8.7 所示。[7]注意,退出链接和资料页面的链接在“Account”(账户)下拉菜单中。使用 Bootstrap 实现下拉菜单的方法参见代码清单 8.16

login success mockup图 8.7:成功登录后显示的资料页面构思图

此时,在现实开发中,我会考虑编写集成测试检测上面规划的行为。我在旁注 3.3 中说过,当你熟练掌握 Rails 的测试工具后,会倾向于先写测试。但这个测试涉及到一些新知识,所以最好在专门的一节中编写(8.2.4 节)。

修改网站布局中的链接时要在 ERb 中使用 if-else 语句,用户登录时显示一组链接,未登录时显示另一组链接:

<% if logged_in? %>
 # 登录用户看到的链接
<% else %>
 # 未登录用户看到的链接
<% end %>

为了编写这种代码,我们需要定义 logged_in? 方法,返回布尔值。

用户登录后,当前用户存储在会话中,即 current_user 不是 nil。检测会话中有没有当前用户要使用“非”操作符(4.2.3 节)。“非”操作符写做 !,经常读作“bang”。logged_in? 方法的定义如代码清单 8.15 所示。

代码清单 8.15:logged_in? 辅助方法

app/helpers/sessions_helper.rb

module SessionsHelper

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

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

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

定义好 logged_in? 方法之后,可以修改用户登录后显示的链接了。我们要添加四个新链接,其中两个链接的地址先使用占位符,第 9 章会换成真正的地址:

<%= link_to "Users",    '#' %>
<%= link_to "Settings", '#' %>

退出链接使用代码清单 8.1 中定义的退出页面地址:

<%= link_to "Log out", logout_path, method: "delete" %>

注意,退出链接中指定了哈希参数,指明这个链接发送的是 HTTP DELETE 请求。[8]我们还要添加资料页面的链接:

<%= link_to "Profile", current_user %>

这个链接可以写成:

<%= link_to "Profile", user_path(current_user) %>

和之前一样,我们可以直接链接到用户对象,Rails 会自动把 current_user 转换成 user_path(current_user)。最后,如果用户未登录,我们要添加一个链接,使用代码清单 8.1 中定义的登录地址,链接到登录页面:

<%= link_to "Log in", login_path %>

把这些链接都放到头部局部视图中,得到的视图如代码清单 8.16 所示。

代码清单 8.16:修改布局中的链接

app/views/layouts/_header.html.erb

<header class="navbar navbar-fixed-top navbar-inverse">
 <div class="container">
  <%= link_to "sample app", root_path, id: "logo" %>
 <nav>
 <ul class="nav navbar-nav navbar-right">
 <li><%= link_to "Home", root_path %></li>
 <li><%= link_to "Help", help_path %></li>
  <% if logged_in? %>
 <li><%= link_to "Users", '#' %></li>
 <li class="dropdown">
 <a href="#" class="dropdown-toggle" data-toggle="dropdown">
 Account <b class="caret"></b>
 </a>
 <ul class="dropdown-menu">
 <li><%= link_to "Profile", current_user %></li>
 <li><%= link_to "Settings", '#' %></li>
 <li class="divider"></li>
 <li>
  <%= link_to "Log out", logout_path, method: "delete" %>
 </li>
 </ul>
 </li>
  <% else %>
 <li><%= link_to "Log in", login_path %></li>
  <% end %>
 </ul>
 </nav>
 </div>
</header>

除了在布局中添加新链接之外,代码清单 8.16 还借助 Bootstrap 实现了下拉菜单。[9]注意这段代码中使用的几个 Bootstrap CSS 类:dropdowndropdown-menu 等。为了让下拉菜单生效,我们要在 application.js(Asset Pipeline 的一部分)中引入 Bootstrap 提供的 JavaScript 库,如代码清单 8.17 所示。

代码清单 8.17:在 application.js 中引入 Bootstrap JavaScript 库

app/assets/javascripts/application.js

//= require jquery
//= require jquery_ujs
//= require bootstrap //= require turbolinks
//= require_tree .

现在,你应该访问登录页面,然后使用有效账户登录——这样足以测试前三节编写的代码表现是否正常。[10]添加代码清单 8.16代码清单 8.17 中的代码后,应该能看到下拉菜单和只有已登录用户才能看到的链接,如图 8.8 所示。如果关闭浏览器,还能确认应用确实忘了登录状态,必须再次登录才能看到上述改动。

profile with logout link 3rd edition图 8.8:用户登录后看到了新添加的链接和下拉菜单

8.2.4 测试布局中的变化

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

  1. 访问登录页面;

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

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

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

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

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

目前,我们只需要一个用户,它的名字和电子邮件地址应该是有效的。因为我们要登入这个用户,所以还要提供正确的密码,和提交给会话控制器中 create 动作的密码比较。参照图 6.7 中的数据模型,可以看出,我们要在用户固件中定义 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

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

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

代码清单 8.18:定义固件中要使用的 digest 方法

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
end

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

代码清单 8.19:测试用户登录所需的固件

test/fixtures/users.yml

michael:
  name: Michael Example
  email: [email protected]
  password_digest: <%= User.digest('password') %>

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

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

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

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

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

user = users(:michael)

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

定义好用户固件之后,现在可以把本节开头列出的操作步骤转换成代码了,如代码清单 8.20 所示。(注意,这段代码中的 getpost 两步严格来说没有关系,其实向控制器发起 POST 请求之前没必要向登录页面发起 GET 请求。我之所以加入这一步是为了明确表明操作步骤,以及确认渲染登录表单时没有错误。)

代码清单 8.20:测试使用有效信息登录的情况 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, 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.25中使用的是 count: 2,指定必须有两个匹配模式的链接。)

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

代码清单 8.21:GREEN
$ bundle exec rake test TEST=test/integration/users_login_test.rb \
>                       TESTOPTS="--name test_login_with_valid_information"

上述命令说明了如何运行一个测试文件中的某个测试——使用如下参数,并指定测试的名字:

TESTOPTS="--name test_login_with_valid_information"

(测试的名字是使用下划线把“test”和测试说明连接在一起。)

8.2.5 注册后直接登录

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

代码清单 8.22:注册后登入用户

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.26 中添加一行代码,检查用户是否已经登录。我们可以定义一个 is_logged_in? 辅助方法,功能和代码清单 8.15 中的 logged_in? 方法一样,如果(测试环境的)会话中有用户的 ID 就返回 true,否则返回 false,如代码清单 8.23 所示。(我们不能像代码清单 8.15 那样使用 current_user,因为在测试中不能使用 current_user 方法,但是可以使用 session 方法。)我们定义的方法不是 logged_in?,而是 is_logged_in?——测试辅助方法和会话辅助方法名字不一样,以免混淆。[12]

代码清单 8.23:在测试中定义检查登录状态的方法,返回布尔值

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.24 中的测试检查注册后用户有没有登录。

代码清单 8.24:测试注册后有没有登入用户 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_via_redirect users_path, user: { name:  "Example User",
                                            email: "[email protected]",
                                            password:              "password",
                                            password_confirmation: "password" }
    end
    assert_template 'users/show'
 assert is_logged_in?  end
end

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

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