8.1 会话
HTTP 协议没有状态,每个请求都是独立的事务,无法使用之前请求中的信息。所以,在 HTTP 协议中无法在两个页面之间记住用户的身份。需要用户登录的应用都要使用“会话)”(session)。会话是两台电脑之间的半永久性连接,例如运行 Web 浏览器的客户端电脑和运行 Rails 的服务器。
在 Rails 中实现会话最常见的方式是使用 cookie。cookie 是存储在用户浏览器中的少量文本。访问其他页面时,cookie 中存储的信息仍在,所以可以在 cookie 中存储一些信息,例如用户的 ID,让应用从数据库中取回已登录的用户。这一节和 8.2 节会使用 Rails 提供的 session
方法实现临时会话,浏览器关闭后会话自动失效。[2]8.4 节会使用 Rails 提供的 cookies
方法让会话持续的时间久一些。
把会话看成符合 REST 架构的资源便于操作,访问登录页面时渲染一个表单用于新建会话,登录时创建一个会话,退出时再把会话销毁。不过会话和用户资源不同,用户资源(通过用户模型)使用数据库存储数据,而会话资源要使用 cookie。所以,登录功能的大部分工作是实现基于会话的认证机制。这一节和下一节要为登录功能做些准备工作,包括创建会话控制器,登录表单和相关的控制器动作。然后在 8.2 节添加所需的会话处理代码,完成登录功能。
和前面的章节一样,我们要在主题分支中工作,本章结束时再合并到主分支:
$ git checkout master
$ git checkout -b log-in-log-out
8.1.1 会话控制器
登录和退出功能由会话控制器中的相应动作处理,登录表单在 new
动作中处理(本节的内容),登录的过程是向 create
动作发送 POST
请求(8.2 节),退出则是向 destroy
动作发送 DELETE
请求(8.3 节)。(HTTP 请求和 REST 动作之间的对应关系参见表 7.1。)
首先,我们要生成会话控制器,以及其中的 new
动作:
$ rails generate controller Sessions new
(参数中指定 new
,其实还会生成视图,所以我们才没指定 create
和 destroy
,因为这两个动作没有视图。)参照 7.2 节创建注册页面的方式,我们要创建一个登录表单,用于创建会话,构思如图 8.1 所示。
图 8.1:登录表单的构思图
用户资源使用特殊的 resources
方法自动获得符合 REST 架构的路由(代码清单 7.3),会话资源则只能使用具名路由,处理发给 /login 地址的 GET
和 POST
请求,以及发给 /logout 地址的 DELETE
请求,如代码清单 8.1 所示。(删除了 rails generate controller
生成的无用路由。)
代码清单 8.1:添加会话控制器的路由
config/routes.rb
Rails.application.routes.draw do
root 'static_pages#home'
get 'help' => 'static_pages#help'
get 'about' => 'static_pages#about'
get 'contact' => 'static_pages#contact'
get 'signup' => 'users#new'
get 'login' => 'sessions#new' post 'login' => 'sessions#create' delete 'logout' => 'sessions#destroy' resources :users
end
代码清单 8.1 中的规则会把 URL 和动作对应起来,就像表 7.1 那样,如表 8.1 所示。
表 8.1:代码清单 8.1 中会话相关的规则生成的路由
HTTP 请求 | URL | 具名路由 | 动作 | 作用 |
---|---|---|---|---|
GET |
/login | login_path |
new |
创建新会话的页面(登录) |
POST |
/login | login_path |
create |
创建新会话(登录) |
DELETE |
/logout | logout_path |
destroy |
删除会话(退出) |
至此,我们添加了好几个自定义的具名路由,最好看一下路由的完整列表。我们可以执行 rake routes
生成路由列表:
$ bundle exec rake 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
你没必要完全理解这些输出的内容。像这样查看路由能对应用支持的动作有个整体认识。
8.1.2 登录表单
定义好相关的控制器和路由之后,我们要编写新建会话的视图,也就是登录表单。比较图 8.1 和图 7.11 之后发现,登录表单和注册表单的外观类似,只不过登录表单只有两个输入框(电子邮件地址和密码)。
如图 8.2 所示,如果提交的登录信息无效,我们想重新渲染登录页面,并显示一个错误消息。在 7.3.3 节,我们使用错误消息局部视图显示错误消息,但是那些消息由 Active Record 自动提供,所以错误消息局部视图不能显示创建会话时的错误,因为会话不是 Active Record 对象,因此我们要使用闪现消息渲染登录时的错误消息。
图 8.2:登录失败后显示的页面构思图
代码清单 7.13 中的注册表单使用 form_for
辅助方法,并且把表示用户实例的 @user
变量作为参数传给 form_for
:
<%= form_for(@user) do |f| %>
.
.
.
<% end %>
登录表单和注册表单之间主要的区别是,会话不是模型,因此不能创建类似 @user
的变量。所以,构建登录表单时,我们要为 form_for
稍微多提供一些信息。
form_for(@user)
的作用是让表单向 /users 发起 POST
请求。对会话来说,我们需要指明资源的名字以及相应的 URL:[3]
form_for(:session, url: login_path)
知道怎么调用 form_for
之后,参照注册表单(代码清单 7.13)编写图 8.1 中构思的登录表单就容易了,如代码清单 8.2 所示。
代码清单 8.2:登录表单的代码
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 %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
注意,为了操作方便,我们还加入了到“注册”页面的链接。代码清单 8.2 中的登录表单如图 8.3 所示。(导航条中的“Log in”还没填写地址,所以你要在地址栏中输入 /login。8.2.3 节会修正这个问题。)
图 8.3:登录表单
生成的表单 HTML 如代码清单 8.3 所示。
代码清单 8.3:代码清单 8.2 中登录表单生成的 HTML
<form accept-charset="UTF-8" action="/login" method="post">
<input name="utf8" type="hidden" value="✓" />
<input name="authenticity_token" type="hidden"
value="NNb6+J/j46LcrgYUC60wQ2titMuJQ5lLqyAbnbAUkdo=" />
<label for="session_email">Email</label>
<input 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.3 和代码清单 7.15,你可能已经猜到了,提交登录表单后会生成一个 params
哈希,其中 params[:session][:email]
和 params[:session][:password]
分别对应电子邮件地址和密码字段。
8.1.3 查找并认证用户
和创建用户类似,创建会话(登录)时先要处理提交无效数据的情况。我们会先分析提交表单后会发生什么,想办法在登录失败时显示有帮助的错误消息(如图 8.2 中的构思)。然后,以此为基础,验证提交的电子邮件地址和密码,处理登录成功的情况(8.2 节)。
首先,我们要为会话控制器编写一个最简单的 create
动作,以及空的 new
动作和 destroy
动作,如代码清单 8.4 所示。create
动作现在只渲染 new
视图,不过为后续工作做好了准备。提交 /login 页面中的表单后,显示的页面如图 8.4 所示。
代码清单 8.4:会话控制器中 create
动作的初始版本
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def new
end
def create
render 'new' end
def destroy
end
end
图 8.4:添加代码清单 8.4 中的 create
动作后,登录失败后显示的页面
仔细看一下图 8.4 中显示的调试信息,你会发现,正如 8.1.2 节末尾所说的,提交表单后会生成 params
哈希,电子邮件地址和密码都在 :session
键中(下述代码省略了一些 Rails 内部使用的信息):
---
session:
email: '[email protected]'
password: 'foobar'
commit: Log in
action: create
controller: sessions
和注册表单类似(图 7.15),这些参数是一个嵌套哈希,在代码清单 4.10 中见过。具体而言,params
包含了如下的嵌套哈希:
{ session: { password: "foobar", email: "[email protected]" } }
也就是说
params[:session]
本身就是一个哈希:
{ password: "foobar", email: "[email protected]" }
所以,
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.5 中的方式实现用户登录功能。
代码清单 8.5:查找并认证用户
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.5 中高亮显示的第一行使用提交的电子邮件地址从数据库中取出相应的用户。(我们在 6.2.5 节说过,电子邮件地址都是以小写字母形式保存的,所以这里调用了 downcase
方法,确保提交有效的地址后能查到相应的记录。)高亮显示的第二行看起来很怪,但在 Rails 中经常使用:
user && user.authenticate(params[:session][:password])
我们使用 &&
(逻辑与)检测获取的用户是否有效。因为除了 nil
和 false
之外的所有对象都被视作 true
,上面这个语句可能出现的结果如表 8.2所示。从表中可以看出,当且仅当数据库中存在提交的电子邮件地址,而且对应的密码和提交的密码匹配时,这个语句才会返回 true
。
表 8.2:user && user.authenticate(…)
可能得到的结果
| 用户 | 密码 | a && b |
| --- | --- | --- |
| 不存在 | 任意值 | (nil && [anything]) == false
|
| 存在 | 错误的密码 | (true && false) == false
|
| 存在 | 正确的密码 | (true && true) == true
|
8.1.4 渲染闪现消息
在 7.3.3 节,我们使用用户模型的验证错误显示注册失败时的错误消息。这些错误关联在某个 Active Record 对象上,不过现在不能使用这种方式了,因为会话不是 Active Record 模型。我们要采取的方法是,登录失败时,在闪现消息中显示消息。代码清单 8.6 是我们首次尝试实现所写的代码,其中有个小小的错误。
代码清单 8.6:尝试处理登录失败(有个小小的错误)
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
布局中已经加入了显示闪现消息的局部视图(代码清单 7.25),所以无需其他修改,flash[:danger]
消息就会显示出来,而且因为使用了 Bootstrap 提供的 CSS,消息的样式也很美观,如图 8.5 所示。
不过,就像代码清单 8.6 中的注释所说,代码不完全正确。显示的页面看起来很正常啊,有什么问题呢?问题在于,闪现消息在一个请求的生命周期内是持续存在的,而重新渲染页面(使用 render
方法)和代码清单 7.24 中的重定向不同,不算是一次新请求,所以你会发现这个闪现消息存在的时间比预计的要长很多。例如,提交无效的登录信息,然后访问首页,还会显示这个闪现消息,如图 8.6 所示。8.1.5 节会修正这个问题。
图 8.5:登录失败后显示的闪现消息图 8.6:闪现消息一直存在
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 中的连续操作。基本的步骤如下所示:
访问登录页面;
确认正确渲染了登录表单;
提交无效的
params
哈希,向登录页面发起post
请求;确认重新渲染了登录表单,而且显示了一个闪现消息;
访问其他页面(例如首页);
确认这个页面中没显示前面那个闪现消息。
实现上述步骤的测试如代码清单 8.7 所示。
代码清单 8.7:捕获继续显示闪现消息的测试 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, session: { email: "", password: "" }
assert_template 'sessions/new'
assert_not flash.empty?
get root_path
assert flash.empty?
end
end
添加上述测试之后,登录测试应该失败:
代码清单 8.8:RED
$ bundle exec rake test TEST=test/integration/users_login_test.rb
上述命令指定 TEST
参数和文件的完整路径,演示如何只运行一个测试文件。
让代码清单 8.7 中的测试通过的方法是,把 flash
换成特殊的 flash.now
。flash.now
专门用于在重新渲染的页面中显示闪现消息。和 flash
不同的是,flash.now
中的内容会在下次请求时消失——这正是代码清单 8.7 中的测试所需的表现。替换之后,正确的应用代码如代码清单 8.9 所示。
代码清单 8.9:处理登录失败正确的代码 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.10:GREEN
$ bundle exec rake test TEST=test/integration/users_login_test.rb
$ bundle exec rake test