# 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) 所示。
圖 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 對象,因此我們要使用閃現消息渲染登錄時的錯誤消息。
圖 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)會修正這個問題。)
圖 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="✓" />
<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
```
圖 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)會修正這個問題。
圖 8.5:登錄失敗后顯示的閃現消息圖 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
```
- Ruby on Rails 教程
- 致中國讀者
- 序
- 致謝
- 作者譯者簡介
- 版權和代碼授權協議
- 第 1 章 從零開始,完成一次部署
- 1.1 簡介
- 1.2 搭建環境
- 1.3 第一個應用
- 1.4 使用 Git 做版本控制
- 1.5 部署
- 1.6 小結
- 1.7 練習
- 第 2 章 玩具應用
- 2.1 規劃應用
- 2.2 用戶資源
- 2.3 微博資源
- 2.4 小結
- 2.5 練習
- 第 3 章 基本靜態的頁面
- 3.1 創建演示應用
- 3.2 靜態頁面
- 3.3 開始測試
- 3.4 有點動態內容的頁面
- 3.5 小結
- 3.6 練習
- 3.7 高級測試技術
- 第 4 章 Rails 背后的 Ruby
- 4.1 導言
- 4.2 字符串和方法
- 4.3 其他數據類型
- 4.4 Ruby 類
- 4.5 小結
- 4.6 練習
- 第 5 章 完善布局
- 5.1 添加一些結構
- 5.2 Sass 和 Asset Pipeline
- 5.3 布局中的鏈接
- 5.4 用戶注冊:第一步
- 5.5 小結
- 5.6 練習
- 第 6 章 用戶模型
- 6.1 用戶模型
- 6.2 用戶數據驗證
- 6.3 添加安全密碼
- 6.4 小結
- 6.5 練習
- 第 7 章 注冊
- 7.1 顯示用戶的信息
- 7.2 注冊表單
- 7.3 注冊失敗
- 7.4 注冊成功
- 7.5 專業部署方案
- 7.6 小結
- 7.7 練習
- 第 8 章 登錄和退出
- 8.1 會話
- 8.2 登錄
- 8.3 退出
- 8.4 記住我
- 8.5 小結
- 8.6 練習
- 第 9 章 更新,顯示和刪除用戶
- 9.1 更新用戶
- 9.2 權限系統
- 9.3 列出所有用戶
- 9.4 刪除用戶
- 9.5 小結
- 9.6 練習
- 第 10 章 賬戶激活和密碼重設
- 10.1 賬戶激活
- 10.2 密碼重設
- 10.3 在生產環境中發送郵件
- 10.4 小結
- 10.5 練習
- 10.6 證明超時失效的比較算式
- 第 11 章 用戶的微博
- 11.1 微博模型
- 11.2 顯示微博
- 11.3 微博相關的操作
- 11.4 微博中的圖片
- 11.5 小結
- 11.6 練習
- 第 12 章 關注用戶
- 12.1 “關系”模型
- 12.2 關注用戶的網頁界面
- 12.3 動態流
- 12.4 小結
- 12.5 練習