企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
# 8.1 会话 [HTTP](http://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol) 协议[没有状态](https://en.wikipedia.org/wiki/Stateless_protocol),每个请求都是独立的事务,无法使用之前请求中的信息。所以,在 HTTP 协议中无法在两个页面之间记住用户的身份。需要用户登录的应用都要使用“[会话](http://en.wikipedia.org/wiki/Session_(computer_science))”(session)。会话是两台电脑之间的半永久性连接,例如运行 Web 浏览器的客户端电脑和运行 Rails 的服务器。 在 Rails 中实现会话最常见的方式是使用 [cookie](http://en.wikipedia.org/wiki/HTTP_cookie)。cookie 是存储在用户浏览器中的少量文本。访问其他页面时,cookie 中存储的信息仍在,所以可以在 cookie 中存储一些信息,例如用户的 ID,让应用从数据库中取回已登录的用户。这一节和 [8.2 节](#logging-in)会使用 Rails 提供的 `session` 方法实现临时会话,浏览器关闭后会话自动失效。[[2](#fn-2)][8.4 节](#remember-me)会使用 Rails 提供的 `cookies` 方法让会话持续的时间久一些。 把会话看成符合 REST 架构的资源便于操作,访问登录页面时渲染一个表单用于新建会话,登录时创建一个会话,退出时再把会话销毁。不过会话和用户资源不同,用户资源(通过用户模型)使用数据库存储数据,而会话资源要使用 cookie。所以,登录功能的大部分工作是实现基于会话的认证机制。这一节和下一节要为登录功能做些准备工作,包括创建会话控制器,登录表单和相关的控制器动作。然后在 [8.2 节](#logging-in)添加所需的会话处理代码,完成登录功能。 和前面的章节一样,我们要在主题分支中工作,本章结束时再合并到主分支: ``` $ git checkout master $ git checkout -b log-in-log-out ``` ## 8.1.1 会话控制器 登录和退出功能由会话控制器中的相应动作处理,登录表单在 `new` 动作中处理(本节的内容),登录的过程是向 `create` 动作发送 `POST` 请求([8.2 节](#logging-in)),退出则是向 `destroy` 动作发送 `DELETE` 请求([8.3 节](#logging-out))。(HTTP 请求和 REST 动作之间的对应关系参见[表 7.1](chapter7.html#table-restful-users)。) 首先,我们要生成会话控制器,以及其中的 `new` 动作: ``` $ rails generate controller Sessions new ``` (参数中指定 `new`,其实还会生成视图,所以我们才没指定 `create` 和 `destroy`,因为这两个动作没有视图。)参照 [7.2 节](chapter7.html#signup-form)创建注册页面的方式,我们要创建一个登录表单,用于创建会话,构思如[图 8.1](#fig-login-mockup) 所示。 ![login mockup](https://box.kancloud.cn/2016-05-11_5733304f2e9fa.png)图 8.1:登录表单的构思图 用户资源使用特殊的 `resources` 方法自动获得符合 REST 架构的路由([代码清单 7.3](chapter7.html#listing-users-resource)),会话资源则只能使用具名路由,处理发给 /login 地址的 `GET` 和 `POST` 请求,以及发给 /logout 地址的 `DELETE` 请求,如[代码清单 8.1](#listing-sessions-resource) 所示。(删除了 `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](#listing-sessions-resource) 中的规则会把 URL 和动作对应起来,就像[表 7.1](chapter7.html#table-restful-users) 那样,如[表 8.1](#table-restful-sessions) 所示。 表 8.1:[代码清单 8.1](#listing-sessions-resource) 中会话相关的规则生成的路由 | 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](#fig-login-mockup) 和[图 7.11](chapter7.html#fig-signup-mockup) 之后发现,登录表单和注册表单的外观类似,只不过登录表单只有两个输入框(电子邮件地址和密码)。 如[图 8.2](#fig-login-failure-mockup) 所示,如果提交的登录信息无效,我们想重新渲染登录页面,并显示一个错误消息。在 [7.3.3 节](chapter7.html#signup-error-messages),我们使用错误消息局部视图显示错误消息,但是那些消息由 Active Record 自动提供,所以错误消息局部视图不能显示创建会话时的错误,因为会话不是 Active Record 对象,因此我们要使用闪现消息渲染登录时的错误消息。 ![login failure mockup](https://box.kancloud.cn/2016-05-11_5733304f3f2b2.png)图 8.2:登录失败后显示的页面构思图 [代码清单 7.13](chapter7.html#listing-signup-form) 中的注册表单使用 `form_for` 辅助方法,并且把表示用户实例的 `@user` 变量作为参数传给 `form_for`: ``` <%= form_for(@user) do |f| %> . . . <% end %> ``` 登录表单和注册表单之间主要的区别是,会话不是模型,因此不能创建类似 `@user` 的变量。所以,构建登录表单时,我们要为 `form_for` 稍微多提供一些信息。 `form_for(@user)` 的作用是让表单向 /users 发起 `POST` 请求。对会话来说,我们需要指明资源的名字以及相应的 URL:[[3](#fn-3)] ``` form_for(:session, url: login_path) ``` 知道怎么调用 `form_for` 之后,参照注册表单([代码清单 7.13](chapter7.html#listing-signup-form))编写[图 8.1](#fig-login-mockup) 中构思的登录表单就容易了,如[代码清单 8.2](#listing-login-form) 所示。 ##### 代码清单 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](#listing-login-form) 中的登录表单如[图 8.3](#fig-login-form) 所示。(导航条中的“Log in”还没填写地址,所以你要在地址栏中输入 /login。[8.2.3 节](#changing-the-layout-links)会修正这个问题。) ![login form](https://box.kancloud.cn/2016-05-11_5733304f51b1e.png)图 8.3:登录表单 生成的表单 HTML 如[代码清单 8.3](#listing-login-form-html) 所示。 ##### 代码清单 8.3:[代码清单 8.2](#listing-login-form) 中登录表单生成的 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 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](#listing-login-form-html) 和[代码清单 7.15](chapter7.html#listing-signup-form-html),你可能已经猜到了,提交登录表单后会生成一个 `params` 哈希,其中 `params[:session][:email]` 和 `params[:session][:password]` 分别对应电子邮件地址和密码字段。 ## 8.1.3 查找并认证用户 和创建用户类似,创建会话(登录)时先要处理提交无效数据的情况。我们会先分析提交表单后会发生什么,想办法在登录失败时显示有帮助的错误消息(如[图 8.2](#fig-login-failure-mockup) 中的构思)。然后,以此为基础,验证提交的电子邮件地址和密码,处理登录成功的情况([8.2 节](#logging-in))。 首先,我们要为会话控制器编写一个最简单的 `create` 动作,以及空的 `new` 动作和 `destroy` 动作,如[代码清单 8.4](#listing-initial-create-session) 所示。`create` 动作现在只渲染 `new` 视图,不过为后续工作做好了准备。提交 /login 页面中的表单后,显示的页面如[图 8.4](#fig-initial-failed-login-rails-3) 所示。 ##### 代码清单 8.4:会话控制器中 `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](https://box.kancloud.cn/2016-05-11_5733304f81d92.png)图 8.4:添加[代码清单 8.4](#listing-initial-create-session) 中的 `create` 动作后,登录失败后显示的页面 仔细看一下[图 8.4](#fig-initial-failed-login-rails-3) 中显示的调试信息,你会发现,正如 [8.1.2 节](#login-form)末尾所说的,提交表单后会生成 `params` 哈希,电子邮件地址和密码都在 `:session` 键中(下述代码省略了一些 Rails 内部使用的信息): ``` --- session: email: 'user@example.com' password: 'foobar' commit: Log in action: create controller: sessions ``` 和注册表单类似([图 7.15](chapter7.html#fig-signup-failure)),这些参数是一个嵌套哈希,在[代码清单 4.10](chapter4.html#listing-nested-hashes) 中见过。具体而言,`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 节](chapter6.html#finding-user-objects))和 `has_secure_password` 提供的 `authenticate` 方法([6.3.4 节](chapter6.html#creating-and-authenticating-a-user))。前面说过,如果认证失败,`authenticate` 方法会返回 `false`。基于以上分析,我们计划按照[代码清单 8.5](#listing-find-authenticate-user) 中的方式实现用户登录功能。 ##### 代码清单 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](#listing-find-authenticate-user) 中高亮显示的第一行使用提交的电子邮件地址从数据库中取出相应的用户。(我们在 [6.2.5 节](chapter6.html#uniqueness-validation)说过,电子邮件地址都是以小写字母形式保存的,所以这里调用了 `downcase` 方法,确保提交有效的地址后能查到相应的记录。)高亮显示的第二行看起来很怪,但在 Rails 中经常使用: ``` user && user.authenticate(params[:session][:password]) ``` 我们使用 `&&`(逻辑与)检测获取的用户是否有效。因为除了 `nil` 和 `false` 之外的所有对象都被视作 `true`,上面这个语句可能出现的结果如[表 8.2](#table-user-and-and)所示。从表中可以看出,当且仅当数据库中存在提交的电子邮件地址,而且对应的密码和提交的密码匹配时,这个语句才会返回 `true`。 表 8.2:`user && user.authenticate(…​)` 可能得到的结果 | 用户 | 密码 | a && b | | --- | --- | --- | | 不存在 | 任意值 | `(nil && [anything]) == false` | | 存在 | 错误的密码 | `(true && false) == false` | | 存在 | 正确的密码 | `(true && true) == true` | ## 8.1.4 渲染闪现消息 在 [7.3.3 节](chapter7.html#signup-error-messages),我们使用用户模型的验证错误显示注册失败时的错误消息。这些错误关联在某个 Active Record 对象上,不过现在不能使用这种方式了,因为会话不是 Active Record 模型。我们要采取的方法是,登录失败时,在闪现消息中显示消息。[代码清单 8.6](#listing-failed-login-attempt) 是我们首次尝试实现所写的代码,其中有个小小的错误。 ##### 代码清单 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](chapter7.html#listing-layout-flash)),所以无需其他修改,`flash[:danger]` 消息就会显示出来,而且因为使用了 Bootstrap 提供的 CSS,消息的样式也很美观,如[图 8.5](#fig-failed-login-flash) 所示。 不过,就像[代码清单 8.6](#listing-failed-login-attempt) 中的注释所说,代码不完全正确。显示的页面看起来很正常啊,有什么问题呢?问题在于,闪现消息在一个请求的生命周期内是持续存在的,而重新渲染页面(使用 `render` 方法)和[代码清单 7.24](chapter7.html#listing-signup-flash) 中的重定向不同,不算是一次新请求,所以你会发现这个闪现消息存在的时间比预计的要长很多。例如,提交无效的登录信息,然后访问首页,还会显示这个闪现消息,如[图 8.6](#fig-flash-persistence) 所示。[8.1.5 节](#a-flash-test)会修正这个问题。 ![failed login flash 3rd edition](https://box.kancloud.cn/2016-05-11_5733304fbd0e4.png)图 8.5:登录失败后显示的闪现消息![flash persistence 3rd edition](https://box.kancloud.cn/2016-05-11_5733304fd5836.png)图 8.6:闪现消息一直存在 ## 8.1.5 测试闪现消息 闪现消息的错误表现是应用的一个小 bug。根据[旁注 3.3](chapter3.html#aside-when-to-test) 中的测试指导方针,遇到这种情况应该编写测试,捕获错误,防止以后再发生。因此,在继续之前,我们要为登录表单的提交操作编写一个简短的集成测试。测试能标识出这个问题,也能避免回归,而且还能为后面的登录和退出功能的集成测试奠定好的基础。 首先,为应用的登录功能生成一个集成测试文件: ``` $ rails generate integration_test users_login invoke test_unit create test/integration/users_login_test.rb ``` 然后,我们要编写一个测试,模拟[图 8.5](#fig-failed-login-flash) 和[图 8.6](#fig-flash-persistence) 中的连续操作。基本的步骤如下所示: 1. 访问登录页面; 2. 确认正确渲染了登录表单; 3. 提交无效的 `params` 哈希,向登录页面发起 `post` 请求; 4. 确认重新渲染了登录表单,而且显示了一个闪现消息; 5. 访问其他页面(例如首页); 6. 确认这个页面中没显示前面那个闪现消息。 实现上述步骤的测试如[代码清单 8.7](#listing-flash-persistence-test) 所示。 ##### 代码清单 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](#listing-flash-persistence-test) 中的测试通过的方法是,把 `flash` 换成特殊的 `flash.now`。`flash.now` 专门用于在重新渲染的页面中显示闪现消息。和 `flash` 不同的是,`flash.now` 中的内容会在下次请求时消失——这正是[代码清单 8.7](#listing-flash-persistence-test) 中的测试所需的表现。替换之后,正确的应用代码如[代码清单 8.9](#listing-correct-login-failure) 所示。 ##### 代码清单 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 ```