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 y
和 x 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"
。这是因为除了 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]
综上所述,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。
图 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 类:dropdown
,dropdown-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 所示。如果关闭浏览器,还能确认应用确实忘了登录状态,必须再次登录才能看到上述改动。
图 8.8:用户登录后看到了新添加的链接和下拉菜单
8.2.4 测试布局中的变化
我们自己动手验证了成功登录后应用的表现正常,在继续之前,还要编写集成测试检查这些行为,以及捕获回归。我们要在代码清单 8.7的基础上,再添加一些测试,检查下面的操作步骤:
访问登录页面;
通过
post
请求发送有效的登录信息;确认登录链接消失了;
确认出现了退出链接;
确认出现了资料页面链接。
为了检查这些变化,在测试中要登入已经注册的用户,也就是说数据库中必须有一个用户。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 所示。(注意,这段代码中的 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中使用的是 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