8.2 登录
最后更新于:2022-04-01 22:29:34
# 8.2 登录
登录表单已经可以处理无效提交,下一步要正确处理有效提交,登入用户。本节通过临时会话让用户登录,浏览器关闭后会话自动失效。[8.4 节](#remember-me)会实现持久会话,即便浏览器关闭,依然处于登录状态。
实现会话的过程中要定义很多相关的函数,而且要在多个控制器和视图中使用。[4.2.5 节](chapter4.html#back-to-the-title-helper)说过,Ruby 支持使用“模块”把这些函数集中放在一处。Rails 生成器很人性化,生成会话控制器时([8.1.1 节](#sessions-controller))自动生成了一个会话辅助方法模块。而且,其中的辅助方法会自动引入 Rails 视图。如果在控制器的基类(`ApplicationController`)中引入辅助方法模块,还可以在控制器中使用,如[代码清单 8.11](#listing-sessions-helper-include) 所示。
##### 代码清单 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 节](#sessions-controller)生成的会话控制器没有关系。)我们可以把 `session` 视作一个哈希,可以按照下面的方式赋值:
```
session[:user_id] = user.id
```
这么做会在用户的浏览器中创建一个临时 cookie,内容是加密后的用户 ID。在后续的请求中,可以使用 `session[:user_id]` 取回这个 ID。[8.4 节](#remember-me)使用的 `cookies` 方法创建的是持久 cookie,而 `session` 方法创建的是临时会话,浏览器关闭后立即失效。
我们想在多个不同的地方使用这个登录方式,所以在会话辅助方法模块中定义一个名为 `log_in` 的方法,如[代码清单 8.12](#listing-log-in-function) 所示。
##### 代码清单 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](#listing-log-in-function) 中的代码是安全的,攻击者无法使用会话中的信息以该用户的身份登录。不过,只有 `session` 方法创建的临时 cookie 是这样,`cookies` 方法创建的持久 cookie 则有可能会受到“会话劫持”(session hijacking)攻击。所以在 [8.4 节](#remember-me)我们会小心处理存入用户浏览器中的信息。
定义好 `log_in` 方法后,我们可以完成会话控制器中的 `create` 动作了——登入用户,然后重定向到用户的资料页面,如[代码清单 8.13](#listing-log-in-success) 所示。[[4](#fn-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 节](chapter7.html#the-finished-signup-form)见过。Rails 会自动把地址转换成用户资料页的地址:
```
user_url(user)
```
定义好 `create` 动作后,[代码清单 8.2](#listing-login-form) 中的登录表单就可以使用了。不过从应用的外观上看不出什么区别,除非直接查看浏览器中的会话,否则没有方法判断用户是否已经登录。[8.2.2 节](#current-user)会使用会话中的用户 ID 从数据库中取回当前用户,做些视觉上的变化。[8.2.3 节](#changing-the-layout-links)会修改网站布局中的链接,还会添加一个指向当前用户资料页面的链接。
## 8.2.2 当前用户
把用户 ID 安全地存储在临时会话中之后,在后续的请求中可以将其读取出来。我们要定义一个名为 `current_user` 的方法,从数据库中取出用户 ID 对应的用户。`current_user` 方法的作用是编写类似下面的代码:
```
<%= current_user.name %>
```
或是:
```
redirect_to current_user
```
查找用户的方法之一是使用 `find` 方法,在用户资料页面就是这么做的([代码清单 7.5](chapter7.html#listing-user-show-action)):
```
User.find(session[:user_id])
```
[6.1.4 节](chapter6.html#finding-user-objects)说过,如果用户 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](#fn-5)]
```
if @current_user.nil?
@current_user = User.find_by(id: session[:user_id])
else
@current_user
end
```
使用 [4.2.3 节](chapter4.html#objects-and-message-passing)中介绍的“或”操作符 `||`,可以把这段代码改写成:
```
@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](#aside-or-equals) 中的说明。
##### 旁注 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 y` 和 `x O=y` 是等效的,其中 `O` 表示操作符。
在 Ruby 中还经常会遇到这种情况,如果变量的值为 `nil` 则给它赋值,否则就不改变这个变量的值。我们可以使用 [4.2.3 节](chapter4.html#objects-and-message-passing)介绍的或操作符(`||`)编写下面的代码:
```
>> @foo
=> nil
>> @foo = @foo || "bar"
=> "bar"
>> @foo = @foo || "baz"
=> "bar"
```
因为 `nil` 是“假值”,所以第一个赋值语句等同于 `nil || "bar"`,得到的结果是 `"bar"`。同样,第二个赋值操作等同于 `"bar" || "baz"`,得到的结果还是 `"bar"`。这是因为除了 `nil` 和 `false` 之外,其他值都是“真值”,而如果第一个表达式的值是真值,`||` 会终止执行。(或操作的执行顺序从左至右,只要出现真值就会终止语句的执行,这种方式叫“短路计算”(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](#fn-6)]
综上所述,`current_user` 方法更简洁的定义方式如[代码清单 8.14](#listing-current-user) 所示。
##### 代码清单 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](#fig-login-success-mockup) 所示。[[7](#fn-7)]注意,退出链接和资料页面的链接在“Account”(账户)下拉菜单中。使用 Bootstrap 实现下拉菜单的方法参见[代码清单 8.16](#listing-layout-login-logout-links)。
![login success mockup](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333052b0130.png)图 8.7:成功登录后显示的资料页面构思图
此时,在现实开发中,我会考虑编写集成测试检测上面规划的行为。我在[旁注 3.3](chapter3.html#aside-when-to-test) 中说过,当你熟练掌握 Rails 的测试工具后,会倾向于先写测试。但这个测试涉及到一些新知识,所以最好在专门的一节中编写([8.2.4 节](#testing-layout-changes))。
修改网站布局中的链接时要在 ERb 中使用 `if-else` 语句,用户登录时显示一组链接,未登录时显示另一组链接:
```
<% if logged_in? %>
# 登录用户看到的链接
<% else %>
# 未登录用户看到的链接
<% end %>
```
为了编写这种代码,我们需要定义 `logged_in?` 方法,返回布尔值。
用户登录后,当前用户存储在会话中,即 `current_user` 不是 `nil`。检测会话中有没有当前用户要使用“非”操作符([4.2.3 节](chapter4.html#objects-and-message-passing))。“非”操作符写做 `!`,经常读作“bang”。`logged_in?` 方法的定义如[代码清单 8.15](#listing-logged-in-p) 所示。
##### 代码清单 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 章](chapter9.html#updating-showing-and-deleting-users)会换成真正的地址:
```
<%= link_to "Users", '#' %>
<%= link_to "Settings", '#' %>
```
退出链接使用[代码清单 8.1](#listing-sessions-resource) 中定义的退出页面地址:
```
<%= link_to "Log out", logout_path, method: "delete" %>
```
注意,退出链接中指定了哈希参数,指明这个链接发送的是 HTTP `DELETE` 请求。[[8](#fn-8)]我们还要添加资料页面的链接:
```
<%= link_to "Profile", current_user %>
```
这个链接可以写成:
```
<%= link_to "Profile", user_path(current_user) %>
```
和之前一样,我们可以直接链接到用户对象,Rails 会自动把 `current_user` 转换成 `user_path(current_user)`。最后,如果用户未登录,我们要添加一个链接,使用[代码清单 8.1](#listing-sessions-resource) 中定义的登录地址,链接到登录页面:
```
<%= link_to "Log in", login_path %>
```
把这些链接都放到头部局部视图中,得到的视图如[代码清单 8.16](#listing-layout-login-logout-links) 所示。
##### 代码清单 8.16:修改布局中的链接
app/views/layouts/_header.html.erb
```
```
除了在布局中添加新链接之外,[代码清单 8.16](#listing-layout-login-logout-links) 还借助 Bootstrap 实现了下拉菜单。[[9](#fn-9)]注意这段代码中使用的几个 Bootstrap CSS 类:`dropdown`,`dropdown-menu` 等。为了让下拉菜单生效,我们要在 `application.js`(Asset Pipeline 的一部分)中引入 Bootstrap 提供的 JavaScript 库,如[代码清单 8.17](#listing-bootstrap-js) 所示。
##### 代码清单 8.17:在 `application.js` 中引入 Bootstrap JavaScript 库
app/assets/javascripts/application.js
```
//= require jquery
//= require jquery_ujs
//= require bootstrap //= require turbolinks
//= require_tree .
```
现在,你应该访问登录页面,然后使用有效账户登录——这样足以测试前三节编写的代码表现是否正常。[[10](#fn-10)]添加[代码清单 8.16](#listing-layout-login-logout-links) 和[代码清单 8.17](#listing-bootstrap-js) 中的代码后,应该能看到下拉菜单和只有已登录用户才能看到的链接,如[图 8.8](#fig-profile-with-logout-link) 所示。如果关闭浏览器,还能确认应用确实忘了登录状态,必须再次登录才能看到上述改动。
![profile with logout link 3rd edition](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/2016-05-11_57333052d80c3.png)图 8.8:用户登录后看到了新添加的链接和下拉菜单
## 8.2.4 测试布局中的变化
我们自己动手验证了成功登录后应用的表现正常,在继续之前,还要编写集成测试检查这些行为,以及捕获回归。我们要在[代码清单 8.7](#listing-flash-persistence-test)的基础上,再添加一些测试,检查下面的操作步骤:
1. 访问登录页面;
2. 通过 `post` 请求发送有效的登录信息;
3. 确认登录链接消失了;
4. 确认出现了退出链接;
5. 确认出现了资料页面链接。
为了检查这些变化,在测试中要登入已经注册的用户,也就是说数据库中必须有一个用户。Rails 默认使用“固件”实现这种需求。固件是一种组织数据的方式,这些数据会载入测试数据库。[6.2.5 节](chapter6.html#uniqueness-validation)删除了默认生成的固件([代码清单 6.30](chapter6.html#listing-empty-fixtures)),目的是让检查电子邮件地址的测试通过。现在,我们要在这个空文件中加入自定义的固件。
目前,我们只需要一个用户,它的名字和电子邮件地址应该是有效的。因为我们要登入这个用户,所以还要提供正确的密码,和提交给会话控制器中 `create` 动作的密码比较。参照[图 6.7](chapter6.html#fig-user-model-password-digest) 中的数据模型,可以看出,我们要在用户固件中定义 `password_digest` 属性。我们会定义 `digest` 方法计算这个属性的值。
[6.3.1 节](chapter6.html#a-hashed-password)说过,密码摘要使用 bcrypt 生成(通过 `has_secure_password` 方法),所以固件中的密码摘要也要使用这种方法生成。查看[安全密码的源码](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb)后,我们发现生成摘要的方法是:
```
BCrypt::Password.create(string, cost: cost)
```
其中,`string` 是要计算哈希值的字符串;`cost` 是“耗时因子”,决定计算哈希值时消耗的资源。耗时因子的值越大,由哈希值破解出原密码的难度越大。这个值对生产环境的安全防护很重要,但在测试中我们希望 `digest` 方法的执行速度越快越好。安全密码的源码中还有这么一行代码:
```
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
```
这行代码相当难懂,你无须完全理解,它的作用是严格实现前面的分析:在测试中耗时因子使用最小值,在生产环境则使用普通(最大)值。([8.4.5 节](chapter8.html#remember-me-checkbox)会深入介绍奇怪的 `?-:` 写法。)
`digest` 方法可以放在几个不同的地方,但 [8.4.1 节](#remember-token-and-digest)会在用户模型中使用,所以建议放在 `user.rb` 中。因为计算摘要时不用获取用户对象,所以我们要把 `digest` 方法附在 `User` 类上,也就是定义为类方法([4.4.1 节](chapter4.html#constructors)简要介绍过)。结果如[代码清单 8.18](#listing-digest-method) 所示。
##### 代码清单 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](#listing-real-user-fixture) 所示。
##### 代码清单 8.19:测试用户登录所需的固件
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.19](#listing-real-user-fixture) 中添加 `password` 属性,Rails 会提示数据库中没有这个列(确实没有)。所以,我们约定固件中所有用户的密码都一样,即 `'password'`。
创建了一个有效用户固件后,在测试中可以使用下面的方式获取这个用户:
```
user = users(:michael)
```
其中,`users` 对应固件文件 `users.yml` 的文件名,`:michael` 是[代码清单 8.19](#listing-real-user-fixture) 中定义的用户。
定义好用户固件之后,现在可以把本节开头列出的操作步骤转换成代码了,如[代码清单 8.20](#listing-user-login-test-valid-information) 所示。(注意,这段代码中的 `get` 和 `post` 两步严格来说没有关系,其实向控制器发起 `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](chapter5.html#listing-layout-links-test)中使用的是 `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](#listing-login-upon-signup) 所示。[[11](#fn-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](chapter7.html#listing-a-test-for-valid-submission) 中添加一行代码,检查用户是否已经登录。我们可以定义一个 `is_logged_in?` 辅助方法,功能和[代码清单 8.15](#listing-logged-in-p) 中的 `logged_in?` 方法一样,如果(测试环境的)会话中有用户的 ID 就返回 `true`,否则返回 `false`,如[代码清单 8.23](#listing-test-helper-sessions) 所示。(我们不能像[代码清单 8.15](#listing-logged-in-p) 那样使用 `current_user`,因为在测试中不能使用 `current_user` 方法,但是可以使用 `session` 方法。)我们定义的方法不是 `logged_in?`,而是 `is_logged_in?`——测试辅助方法和会话辅助方法名字不一样,以免混淆。[[12](#fn-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](#listing-login-after-signup-test) 中的测试检查注册后用户有没有登录。
##### 代码清单 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: "user@example.com",
password: "password",
password_confirmation: "password" }
end
assert_template 'users/show'
assert is_logged_in? end
end
```
现在,测试组件应该可以通过:
##### 代码清单 8.25:**GREEN**
```
$ bundle exec rake test
```
';
<%= link_to "sample app", root_path, id: "logo" %>