<ruby id="bdb3f"></ruby>

    <p id="bdb3f"><cite id="bdb3f"></cite></p>

      <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
        <p id="bdb3f"><cite id="bdb3f"></cite></p>

          <pre id="bdb3f"></pre>
          <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

          <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
          <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

          <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                <ruby id="bdb3f"></ruby>

                ??一站式輕松地調用各大LLM模型接口,支持GPT4、智譜、豆包、星火、月之暗面及文生圖、文生視頻 廣告
                # 8.4 記住我 [8.2 節](#logging-in)實現的登錄系統自成一體且功能完整,不過大多數網站還會提供一種功能——用戶關閉瀏覽器后仍能記住用戶的會話。本節,我們首先實現自動記住用戶會話的功能,只有用戶明確退出后會話才會失效。[8.4.5 節](#remember-me-checkbox)實現另一種常用方式:提供一個“記住我”復選框,讓用戶選擇是否記住會話。這兩種方式都很專業,[GitHub](http://github.com/) 和 [Bitbucket](http://bitbucket.org/) 等網站使用第一種,[Facebook](http://www.facebook.com/) 和 [Twitter](http://twitter.com/) 等網站使用第二種。 ## 8.4.1 記憶令牌和摘要 [8.2 節](#logging-in)使用 Rails 中的 `session` 方法存儲用戶的 ID,但是瀏覽器關閉后這個信息就不見了。本節,我們邁出實現持久會話的第一步:生成使用 `cookies` 方法創建持久 cookie 所需的“記憶令牌”,以及認證令牌所需的安全記憶摘要。 [8.2.1 節](#the-log-in-method)說過,使用 `session` 方法存儲的信息默認情況下就是安全的,但使用 `cookies` 方法存儲的信息則不然。具體而言,持久 cookie 有被[會話劫持](http://en.wikipedia.org/wiki/Session_hijacking)的風險,攻擊者可以使用盜取的記憶令牌以某個用戶的身份登錄。盜取 cookie 中的信息主要有四種方法:(1)使用[包嗅探](https://en.wikipedia.org/wiki/Packet_analyzer)工具截獲不安全網絡中傳輸的 cookie;[[14](#fn-14)](2)獲取包含記憶令牌的數據庫;(3)使用“跨站腳本”(Cross-Site Scripting,簡稱 XSS)攻擊;(4)獲取已登錄用戶的設備訪問權。我們在 [7.5 節](chapter7.html#professional-grade-deployment)啟用了全站 SSL,避免嗅探網絡中傳輸的數據,因此解決了第一個問題。為了解決第二個問題,我們不會存儲記憶令牌本身,而是存儲令牌的哈希摘要——這種方法和 [6.3 節](chapter6.html#adding-a-secure-password)一樣,不存儲原始密碼,而是存儲密碼摘要。Rails 會轉義插入視圖模板中的內容,所以自動解決了第三個問題。對于最后一個問題,雖然沒有萬無一失的方法能避免攻擊者獲取已登錄用戶電腦的訪問權,不過我們可以在每次用戶退出后修改令牌,以及簽名加密存儲在瀏覽器中的敏感信息,盡量減少第四個問題發生的幾率。 經過上述分析,我們計劃按照下面的方式實現持久會話: 1. 生成隨機字符串,當做記憶令牌; 2. 把這個令牌存入瀏覽器的 cookie 中,并把過期時間設為未來的某個日期; 3. 在數據庫中存儲令牌的摘要; 4. 在瀏覽器的 cookie 中存儲加密后的用戶 ID; 5. 如果 cookie 中有用戶的 ID,就用這個 ID 在數據庫中查找用戶,并且檢查 cookie 中的記憶令牌和數據庫中的哈希摘要是否匹配。 注意,最后一步和登入用戶很相似:使用電子郵件地址取回用戶,然后(使用 `authenticate` 方法)驗證提交的密碼和密碼摘要是否匹配([代碼清單 8.5](#listing-find-authenticate-user))。所以,我們的實現方式和 `has_secure_password` 差不多。 首先,我們把所需的 `remember_digest` 屬性加入用戶模型,如[圖 8.9](#fig-user-model-remember-digest) 所示。 ![user model remember digest](https://box.kancloud.cn/2016-05-11_573330581f375.png)圖 8.9:添加 `remember_digest` 屬性后的用戶模型 為了把[圖 8.9](#fig-user-model-remember-digest) 中的數據模型添加到應用中,我們要生成一個遷移: ``` $ rails generate migration add_remember_digest_to_users remember_digest:string ``` (可以和 [6.3.1 節](chapter6.html#a-hashed-password)添加密碼摘要的遷移比較一下。)和之前的遷移一樣,遷移的名字以 `_to_users` 結尾,這么做是為了告訴 Rails 這個遷移是用來修改 `users` 表的。因為我們還指定了屬性和類型,所以 Rails 會自動為我們生成遷移代碼,如[代碼清單 8.30](#listing-add-remember-digest-to-users-generated) 所示。 ##### 代碼清單 8.30:生成的遷移,用來添加記憶摘要 db/migrate/[timestamp]_add_remember_digest_to_users.rb ``` class AddRememberDigestToUsers < ActiveRecord::Migration def change add_column :users, :remember_digest, :string end end ``` 我們不會通過記憶摘要取回用戶,所以沒必要在 `remember_digest` 列上添加索引,因此可以直接使用上述自動生成的遷移: ``` $ bundle exec rake db:migrate ``` 現在我們要決定使用什么做記憶令牌。很多方法基本上都差不多,其實只要是一定長度的隨機字符串都行。Ruby 標準庫中 `SecureRandom` 模塊的 `urlsafe_base64` 方法剛好能滿足我們的需求。[[15](#fn-15)]這個方法返回長度為 22 的隨機字符串,包含字符 A-Z、a-z、0-9、“-”和“_”(每一位都有 64 種可能,因此方法名中有“[base64](http://en.wikipedia.org/wiki/Base64)”)。典型的 base64 字符串如下所示: ``` $ rails console >> SecureRandom.urlsafe_base64 => "q5lt38hQDc_959PVoo6b7A" ``` 就像兩個用戶可以使用相同的密碼一樣,[[16](#fn-16)]記憶令牌也沒必要是唯一的,不過如果唯一的話,安全性更高。[[17](#fn-17)]對 base64 字符串來說,22 個字符中的每一個都有 64 種取值可能,所以兩個記憶令牌“碰撞”的幾率小到可以忽略,只有 1/6422 = 2-132 ≈ 10-40。而且,使用可在 URL 中安全使用的 base64 字符串(`urlsafe_base64` 方法的名字所示),我們還能在賬戶激活和密碼重設鏈接中使用類似的令牌([第 10 章](chapter10.html#account-activation-and-password-reset))。 記住用戶的登錄狀態要創建一個記憶令牌,并且在數據庫中存儲這個令牌的摘要。我們已經定義了 `digest` 方法,并且在測試固件中用過([代碼清單 8.18](#listing-digest-method))。基于上述分析,現在我們可以定義一個 `new_token` 方法,創建一個新令牌。和 `digest` 方法一樣,新建令牌的方法也不需要用戶對象,所以也定義為類方法,[[18](#fn-18)]如[代碼清單 8.31](#listing-token-method) 所示。 ##### 代碼清單 8.31:添加生成令牌的方法 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 # 返回一個隨機令牌 def User.new_token SecureRandom.urlsafe_base64 end end ``` 我們計劃定義 `user.remember` 方法把記憶令牌和用戶關聯起來,并且把相應的記憶摘要存入數據庫。[代碼清單 8.30](#listing-add-remember-digest-to-users-generated)中的遷移已經添加了 `remember_digest` 屬性,但是還沒有 `remember_token` 屬性。我們要找到一種方法,通過 `user.remember_token` 獲取令牌(為了存入 cookie),但又不在數據庫中存儲令牌。[6.3 節](chapter6.html#adding-a-secure-password)解決過類似的問題——使用虛擬屬性 `password` 和數據庫中的 `password_digest` 屬性。其中,虛擬屬性 `password` 由 `has_secure_password` 方法自動創建。但是,我們要自己編寫代碼創建 `remember_token` 屬性,方法是使用 [4.4.5 節](chapter4.html#a-user-class)用過的 `attr_accessor`,創建一個可訪問的屬性: ``` class User < ActiveRecord::Base attr_accessor :remember_token . . . def remember self.remember_token = ... update_attribute(:remember_digest, ...) end end ``` 注意 `remember` 方法中第一行代碼的賦值操作。根據 Ruby 處理對象內賦值操作的規則,如果沒有 `self`,創建的是一個名為 `remember_token` 的本地變量——這并不是我們想要的行為。使用 `self` 的目的是確保把值賦給用戶的 `remember_token` 屬性。(現在你應該知道為什么 `before_save` 回調中要使用 `self.email`,而不是 `email` 了吧([代碼清單 6.31](chapter6.html#listing-email-downcase))。)`remember` 方法的第二行代碼使用 `update_attribute` 方法更新記憶摘要。([6.1.5 節](chapter6.html#updating-user-objects)說過,這個方法會跳過驗證。這里必須跳過驗證,因為我們無法獲取用戶的密碼和密碼確認。) 基于上述分析,創建有效令牌和摘要的方法是:首先使用 `User.new_token` 創建一個新記憶令牌,然后使用 `User.digest` 生成摘要,再更新數據庫中的記憶摘要。實現這個步驟的 `remember` 方法如[代碼清單 8.32](#listing-user-model-remember) 所示。 ##### 代碼清單 8.32:在用戶模型中添加 `remember` 方法 GREEN app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token 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 # 返回一個隨機令牌 def User.new_token SecureRandom.urlsafe_base64 end # 為了持久會話,在數據庫中記住用戶 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end end ``` ## 8.4.2 登錄時記住登錄狀態 定義好 `user.remember` 方法之后,我們可以創建持久會話了,方法是,把(加密后的)用戶 ID和記憶令牌作為持久 cookie 存入瀏覽器。為此,我們要使用 `cookies` 方法。這個方法和 `session` 一樣,可以視為一個哈希。一個 cookie 有兩部分信息,一個是 `value`(值),一個是可選的 `expires`(過期日期)。例如,我們可以創建一個值為記憶令牌,20 年后過期的 cookie,實現持久會話: ``` cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc } ``` (這里使用了一個便利的 Rails 時間輔助方法,參見[旁注 8.2](#aside-time-helpers)。 )Rails 應用中經常使用 20 年后過期的 cookie,所以 Rails 提供了一個特殊的方法 `permanent`,用于創建這種 cookie,所以上述代碼可以簡寫為: ``` cookies.permanent[:remember_token] = remember_token ``` 這樣寫,Rails 會自動把過期時間設為 `20.years.from_now`。 ##### 旁注 8.2:cookie 在 `20.years.from_now` 之后過期 你可能還記得,[4.4.2 節](chapter4.html#class-inheritance)說過,可以向任何 Ruby 類,甚至是內置的類中添加自定義的方法。那一節,我們向 `String` 類添加了 `palindrome?` 方法(而且還發現了 `"deified"` 是回文)。我們還介紹過,Rails 為 `Object` 類添加了 `blank?` 方法(所以,`"".blank?`、`" ".blank?` 和 `nil.blank?` 的返回值都是 `true`)。創建 `20.years.from_now` 之后過期的 cookie 的 `cookies.permanent` 方法又是一例。`permanent` 方法使用了 Rails 提供的一個時間輔助方法。時間輔助方法添加到 `Fixnum` 類(整數的基類)中: ``` $ rails console >> 1.year.from_now => Sun, 09 Aug 2015 16:48:17 UTC +00:00 >> 10.weeks.ago => Sat, 31 May 2014 16:48:45 UTC +00:00 ``` Rails 還在 `Fixnum` 類中添加了其他輔助方法: ``` >> 1.kilobyte => 1024 >> 5.megabytes => 5242880 ``` 這幾個輔助方法可用于驗證文件上傳,例如,限制上傳的圖片最大不超過 `5.megabytes`。 這種為內置類添加方法的特性很靈便,可以擴展 Ruby 的功能,不過使用時要小心一些。其實 Rails 的很多優雅之處正是基于 Ruby 語言的這一特性實現的。 我們可以參照 `session` 方法,使用下面的方式把用戶的 ID 存入 cookie: ``` cookies[:user_id] = user.id ``` 但是這種方式存儲的是純文本,因此攻擊者很容易竊取用戶的賬戶。為了避免這種問題,我們要對 cookie 簽名,存入瀏覽器之前安全加密 cookie: ``` cookies.signed[:user_id] = user.id ``` 因為我們想讓用戶 ID 和永久的記憶令牌配對,所以也要永久存儲用戶 ID。為此,我們可以串聯調用 `signed` 和 `permanent` 方法: ``` cookies.permanent.signed[:user_id] = user.id ``` 存儲 cookie 后,再訪問頁面時可以使用下面的代碼取回用戶: ``` User.find_by(id: cookies.signed[:user_id]) ``` 其中,`cookies.signed[:user_id]` 會自動解密 cookie 中的用戶 ID。然后,再使用 bcrypt 確認 `cookies[:remember_token]` 和 [代碼清單 8.32](#listing-user-model-remember) 生成的 `remember_digest` 是否匹配。(你可能想知道為什么不能只使用簽名的用戶 ID。如果沒有記憶令牌,攻擊者一旦知道加密的 ID,就能以這個用戶的身份登錄。但是按照我們目前的設計方式,就算攻擊者同時獲得了用戶 ID 和記憶令牌,也要等到用戶退出后才能登錄。) 最后一步是,確認記憶令牌匹配用戶的記憶摘要。對現在這種情況來說,使用 bcrypt 確認是否匹配有很多等效的方法。如果查看[安全密碼的源碼](https://github.com/rails/rails/blob/master/activemodel/lib/active_model/secure_password.rb),會發現下面這個比較語句:[[19](#fn-19)] ``` BCrypt::Password.new(password_digest) == unencrypted_password ``` 這里,我們需要的代碼如下: ``` BCrypt::Password.new(remember_digest) == remember_token ``` 仔細想一想,這行代碼有點兒奇怪:看起來是直接比較 bcrypt 計算得到的密碼哈希和令牌,那么,要使用 `==` 就得解密摘要。可是,使用 bcrypt 的目的是為了得到不可逆的哈希值,所以這么想是不對的。研究 [bcrypt gem 的源碼](https://github.com/codahale/bcrypt-ruby/blob/master/lib/bcrypt/password.rb)后,你會發現 bcrypt 重定義了 `==`,上述代碼其實等效于: ``` BCrypt::Password.new(remember_digest).is_password?(remember_token) ``` 這種寫法沒使用 `==`,而是使用返回布爾值的 `is_password?` 方法進行比較。因為這么寫意思更明確,所以,在應用代碼中我們就這么寫。 基于上述分析,我們可以在用戶模型中定義 `authenticated?` 方法,比較摘要和令牌。這個方法的作用類似于 `has_secure_password` 提供用來認證用戶的 `authenticate` 方法([代碼清單 8.13](#listing-log-in-success))。`authenticated?` 方法的定義如[代碼清單 8.33](#listing-authenticated-p) 所示。 ##### 代碼清單 8.33:在用戶模型中添加 `authenticated?` 方法 app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token 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 # 返回一個隨機令牌 def User.new_token SecureRandom.urlsafe_base64 end # 為了持久會話,在數據庫中記住用戶 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 如果指定的令牌和摘要匹配,返回 true def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end end ``` 雖然[代碼清單 8.33](#listing-authenticated-p) 中的 `authenticated?` 方法和記憶令牌聯系緊密,不過在其他情況下也很有用,[第 10 章](chapter10.html#account-activation-and-password-reset)會改寫這個方法,讓它的使用范圍更廣。 現在可以記住用戶的登錄狀態了。我們要在 `log_in` 后面調用 `remember` 輔助方法,如[代碼清單 8.34](#listing-log-in-with-remember) 所示。 ##### 代碼清單 8.34:登錄并記住登錄狀態 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 remember user redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out redirect_to root_url end end ``` 和登錄功能一樣,[代碼清單 8.34](#listing-log-in-with-remember) 把真正的工作交給會話輔助方法完成。在會話輔助方法模塊中,我們要定義一個名為 `remember` 的方法,調用 `user.remember`,從而生成一個記憶令牌,并把對應的摘要存入數據庫;然后使用 `cookies` 創建永久 cookie,保存用戶 ID 和記憶令牌。結果如[代碼清單 8.35](#listing-remember-method) 所示。 ##### 代碼清單 8.35:記住用戶 app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用戶 def log_in(user) session[:user_id] = user.id end # 在持久會話中記住用戶 def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # 返回當前登錄的用戶(如果有的話) def current_user @current_user ||= User.find_by(id: session[:user_id]) end # 如果用戶已登錄,返回 true,否則返回 false def logged_in? !current_user.nil? end # 退出當前用戶 def log_out session.delete(:user_id) @current_user = nil end end ``` 現在,用戶登錄后會被記住,因為在瀏覽器中存儲了有效的記憶令牌。但是還沒有什么實際作用,因為[代碼清單 8.14](#listing-current-user)中定義的 `current_user` 方法只能處理臨時會話: ``` @current_user ||= User.find_by(id: session[:user_id]) ``` 對持久會話來說,如果臨時會話中有 `session[:user_id]`,那么就從中取回用戶,否則,應該檢查 `cookies[:user_id]`,取回(并且登入)持久會話中存儲的用戶。實現方式如下: ``` if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) elsif cookies.signed[:user_id] user = User.find_by(id: cookies.signed[:user_id]) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end ``` (這里沿用了 [代碼清單 8.5](#listing-find-authenticate-user) 中使用的 `user && user.authenticated` 模式。)上述代碼可以使用,但注意,其中重復使用了 `session` 和 `cookies`。我們可以去除重復,寫成這樣: ``` if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end ``` 改寫后使用了常見但有點兒讓人困惑的結構: ``` if (user_id = session[:user_id]) ``` 別被外觀迷惑了,這不是比較語句(比較時應該使用雙等號 `==`),而是賦值語句。如果讀出來,不能念成“如果用戶 ID 等于會話中的用戶 ID”,應該是“如果會話中有用戶的 ID,把會話中的 ID 賦值給 `user_id`”。[[20](#fn-20)] 按照上述分析定義 `current_user` 輔助方法,如[代碼清單 8.36](#listing-persistent-current-user) 所示。 ##### 代碼清單 8.36:更新 `current_user` 方法,支持持久會話 RED app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用戶 def log_in(user) session[:user_id] = user.id end # 在持久會話中記住用戶 def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # 返回 cookie 中記憶令牌對應的用戶 def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end # 如果用戶已登錄,返回 true,否則返回 false def logged_in? !current_user.nil? end # 退出當前用戶 def log_out session.delete(:user_id) @current_user = nil end end ``` 現在,新登錄的用戶能正確記住登錄狀態了。你可以確認一下:登錄后關閉瀏覽器,再打開瀏覽器,重新訪問演示應用,檢查是否還是已登錄狀態。如果愿意,甚至還可以直接查看瀏覽器中的 cookie,如[圖 8.10](#fig-cookie-in-browser) 所示。[[21](#fn-21)] ![cookie in browser chrome](https://box.kancloud.cn/2016-05-11_57333058367c6.png)圖 8.10:本地瀏覽器 cookie 中存儲的記憶令牌 現在我們的應用還有一個問題:無法清除瀏覽器中的 cookie(除非等到 20 年后),因此用戶無法退出。這正是測試應該捕獲的問題,而且目前測試的確無法通過: ##### 代碼清單 8.37:**RED** ``` $ bundle exec rake test ``` ## 8.4.3 忘記用戶 為了讓用戶退出,我們要定義一些和記住用戶相對的方法,忘記用戶。最終實現的 `user.forget` 方法,把記憶摘要的值設為 `nil`,即撤銷 `user.remember` 的操作,如[代碼清單 8.38](#listing-user-model-forget) 所示。 ##### 代碼清單 8.38:在用戶模型中添加 `forget` 方法 app/models/user.rb ``` class User < ActiveRecord::Base attr_accessor :remember_token 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 # 返回一個隨機令牌 def User.new_token SecureRandom.urlsafe_base64 end # 為了持久會話,在數據庫中記住用戶 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 如果指定的令牌和摘要匹配,返回 true def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end # 忘記用戶 def forget update_attribute(:remember_digest, nil) end end ``` 然后我們可以定義 `forget` 輔助方法,忘記持久會話,然后在 `log_out` 輔助方法中調用 `forget`,如[代碼清單 8.39](#listing-log-out-with-forget) 所示。`forget` 方法先調用 `user.forget`,然后再從 cookie 中刪除 `user_id` 和 `remember_token`。 ##### 代碼清單 8.39:退出持久會話 app/helpers/sessions_helper.rb ``` module SessionsHelper # 登入指定的用戶 def log_in(user) session[:user_id] = user.id end . . . # 忘記持久會話 def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end # 退出當前用戶 def log_out forget(current_user) session.delete(:user_id) @current_user = nil end end ``` ## 8.4.4 兩個小問題 現在還有兩個相互之間有關系的小問題要解決。第一個,雖然只有登錄后才能看到退出鏈接,但一個用戶可能會同時打開多個瀏覽器窗口訪問網站,如果用戶在一個窗口中退出了,再在另一個窗口中點擊退出鏈接的話會導致錯誤,因為[代碼清單 8.39](#listing-log-out-with-forget) 中使用了 `current_user`。[[22](#fn-22)]我們可以限制只有已登錄的用戶才能退出,解決這個問題。 第二個問題,用戶可能會在不同的瀏覽器中登錄(登錄狀態也被記住),例如 Chrome 和 Firefox,如果用戶在一個瀏覽器中退出,而另一個瀏覽器中沒有退出,就會導致問題。[[23](#fn-23)]假如用戶在 Firefox 中退出了,那么記憶摘要的值變成了 `nil`(通過[代碼清單 8.38](#listing-user-model-forget) 中的 `user.forget`)。在 Firefox 中沒什么問題,因為[代碼清單 8.39](#listing-log-out-with-forget) 中的 `log_out` 方法刪除了用戶的 ID,所以在 `current_user` 方法中,`user` 變量的值是 `nil`: ``` # 返回 cookie 中記憶令牌對應的用戶 def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end ``` 那么,基于短路計算原則,表達式 ``` user && user.authenticated?(cookies[:remember_token]) ``` 的值是 `false`。(因為 `user` 是 `nil`,是假值,所以不會再執行第二個表達式。)而在 Chrome 中,用戶 ID 沒被刪除,所以 `user` 的值不是 `nil`,所以會執行第二個表達式。這意味著,在 `authenticated?` 方法([代碼清單 8.33](#listing-authenticated-p))中 ``` def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end ``` `remember_digest` 的值是 `nil`,所以調用 `BCrypt::Password.new(remember_digest)` 時會拋出異常。而遇到這種情況時,我們希望 `authenticated?` 方法返回 `false`。 這正是測試驅動開發的優勢所在,所以在解決之前,我們先編寫測試捕獲這兩個小問題。我們先讓[代碼清單 8.28](#listing-user-logout-test) 中的集成測試失敗,如[代碼清單 8.40](#listing-test-double-logout) 所示。 ##### 代碼清單 8.40:測試用戶退出 RED test/integration/users_login_test.rb ``` require 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest . . . test "login with valid information followed by logout" do get login_path post login_path, session: { email: @user.email, password: 'password' } assert is_logged_in? 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) delete logout_path assert_not is_logged_in? assert_redirected_to root_url # 模擬用戶在另一個窗口中點擊退出鏈接 delete logout_path follow_redirect! assert_select "a[href=?]", login_path assert_select "a[href=?]", logout_path, count: 0 assert_select "a[href=?]", user_path(@user), count: 0 end end ``` 第二個 `delete logout_path` 會拋出異常,因為沒有當前用戶,由此導致測試組件無法通過: ##### 代碼清單 8.41:**RED** ``` $ bundle exec rake test ``` 在應用代碼中,我們只需在 `logged_in?` 返回 `true` 時調用 `log_out` 即可,如[代碼清單 8.42](#listing-destroy-forget) 所示。 ##### 代碼清單 8.42:只有登錄后才能退出 GREEN app/controllers/sessions_controller.rb ``` class SessionsController < ApplicationController . . . def destroy log_out if logged_in? redirect_to root_url end end ``` 第二個問題涉及到兩種不同的瀏覽器,在集成測試中很難模擬,不過直接在用戶模型層測試很簡單。我們只需創建一個沒有記憶摘要的用戶(`setup` 方法中定義的 `@user` 就沒有),再調用 `authenticated?` 方法即可,如[代碼清單 8.43](#listing-test-authenticated-invalid-token) 所示。(注意,我們直接使用空記憶令牌,因為還沒用到這個值之前就會發生錯誤。) ##### 代碼清單 8.43:測試沒有摘要時 `authenticated?` 方法的表現 RED test/models/user_test.rb ``` require 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end end ``` `BCrypt::Password.new(nil)` 會拋出異常,所以測試組件不能通過: ##### 代碼清單 8.44:**RED** ``` $ bundle exec rake test ``` 為了修正這個問題,讓測試通過,記憶摘要的值為 `nil` 時,`authenticated?` 要返回 `false`,如[代碼清單 8.45](#listing-authenticated-p-fixed) 所示。 ##### 代碼清單 8.45:更新 `authenticated?`,處理沒有記憶摘要的情況 GREEN app/models/user.rb ``` class User < ActiveRecord::Base . . . # 如果指定的令牌和摘要匹配,返回 true def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end end ``` 如果記憶摘要的值為 `nil`,會直接返回 `return` 語句。這種方式經常用到,目的是強調其后的代碼會被忽略。等價的代碼如下: ``` if remember_digest.nil? false else BCrypt::Password.new(remember_digest).is_password?(remember_token) end ``` 這樣寫也行,但我喜歡明確返回的版本,而且也稍微簡短一些。 按照[代碼清單 8.45](#listing-authenticated-p-fixed) 修改之后,測試組件應該可以通過了,說明這兩個小問題都解決了: ##### 代碼清單 8.46:**GREEN** ``` $ bundle exec rake test ``` ## 8.4.5 “記住我”復選框 至此,我們的應用已經實現了完整且專業的認證系統。最后一步,我們來看一下如何使用“記住我”復選框讓用戶選擇是否記住登錄狀態。包含這個復選框的登錄表單構思圖如[圖 8.11](#fig-login-remember-me-mockup) 所示。 ![login remember me mockup](https://box.kancloud.cn/2016-05-11_5733305849e23.png)圖 8.11:構思“記住我”復選框 為了實現這個構思,我們首先要在登錄表單([代碼清單 8.2](#listing-login-form))中添加一個復選框。和標注(label)、文本字段、密碼字段和提交按鈕一樣,復選框也可以使用 Rails 輔助方法創建。不過,為了得到正確的樣式,我們要把復選框嵌套在標注中,如下所示: ``` <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> ``` 把這段代碼添加到登錄表單后,得到的視圖如[代碼清單 8.47](#listing-remember-me-checkbox) 所示。 ##### 代碼清單 8.47:在登錄表單中添加“記住我”復選框 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.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div> ``` [代碼清單 8.47](#listing-remember-me-checkbox) 中使用了 CSS 類 `checkbox` 和 `inline`,Bootstrap 使用這兩個類把復選框和文本(“Remember me on this computer”)放在同一行。為了完善樣式,我們還要再定義一些 CSS 規則,如[代碼清單 8.48](#listing-remember-me-css) 所示。得到的登錄表單如[圖 8.12](#fig-login-form-remember-me) 所示。 ##### 代碼清單 8.48:“記住我”復選框的 CSS 規則 app/assets/stylesheets/custom.css.scss ``` . . . /* forms */ . . . .checkbox { margin-top: -10px; margin-bottom: 10px; span { margin-left: 20px; font-weight: normal; } } #session_remember_me { width: auto; margin-left: 0; } ``` ![login form remember me](https://box.kancloud.cn/2016-05-11_5733305861d1f.png)圖 8.12:添加“記住我”復選框后的登錄表單 修改登錄表單后,當用戶勾選這個復選框后,要記住用戶的登錄狀態,否則不記住。因為前一節的工作做得很好,現在實現起來只需一行代碼就行。提交登錄表單后,`params` 哈希中包含一個基于復選框狀態的值(你可以使用有效信息填寫登錄表單,然后提交,看一下頁面底部的調試信息)。如果勾選了復選框,`params[:session][:remember_me]` 的值是 `'1'`,否則是 `'0'`。 我們可以檢查 `params` 哈希中的相關值,根據提交的值決定是否記住用戶: ``` if params[:session][:remember_me] == '1' remember(user) else forget(user) end ``` 根據[旁注 8.3](#aside-ternary-operator) 中的說明,這種 `if-then` 分支語句可以使用“三元操作符”變成一行:[[24](#fn-24)] ``` params[:session][:remember_me] == '1' ? remember(user) : forget(user) ``` 在會話控制器的 `create` 動作中加入這行代碼后,得到的是非常簡潔的代碼,如[代碼清單 8.49](#listing-remember-me-ternary) 所示。(現在你應該可以理解[代碼清單 8.18](#listing-digest-method)中使用三元操作符定義 `cost` 變量的代碼了。) ##### 代碼清單 8.49:處理提交的“記住我”復選框 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 params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out if logged_in? redirect_to root_url end end ``` 至此,我們的登錄系統完成了。你可以在瀏覽器中勾選或不勾選“記住我”確認一下。 ##### 旁注 8.3:世界上有 10 種人 有一個老笑話,說世界上有 10 種人,懂二進制的人和不懂二進制的人。(這里的 10,在二進制中是 2)同理,我們可以說,世界上有 11 種人,一種人喜歡三元操作符,一種人不喜歡,還有一種人不知道三元操作符是什么。(如果你碰巧是第三種人,稍后就不是了。) 編程一段時間之后,你會發現,最常使用的流程控制之一是下面這種: ``` if boolean? do_one_thing else do_something_else end ``` Ruby 和其他很多語言一樣(包括 C/C++,Perl,PHP 和 Java),提供了一種更為簡單的表達式來替代這種流程控制結構——三元操作符(之所以這么叫,是因為三元操作符包括三部分): ``` boolean? ? do_one_thing : do_something_else ``` 三元操作符甚至還可以用來替代賦值操作,所以 ``` if boolean? var = foo else var = bar end ``` 可以寫成: ``` var = boolean? ? foo : bar ``` 而且,為了方便,函數的返回值也經常使用三元操作符: ``` def foo do_stuff boolean? ? "bar" : "baz" end ``` 因為 Ruby 函數的默認返回值是定義體中的最后一個表達式,所以 `foo` 方法的返回值會根據 `boolean?` 的結果而不同,不是 `"bar"` 就是 `"baz"`。 ## 8.4.6 記住登錄狀態功能的測試 “記住我”功能雖然可以使用了,但是我們還得編寫一些測試,確認表現正常。測試的目的是要捕獲實現方式中可能出現的錯誤,這一點稍后討論。更重要的原因是,實現持久會話的代碼現在完全沒有測試。編寫測試時要使用一些小技巧,但能得到更強大的測試組件。 ### 測試“記住我”復選框 處理“記住我”復選框時([代碼清單 8.49](#listing-remember-me-ternary)),我最初編寫的代碼是: ``` params[:session][:remember_me] ? remember(user) : forget(user) ``` 而正確的代碼應該寫成: ``` params[:session][:remember_me] == '1' ? remember(user) : forget(user) ``` `params[:session][:remember_me]` 的值不是 `'0'` 就是 `'1'`,都是真值,所以總是返回 `true`,應用會一直以為勾選了“記住我”。這正式測試能捕獲的問題。 因為記住登錄狀態之前用戶要先登錄,所以我們首先要定義一個輔助方法,在測試中登入用戶。在[代碼清單 8.20](#listing-user-login-test-valid-information) 中,我們使用 `post` 方法發送有效的 `session` 哈希,登入用戶,但是每次都這么做有點麻煩。為了避免不必要的重復,我們要編寫一個輔助方法,名為 `log_in_as`,登入用戶。 登入用戶的方法在不同類型的測試中有所不同,在集成測試中我們可以按照[代碼清單 8.20](#listing-user-login-test-valid-information) 中的方式向登錄地址發送數據,但是在其他測試中,例如控制器和模型測試,這么做不行,我們要直接使用 `session` 方法。因此,`log_in_as` 要檢測測試的類型,然后使用相應的處理方式。我們可以使用 Ruby 中的 `defined?` 方法區分集成測試和其他測試。如果定義了指定的參數,`defined?` 方法返回 `true`,否則返回 `false`。對現在的需求來說,`post_via_redirect` 方法只能在集成測試中使用,所以 ``` defined?(post_via_redirect) ... ``` 在集成測試中返回 `true`,在其他類型的測試中返回 `false`。由此,我們可以定義一個名為 `integration_test?` 的方法,返回布爾值,然后使用 `if-else` 語句按照下面的方式編寫代碼: ``` if integration_test? # 向登錄地址發送數據登入用戶 else # 使用 session 方法登入用戶 end ``` 把上面的注釋換成代碼后得到的 `log_in_as` 輔助方法如[代碼清單 8.50](#listing-test-helper-log-in) 所示。(這個方法相當高級,如果不能完全理解也沒事。) ##### 代碼清單 8.50:添加 `log_in_as` 輔助方法 test/test_helper.rb ``` ENV['RAILS_ENV'] ||= 'test' . . . class ActiveSupport::TestCase fixtures :all # 如果用戶已登錄,返回 true def is_logged_in? !session[:user_id].nil? end # 登入測試用戶 def log_in_as(user, options = {}) password = options[:password] || 'password' remember_me = options[:remember_me] || '1' if integration_test? post login_path, session: { email: user.email, password: password, remember_me: remember_me } else session[:user_id] = user.id end end private # 在集成測試中返回 true def integration_test? defined?(post_via_redirect) end end ``` 注意,為了實現最大的靈活性,[代碼清單 8.50](#listing-test-helper-log-in) 中的 `log_in_as` 方法有一個 `options` 哈希參數,而且為密碼和“記住我”復選框設置了默認值,分別為 `'passowrd'` 和 `'1'`。因為哈希中未出現的鍵對應的值是 `nil`,所以: ``` remember_me = options[:remember_me] || '1' ``` 如果傳入了參數就使用指定的值,否則使用默認值(遵照[旁注 8.1](#aside-or-equals) 中說明的短路計算法則)。 為了檢查“記住我”復選框的行為,我們要編寫兩個測試,對應勾選和沒勾選復選框兩種情況。使用[代碼清單 8.50](#listing-test-helper-log-in) 中定義的登錄輔助方法很容易實現,分別為: ``` log_in_as(@user, remember_me: '1') ``` 和 ``` log_in_as(@user, remember_me: '0') ``` (因為 `remember_me` 的默認值是 `'1'`,所以第一種情況可以省略這個選項。不過我加上了,讓兩種情況的代碼結構一致。) 登錄后,我們可以檢查 `cookies` 的 `remember_token` 鍵,確認有沒有記住登錄狀態。理想情況下,我們可以檢查 cookie 中的值是否等于用戶的記憶令牌,但對目前的設計方式而言,在測試中行不通:控制器中的 `user` 變量有記憶令牌屬性,但測試中的 `@user` 變量沒有(因為 `remember_token` 是虛擬屬性)。這個問題的修正方法留作[練習](#log-in-log-out-exercises)。現在我們只測試 cookie 中相關的值是不是 `nil`。 不過,還有一個小問題,不知是什么原因,在測試中 `cookies` 方法不能使用符號鍵,所以: ``` cookies[:remember_token] ``` 的值始終是 `nil`。幸好,`cookies` 可以使用字符串鍵,因此: ``` cookies['remember_token'] ``` 可以獲得我們所需的值。寫出的測試如[代碼清單 8.51](#listing-remember-me-test) 所示。([代碼清單 8.20](#listing-user-login-test-valid-information) 中用過 `users(:michael)`,它的作用是獲取[代碼清單 8.19](#listing-real-user-fixture) 中的用戶固件。) ##### 代碼清單 8.51:測試“記住我”復選框 GREEN test/integration/users_login_test.rb ``` require 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "login with remembering" do log_in_as(@user, remember_me: '1') assert_not_nil cookies['remember_token'] end test "login without remembering" do log_in_as(@user, remember_me: '0') assert_nil cookies['remember_token'] end end ``` 如果你沒犯我曾經犯過的錯誤,測試應該可以通過: ##### 代碼清單 8.52:**GREEN** ``` $ bundle exec rake test ``` ### 測試“記住”分支 在[8.4.2 節](#login-with-remembering),我們自己動手確認了前面實現的持久會話可以正常使用,但是 `current_user` 方法的相關分支完全沒有測試。針對這種情況,我最喜歡在未測試的代碼塊中拋出異常:如果沒覆蓋這部分代碼,測試能通過;如果覆蓋了,失敗消息中會標識出相應的測試。如[代碼清單 8.53](#listing-branch-raise) 所示。 ##### 代碼清單 8.53:在未測試的分支中拋出異常 GREEN app/helpers/sessions_helper.rb ``` module SessionsHelper . . . # 返回 cookie 中記憶令牌對應的用戶 def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) raise # 測試仍能通過,所以沒有覆蓋這個分支 user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end . . . end ``` 現在,測試應該可以通過: ##### 代碼清單 8.54:**GREEN** ``` $ bundle exec rake test ``` 顯然這是個問題,因為[代碼清單 8.53](#listing-branch-raise) 會導致應用無法正常使用。而且,手動測試持久會話很麻煩,所以,如果以后想重構 `current_user` 方法的話([第 10 章](chapter10.html#account-activation-and-password-reset)),現在就要測試。 因為[代碼清單 8.50](#listing-test-helper-log-in) 中的 `log_in_as` 輔助方法自動設定了 `session[:user_id]`,所以在集成測試中測試 `current_user` 方法的“記住”分支很難。不過,幸好我們可以跳過這個限制,在會話輔助方法的測試中直接測試 `current_user` 方法。我們要手動創建這個測試文件: ``` $ touch test/helpers/sessions_helper_test.rb ``` 測試的步驟很簡單: 1. 使用固件定義一個 `user` 變量; 2. 調用 `remember` 方法記住這個用戶; 3. 確認 `current_user` 就是這個用戶。 因為 `remember` 方法沒有設定 `session[:user_id]`,所以上述步驟能測試“記住”分支。測試如[代碼清單 8.55](#listing-persistent-sessions-test) 所示。 ##### 代碼清單 8.55:測試持久會話 test/helpers/sessions_helper_test.rb ``` require 'test_helper' class SessionsHelperTest < ActionView::TestCase def setup @user = users(:michael) remember(@user) end test "current_user returns right user when session is nil" do assert_equal @user, current_user assert is_logged_in? end test "current_user returns nil when remember digest is wrong" do @user.update_attribute(:remember_digest, User.digest(User.new_token)) assert_nil current_user end end ``` 注意,我們還寫了一個測試,確認如果記憶摘要和記憶令牌不匹配時當前用戶是 `nil`,由此測試嵌套的 `if` 語句中 `authenticated?` 的表現: ``` if user && user.authenticated?(cookies[:remember_token]) ``` [代碼清單 8.55](#listing-persistent-sessions-test) 中的測試應該失敗: ##### 代碼清單 8.56:**RED** ``` $ bundle exec rake test TEST=test/helpers/sessions_helper_test.rb ``` 我們要刪除 `raise`,把 `current_user` 方法恢復原樣,如[代碼清單 8.57](#listing-branch-no-raise) 所示,這樣測試就能通過了。(你還可以把[代碼清單 8.57](#listing-branch-no-raise) 中的 `authenticated?` 刪除,看看[代碼清單 8.55](#listing-persistent-sessions-test) 中的測試是否失敗,從而確認第二個測試編寫的是否正確。) ##### 代碼清單 8.57:刪除拋出異常的代碼 GREEN app/helpers/sessions_helper.rb ``` module SessionsHelper . . . # 返回 cookie 中記憶令牌對應的用戶 def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end . . . end ``` 現在,測試組件應該可以通過: ##### 代碼清單 8.58:**GREEN** ``` $ bundle exec rake test ``` 現在,`current_user` 方法中的“記住”分支有了測試,我們不用手動檢查了,還且測試還能捕獲回歸。
                  <ruby id="bdb3f"></ruby>

                  <p id="bdb3f"><cite id="bdb3f"></cite></p>

                    <p id="bdb3f"><cite id="bdb3f"><th id="bdb3f"></th></cite></p><p id="bdb3f"></p>
                      <p id="bdb3f"><cite id="bdb3f"></cite></p>

                        <pre id="bdb3f"></pre>
                        <pre id="bdb3f"><del id="bdb3f"><thead id="bdb3f"></thead></del></pre>

                        <ruby id="bdb3f"><mark id="bdb3f"></mark></ruby><ruby id="bdb3f"></ruby>
                        <pre id="bdb3f"><pre id="bdb3f"><mark id="bdb3f"></mark></pre></pre><output id="bdb3f"></output><p id="bdb3f"></p><p id="bdb3f"></p>

                        <pre id="bdb3f"><del id="bdb3f"><progress id="bdb3f"></progress></del></pre>

                              <ruby id="bdb3f"></ruby>

                              哎呀哎呀视频在线观看