はじめに
railsチュートリアル9章の内容が頭に入りにくかったので、自分なりの言葉でまとめた備忘録です
目標
以下のようなsessionメソッドでは、ユーザーIDの一時保存はできるが、
ブラウザを閉じると、保存が破棄されるため、再ログインが必要となる。
session[:user_id] = user.id
一般的なサイトは、ログイン状態を保持するかどうかの確認があるケースが多いため、
cookiesメソッドを用いて、ブラウザを閉じても、ログイン状態を維持できる機能を追加し、
それを選択できるような状態にする。
cookiesメソッドの問題点
sessionメソッドで保存した内容は安全性の高い物となっているが、
cookiesメソッドで保存する場合は、内容がそのまま保存されるため、安全性が低い。
cookiesを盗む有名な方法は4種類あり、解決策は以下のとおりとなる。
①管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す。
⇒Secure Sockets Layer(SSL)をサイト全体に適用して、ネットワークデータを暗号化で保護
②データベースから記憶トークンを取り出す。
⇒記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存する
③クロスサイトスクリプティング(XSS)を使う。
⇒Railsにより、ビューのテンプレートで入力した内容をすべて自動的にエスケープされる
④ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
⇒物理攻撃自体は防げないが、ユーザーがログアウトしたときにトークンを必ず変更するようにし、
セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名を行う
トークン:コンピューターが作成・管理する情報(パスワード:ユーザーが作成・管理する情報)
記憶トークンの生成
記憶トークンの内容は長く、ランダム性がある文字列であればok
⇒Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使用する
(A–Z、a–z、0–9、"-"、"_"の64種類から重複ありで22文字選び返すメソッド)
これを、Userモデルのクラスメソッドとして作成する
(オブジェクトのインスタンスを使用しないメソッドはクラスメソッドにするのが常道)
def User.new_token
SecureRandom.urlsafe_base64
end
記憶トークンのハッシュ化
記憶トークンをDBにそのまま保存すると、DBの情報を取られ得た際に問題となるため、
記憶トークンをハッシュ化し、ハッシュ化にはbcryptを用いる(gemのためインストール必要)
以下の表記で文字列をハッシュ化することができる。
BCrypt::Password.create(文字列, cost: コストパラメータ)
コストパラメータはハッシュを算出するための計算コストを指定する。
値が高い⇒パスワードの推測が困難になるが、計算処理が重くなる
したがって、テスト環境ではコストを下げ、本番環境ではコストを上げる事を考える
cost = if ActiveModel::SecurePassword.min_cost #テスト環境かどうか? テスト環境⇒true
BCrypt::Engine::MIN_COST #最小のコスト(4)
else
BCrypt::Engine.cost #何もしていなければデフォルトのコスト(12)
end
コストについての参考
上記を三項演算子を用いて表記すると
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
以上の内容を用いて、クラスメソッドを作成する。
def User.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
ハッシュ化した記憶トークンのDB保存
まずはハッシュ化した記憶トークンを入れる場所が必要のため、
remember_digest属性をUserモデルに追加
$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
user.remember_tokenでトークンにアクセスをしたいが、トークンをデータベースに保存はしたくない。
⇒attr_accessorを使って仮想の属性を作り出す
(has_secure_passwordメソッドの仮想のpassword属性と同じ考え)
attr_accessor :remember_token
remember_tokenにトークンを代入し、それをハッシュ化しDB(remember_digest)に保存するメソッドを追加する
def remember
self.remember_token = User.new_token #remember_token = ではローカル変数扱いになってしまう
update_attribute(:remember_digest, User.digest(remember_token))
end
cookiesの保存
cookiesに記憶トークンとユーザーidを保存させる
有効期限(expires)は省略可能、今回は20年で設定している(よく使われる有効期限のひとつ)
cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }
cookies[:user_id] = { value: user.id, expires: 20.years.from_now.utc }
cookiesの有効期限20年はよく使われている設定であり、Railsにもpermanentという専用メソッドが存在ため、
そのメソッドで書き直す。
cookies.permanent[:remember_token] = remember_token
cookies.permanent[:user_id] = user.id
記憶トークンはそのままの文字列で保存しても問題ないが、ユーザーidをそのまま保存すると、
攻撃者がユーザーアカウントを奪い取ることを助けてしまう可能性があり。
⇒署名付きcookieを使用する(cookieをブラウザに保存する前に安全に暗号化する)
cookies.permanent[:remember_token] = remember_token
cookies.permanent.signed[:user_id] = user.id
以上の記述を行うことで、以下内容でユーザーを取り出せるようになる。
User.find_by(id: cookies.signed[:user_id])
記憶トークンとDBの情報(remember_digest)の一致確認
渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する方法として、
secure_passwordのソースコードを参考にする
BCrypt::Password.new(password_digest) == unencrypted_password
上記を参考に今回のケースであてはめる
BCrypt::Password.new(remember_digest) == remember_token
上記の書き方では、ハッシュ化したもの == ハッシュ化前のものを比較しており、
ハッシュ化したものは復号化できないため、本来おかしい。
⇒bcrypt gemのソースコードで == の再定義が行われている
# Compares a potential secret against the hash. Returns true if the secret is the original secret, false otherwise.
def ==(secret)
super(BCrypt::Engine.hash_secret(secret, @salt))
end
alias_method :is_password?, :==
また、 == はis_password?メソッドと同義のため、以下に書き直せる
BCrypt::Password.new(remember_digest).is_password?(remember_token)
以上より、記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドを、Userモデルに記載
(remember_tokenはローカル変数であり、attr_accessor :remember_tokenとは関係なし)
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
createコントローラへ記載
user.rememberを呼び出し、ユーザーidとトークンをcookiesに保存するメソッドを、sessions_helperに追加
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
createアクションにrememberヘルパーメソッドを追加
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
current_userメソッドの変更
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
上記では、一時セッションにしか対応していないため、永続セッションにも対応させる。
具体的には、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
このままでも動くが、user_idに対しsessionメソッドとcookiesメソッドを2回使っているので、1回で済むように、
if文をユーザーIDにユーザーIDのセッションを代入した結果、ユーザーIDのセッションが存在すればに変更し、
sessionヘルパーに記載する。
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
ログアウト処理
ログアウトする方法は、cookiesの中身をdeleteすれば良い。
また、ログアウトと同時にDB内のハッシュ化したトークンをnilにする。
まずは、remember_digestカラムをnilに更新するメソッドをuserモデルに追加する。
def forget
update_attribute(:remember_digest, nil)
end
log_outヘルパーメソッドにforgetヘルパーメソッドを追記する(中身はまだ書いていない)
def log_out
forget(current_user) #追記
session.delete(:user_id)
@current_user = nil
end
forgetヘルパーメソッドの中身を記載する
def forget(user)
user.forget #update_attribute(:remember_digest, nil)
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
目立たないバグ1
2つタブを開いた状態で、1つのタブでログアウトしたあとに、
もう一つのタブでログアウトするとエラーが発生
【原因】
1回目のログアウト時にcurrent_userがnilになっているため、
2回目のログアウトでforget(current_user)がエラーとなる
【対策】
ユーザーがログイン中の場合にのみログアウト
def destroy
log_out if logged_in? #変更
redirect_to root_url
end
目立たないバグ2
2つのブラウザ(Firefox、Chromeなど)でサイトを閲覧時、Firefoxでログアウトし、
Chromeを閉じ、同じサイトを開くとエラーが発生
【原因】
Firefoxでのログアウト時に、remember_digestがnilになる。
Chromeを閉じて、開いた際のuser_idの記憶は以下の状況になる
session[:user_id] ⇒ データなし
cookies.signed[:user_id] ⇒ データあり
その結果current_userのuser.authenticated?(cookies[:remember_token])部分で、
remember_digestがnilになっていることでエラーが発生する
def current_user
if (user_id = session[:user_id]) #false
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id]) #true
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token]) #問題発生
log_in user
@current_user = user
end
end
end
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
【対策】
上記コード実行前に、remember_digestが空であればfalseを返す処理を追記する
def authenticated?(remember_token)
return false if remember_digest.nil? #追記
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
ログイン状態を保持するかどうかのチェックボックス
まずは、ログインフォームにチェックボックスを追加したいため、
以下内容をフォーム部分に追記する
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
これにより、チェックボックスがオン = 1、チェックボックスがオフ = 0が返ってくる。
値の取得は以下でできる。
params[:session][:remember_me]
チェックボックスがオンのとき、cookiesメソッドによるログインの永続化を行いたいので、
1⇒remember(user) 0⇒forget(user)を呼び出すようにする
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
上記を三項演算子を用いてsessionコントローラのcreateアクションに追記する
def create
user = User.find_by(email: params[:session][:email])
if user && user.authenticate(params[:session][:password])
log_in(user)
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
flash[:success] = "ログインに成功しました"
redirect_to user
else
flash.now[:danger] = 'Eメールとパスワードの組み合わせが無効です'
render 'new'
end
end