発展的なログイン機構
第8章の記事ではでは一時cookieによるログイン機構を実装したが、本記事ではユーザーのログイン情報を記憶しておき、ブラウザを再起動した後でもすぐにログインできる機能 (remember me) を、永続cookieを使って実装していく。
Remember me機能
本節では、ユーザーのログイン状態をブラウザを閉じた後でも有効にする [remember me] 機能を実装していく。この機能を使うと、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになる。
記憶トークンと暗号化
前回は、Railsのsessionメソッドを使ってユーザーIDを保存したが、この情報はブラウザを閉じると消えてしまう。本節では、セッションの永続化の第一歩として記憶トークン (remember token) を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト (remember digest) によるトークン認証にこの記憶トークンを活用する。
sessionメソッドで保存した情報は自動的に安全が保たれるが、cookiesメソッドに保存する情報はそのようにはなっていない。特に、cookiesを永続化するとセッションハイジャックという攻撃を受ける可能性がある。これに対処するため、次の方針で永続的セッションを作成することにする
1:記憶トークンにはランダムな文字列を生成して用いる。
2:ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
3:トークンはハッシュ値に変換してからデータベースに保存する。
4:ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
5:永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
それでは最初に、必要となるremember_digest属性をUserモデルに追加する。
$ rails generate migration add_remember_digest_to_users remember_digest:string
そしてマイグレーションをデータベースに反映させる。
$ rails db:migrate
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存する。fixtureをテストするときにdigestメソッドを既に作成してあったので 、上の結論に従って、新しいトークンを作成するためのnew_tokenメソッドを作成できる。この新しいdigestメソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにする。
class User < ApplicationRecord
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メソッドを作成する。このメソッドは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存する。Userモデルには既にremember_digest属性が追加されているが、remember_token属性はまだ追加されていない。このため、user.remember_tokenメソッドを使ってトークンにアクセスできるようにし、かつ、トークンをデータベースに保存せずに実装する必要がある。これを実装するため、attr_accessorを使って「仮想の」属性を作成する。
class User < ApplicationRecord
attr_accessor :remember_token #モデルのインスタンスの指定した変数にアクセスできるようになる
.
.
.
def remember
self.remember_token = ...
update_attribute(:remember_digest, ...)
end
end
rememberメソッドの2行目では、update_attributeメソッドを使って記憶ダイジェストを更新している。このメソッドはバリデーションを素通りさせる。今回はユーザーのパスワードやパスワード確認にアクセスできないので、バリデーションを素通りさせなければならない。最初にUser.new_tokenで記憶トークンを作成し、続いてUser.digestを適用した結果で記憶ダイジェストを更新する。rememberメソッドの更新結果を以下に示す。
class User < ApplicationRecord
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
ログイン状態の保持
user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができた。これを実際に行うにはcookiesメソッドを使う。このメソッドは、ハッシュとして扱えます。個別のcookiesは、1つのvalue (値) と、オプションのexpires (有効期限) からできている。
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
また、
cookies.permanent[:remember_token] = remember_token
上のコードによって、Railsは期限を20.years.from_nowに設定する。
ユーザーIDをcookiesに保存するには、
cookies.signed[:user_id] = user.id
これにより、ユーザーIDを暗号化したものをcookieに保存する。
cookiesを設定すると、以後のページのビューでこのようにcookiesからユーザーを取り出せるようになる。
User.find_by(id: cookies.signed[:user_id])
cookies.signed[:user_id]では自動的にユーザーIDのcookiesの暗号が解除され、元に戻る。続いて、bcryptを使って、渡されたcookies[:remember_token]がremember_digestと一致することを確認する。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
これを使った、記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドを、Userモデルの中に置く。
class User < ApplicationRecord
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
remember_digestの属性の使い方に注意する。この使い方はself.remember_digestと同じであり、すなわち第6章のnameやemailの使い方と同じになる。実際、remember_digestの属性はデータベースのカラムに対応しているため、Active Recordによって簡単に取得したり保存したりできる。
これで、ログインしたユーザーを記憶する処理の準備が整った。rememberヘルパーメソッドを追加して、log_inと連携させる。
ログインしてユーザーを保持する
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
ユーザーを記憶する
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
# ユーザーのセッションを永続的にする
def remember(user)
user.remember #ユーザーの記憶トークンを生成し、それをハッシュ化したものをデータベースのユーザーモデルのremember_digestに追加
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
# 現在ログインしているユーザーを返す (いる場合)
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
上のコードでは、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されるが、8章で定義したcurrent_userメソッドでは一時セッションしか扱っていないので、このままでは正常に動作しない。
@current_user ||= User.find_by(id: session[:user_id])
永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要がある。これを行うには、current_userメソッドを次のように記述する。
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]) #渡されたcookies[:remember_token]がremember_digestと一致することを確認する。
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
なお、
if (user_id = session[:user_id])
一見、上のコードは比較を行っているように見えるが、これは比較ではない。比較であれば==を使うはずだが、ここでは代入を行っている。
ユーザーを忘れる
ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義する。このuser.forgetメソッドによって、user.rememberが取り消される。具体的には、記憶ダイジェストをnilで更新する。
class User < ApplicationRecord
attr_accessor :remember_token
.
.
.
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
このコードを使うと、永続セッションを終了できるようになる準備が整う。終了するには、forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す。forgetヘルパーメソッドではuser.forgetを呼んでからuser_idとremember_tokenのcookiesを削除している。
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
[Remember me] チェックボックス
本章では、[remember me] チェックボックスでログインを保持する方法を解説する。チェックボックスを追加したモックアップを以下に示す。
今回の実装は、ログインフォームにチェックボックスを追加するところから始める。
<% 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, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= 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>
ここでは、2つのCSSクラスcheckboxとinlineを使ってる。
ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにする。ログインフォームから送信されたparamsハッシュには既にチェックボックスの値が含まれている。
params[:session][:remember_me]
この値は、チェックボックスがオンのときに'1'になり、オフのときに'0'になる。paramsハッシュのこの値を調べれば、送信された値に基いてユーザーを記憶したり忘れたりできるようになる。
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
この実装によって、ログインシステムの実装がついに完了した。