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,可以把 signed 和 permanent 方法串接在一起:
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
其中重複使用了 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])
不是比較句型。而是賦值語句,意思就是:「如果 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