Remember token and digest

之前我們使用 Rails 的 session 方法來儲存使用者的 ID,但如果使用者關閉瀏覽器,被儲存的資訊就會消失。

為了讓 cookies 方法建立持續性的 cookies,我們要產生一個 remember token,以及一個安全的 remember digest 來驗證這些 tokens。

之前提過,使用 session 方法儲存的資料是安全的,但使用 cookies 方法儲存資料就不盡然了。也就是說,持久性的 cookies 容易受到 session hijacking 攻擊,駭客會使用偷來的 remember token 以某個使用者的身份登入網站。

偷取 cookies 有四種方法:

  1. 使用 packet sniffer 在不安全的網路傳輸中攔截傳輸的 cookies
  2. 獲取包含 remember token 的資料庫
  3. 使用 cross-site scripting (XSS) 攻擊
  4. 獲取已登入使用者的機器訪問權

我們之前就使用了 SSL 來防範第一種攻擊。然後會使用儲存 remember token 的 hash digest 取代 remember token 本身來防止第二種問題,就像儲存 password digest 而不是原始密碼一樣。Rails 會透過逸出(escaping )插入 view template 的內容來防止第三種問題。最後雖然沒有萬無一失的方法能避免駭客獲取已登入使用者的電腦訪問權,我們可以計劃在每一次使用者登出的時候,修改 remember token,以及簽名加密(cryptographically sign)儲存在瀏覽器中的敏感訊息,盡量減少第四種問題發生的機率。

基於上述分析,我們建立持久性 session 的計畫是:

  1. 建立一個隨機的字串,當作 remember token
  2. 把這個 token 存入瀏覽器的 cookies 中,把過期時間設為未來某個日期
  3. 把 token 的 hash digest 存進資料庫
  4. 把加密過的使用者 ID 存進瀏覽器的 cookies 裡面
  5. 如果 cookies 中有使用者的 ID,就用這個 ID 在資料庫裡尋找使用者,檢查 cookie 中的 remember token 和資料庫中的 hash digest 是否匹配

最後一個步驟跟使用者登入很像,使用 email 取回使用者,然後驗證(使用 authenticate 方法)提交的密碼是否和資料庫中的 password digest 匹配。所以我們實作的方式和 has_secure_password 類似。

我們會先從在 User model 建立一個 remember_digest 屬性開始:

所以要建立一個遷移檔案:

$ rails generate migration add_remember_digest_to_users remember_digest:string

遷移檔案的名稱最後是 to_users,是為了告訴 Rails 這個遷移是要更改資料庫的 users table,因為同時包含了屬性 remember_digest 和類型 string,所以 Rails 就會自動建立以下的遷移內容:

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 取回使用者,所以不必在 remember_digest 欄位建立資料庫索引。

執行遷移:

$ bundle exec rake db:migrate

現在要來製作 remember token,基本上任何一定長度的隨機字串都可以當作 token。Ruby 的 standard library,有一個 SecureRandom module 的 urlsafe_base64 方法可以滿足我們的需求。這個方法會回傳一個長度為 22 的隨機字串,裡面會由 A-Za-z0-9-_ 隨機組成(每一位元有 64 種可能)。

典型的 base64 字串如下:

$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

就像兩個使用者可以使用相同的密碼,remember token 也不必一定要唯一,不過如果是唯一,安全性更高。以上述的 base64 字串為例,每個字都有 64 種可能性,所以兩個 token 會一樣的機率小到可以忽略。此外,使用可以在 URL 安全使用的 base64 字串(這就是為什麼方法名稱為 urlsafe_base64),我們還可以在啟動帳戶和重設密碼中使用類似的 token。

如前所述,要記住使用者,我們要建立一個 remember token 和 token 的 digest,然後把 digest 存進資料庫。我們之前為了測試 fixtures 已經建立了一個 digest 方法,基於上述分析,我們可以建立一個 new_token 方法來產生新的 token。跟 digest 方法一樣,new_token 不需要用到使用者的物件,所以把它定義成 class method。

一般的規則是,如果方法不需要物件實例(an instance of an object),就應該要被定義成 class method

new_token 方法定義如下:

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 }

  # Returns the hash digest of the given string.
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # Returns a random token.
  def User.new_token
    SecureRandom.urlsafe_base64
  end
end

接下來要建立一個 user.remember 方法,這個方法要跟使用者的 remember token 關聯,然後把對應的 digest 存進資料庫。之前我們已經建立了一個遷移,產生了一個 remember_digest 屬性,但沒有 remember_token 屬性。我們要找到一種方法,透過 user.remember_token 取得 token(為了要存進 cookie),但不會在資料庫裡儲存 token。

之前在建立安全密碼時,有解決類似的問題,也就是使用一個虛擬屬性 password 和資料庫裡的 password_digest 屬性搭配使用,不過 password 虛擬屬性是由 has_secure_password 自動產生的,現在我們則是要自己手動寫出 remember_token 屬性,可以使用 attr_accessor 方法來建立一個可以讀取的屬性:

class User < ActiveRecord::Base
  attr_accessor :remember_token
  .
  .
  .
  def remember
    self.remember_token = ...
    update_attribute(:remember_digest, ...)
  end
end

這行:

self.remember_token = ...

根據 Ruby 物件裡面派值的操作,如果沒有 self,會建立出一個 local variable remember_token,但這不是我們想要的。使用 self 的目的是要確保把值指派給使用者的 remember_token 屬性。

然後這行:

update_attribute(:remember_digest, ...)

使用 update_attribute 方法來更新 remember digest。之前提過,使用 update_attribute 方法可以跳過驗證,因為在這個例子中,我們不需要獲取使用者的密碼或密碼確認。

完整程式碼如下:

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 }

  # Returns the hash digest of the given string.
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # Returns a random token.
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # Remembers a user in the database for use in persistent sessions.
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end

所以要建立一個有效的 remember token 和 digest 的方法就是,先使用 User.new_token 建一個新的 remember token,然後使用 User.digest 產生 digest,最後更新資料庫裡的 remember_digest 欄位資料。