Login with remembering

定義好 remember 方法之後,現在要來建立持久性 session。也就是要把加密過的使用者 ID 和 remember token 作為持久性 cookie 存進瀏覽器。我們要使用 cookies 方法,可以把它視為一個 hash。一個 cookie 由兩個部分資訊組成,一個是 value,一個是選擇性的 expires(過期日期)。例如,建立一個 value 為 remember token,20 年過期的 cookie,實現持久性 session:

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

由於 20 年過期的 cookie 太常使用,所以 Rails 提供了一個方法 permanent,用來建立這種 cookie,所以上述程式碼可以改成:

cookies.permanent[:remember_token] = remember_token

這樣寫,Rails 就會自動把日期設為 20.years.from_now


我們可以參考 session[:user_id] = user.id 的方式,來把使用者 ID 存進 cookies:

cookies[:user_id] = user.id

但這種方式是儲存 plain text,駭客很容易偷取使用者的帳戶。為避免這個問題,要使用 signed cookie,在存進瀏覽器之前安全地加密 cookie:

cookies.signed[:user_id] = user.id

因為我們想讓使用者 ID 和 永久的 remember token 配對,所以也要永久儲存使用者 ID,可以把 signedpermanent 方法串接在一起:

cookies.permanent.signed[:user_id] = user.id

cookies 設定好之後,之後要瀏覽頁面就可以透過以下方式取回使用者:

User.find_by(id: cookies.signed[:user_id])

cookies.signed[:user_id] 會自動解密使用者 ID,然後使用 bcrypt 確認 cookies[:remember_token] 是否和 remember_digest 匹配。

如果只使用 signed ID,沒有 remember token 的話,駭客一旦知道加密過的 ID,就能以這個使用者的身份登入。但如果按照我們設計的方式,就算駭客同時取得 ID 和 remember token,也要等到使用者登出後才能登入。


最後就是要驗證 remember token 是否匹配使用者的 remember digest。在這種情況下,使用 bcrypt 確認是否匹配有很多相同的方法。查詢 secure password source code 會發現以下寫法:

BCrypt::Password.new(password_digest) == unencrypted_password

在我們的例子中會是:

BCrypt::Password.new(remember_digest) == remember_token

上述程式碼有點奇怪,看起來是直接比較 bcrypt 計算得到的 password digest 和 remember token,而如果要使用 == 做比較,就要解密 digest。但原本使用 bcrypt 的目的就是要得到不可逆的 hash,所以這樣子不太對。如果研究 bcrypt gem 原始碼,你會發現 bcrypt 重新定義了 ==,所以上述的程式碼等於:

BCrypt::Password.new(remember_digest).is_password?(remember_token)

上述寫法,使用 is_password? 方法做比較,因為這樣寫意義更明確。

找時間研究一下

最後在 User model 定義 authenticated? 方法,用來比較 remember token 和 digest。這個方法類似 has_secure_password 提供用來驗證使用者的 authenticate 方法:

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

  # Returns true if the given token matches the digest.
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

注意,authenticated? 的參數 remember_token 是一個方法的 local variable,跟之前定義的 attr_accessor :remember_token 是不一樣的東西。然後 remember_digest 其實就是 self.remember_digest,是剛剛我們建立的資料庫欄位。

現在可以記住使用者的登入狀態了。然後我們要在 log_in 後面調用 remember 方法:

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

和登入功能一樣,上述的程式碼把真正的工作交給 Sessions 輔助方法完成,我們要在 Sessions 輔助方法裡,定義一個 remember 方法,在這個方法裡調用 user.remember,從而產生 remember token,然後把對應的 remember digest 存進資料庫。接著使用 cookies 方法為使用者 ID 和 remember token 建立永久性的 cookies。

完整程式碼如下:

app/helpers/sessions_helper.rb

module SessionsHelper

  # Logs in the given user.
  def log_in(user)
    session[:user_id] = user.id
  end

  # Remembers a user in a persistent session.
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # Returns the current logged-in user (if any).
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  # Returns true if the user is logged in, false otherwise.
  def logged_in?
    !current_user.nil?
  end

  # Logs out the current user.
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

現在使用者登入之後會被記住,瀏覽器會儲存有效的 remember token,但之前定義的 current_user 方法目前只能處理暫時性 session:

@current_user ||= User.find_by(id: session[:user_id])

在持久性 sessions 中,如果 session[:user_id] 的值存在,那就從中取回使用者,否則應該檢查 cookies[:user_id] 取回(並且登入)持久性 sessions 中儲存的使用者,我們可以透過以下的程式碼實作:

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

其中重複使用了 sessioncookies,我們可以這樣修改:

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])

不是比較句型。而是賦值語句,意思就是:「如果 session 中有使用者的 ID,把 session 中的 ID 賦值給 user_id」

作者習慣把賦值語句放在括號裡,從視覺上提醒這是賦值不是比較

更新 current_user 的輔助方法:

app/helpers/sessions_helper.rb

module SessionsHelper

  # Logs in the given user.
  def log_in(user)
    session[:user_id] = user.id
  end

  # Remembers a user in a persistent session.
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # Returns the user corresponding to the remember token 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

  # Returns true if the user is logged in, false otherwise.
  def logged_in?
    !current_user.nil?
  end

  # Logs out the current user.
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end

現在新登入的使用者,能被正確的記住登入狀態了。可以確認一下:登入然後關閉瀏覽器、再打開瀏覽器、重新瀏覽網站,確認你還在登入狀態。也可以透過檢查瀏覽器的 cookies。

最後還有個問題,就是無法清除掉 cookies(除非等到 20 年後),沒有方法讓使用者登出,而這正是測試應該 catch 到的問題,所以執行測試,會無法通過(Red):

$ bundle exec rake test