#目次
#1. はじめに
- この記事は、Rails初学者の工業大学三年生がRailsチュートリアルの学習記録を
つけるための記事です。 - 筆者自体がRailsやWebについて知識が少ないので、内容の解釈などに
間違いがある可能性があります。(その時はコメントで指摘してくださると助かります!) - Railsチュートリアル内ではRailsの内容以外にも、gitでのバージョン管理やHerokuを使ったデプロイも
学習しますが、gitに関しては既に私が学習済みのため学習記録には記述しません。 - 演習の記録も省略します。
#2. 第9章の概要
前回実装したログイン機能は一度ログインしても、ブラウザを閉じた時点でログイン状態が破棄されてしまいます。
この章では、ユーザーがログアウトを実行しない限りログイン状態を維持できるRemember Me機能を実装して、
その機能を利用するかどうかをチェックボックスを使用することでユーザーが選択できるようにします。
- Remember Me機能の実装
- 機能実装の準備
- cookiesメソッド
- Remember Me機能をログイン処理に組み込む
- ログアウト機能の改良
- 記憶したユーザーを忘れる方法
- 目立たない2つのバグ
- バグの解消
- Remember Me機能を使用するチェックボックスの追加
#3. 学習内容
###1. Remember Me機能の実装
####1-1. 機能実装の準備
今回実装するRemember Me機能は記憶トークンという情報を生成し、その情報で認証を行うことで
永続的なセッションを作成できます。
よって、記憶トークンを保存する属性をUserモデルに追加し、記憶トークンをデータベース上に保存できるようにすれば
Remember Me機能は実装できるのですが、この方法ではデータベース上の記憶トークンが他の人に取り出された時に
記憶トークンを取り出したユーザーが特定のユーザーになりすましてログインできてしまいます。
このような攻撃をセッションハイジャックと呼び、この攻撃の対策として記憶トークンをそのままデータベースに保存せず、
記憶トークンをハッシュ化した記憶ダイジェストをデータベース上に保存します。
この方法はユーザーのパスワードを扱うときの方法と同じです。
パスワードも値をそのままデータベース上に保存しているのではなく、ハッシュ化を行ったパスワードダイジェストを
データベース上に保存しています。
パスワードと同じような扱いを記憶トークンでも行えるようにするために、Userモデルに__remember_digest属性__を追加します。
rails generate migration add_remember_digest_to_users remember_digest:string
を実行して。
マイグレーションが作成されていることを確認したらrails db:migrate
を実行します。
次に、記憶トークンの生成を行うメソッドをUserモデルのクラスメソッドとして定義します。
記憶トークンの生成にはbase64という64種類の文字で長さが22のランダムな文字列を返すメソッドを使用します。
# ランダムなトークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
このメソッドを定義したことでランダムなトークンをユーザーに対して付与することができます。
メソッドが実装できたら、生成されたトークンをハッシュ化してremember_digest属性に記憶ダイジェストを保存する
rememberメソッドを定義します。
class User < ApplicationRecord
attr_accessor :remember_token
~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ #途中のコードは省略
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
end
attr_accessor :remember_token
の部分はremenber_tokenというデータベース上にない属性を使用できるように、
仮想的な属性を作成するコードです。
rememberメソッドではattr_accessorで作成したremember_token属性に、new_tokenメソッドで作成したトークンを代入します。
そしたら、update_attributeメソッドでハッシュ化したremember_tokenをデータベース上に保存します。
これで、cookieを使った永続セッションの作成の準備ができました。
####1-2. cookiesメソッド
rememberメソッドにより記憶ダイジェストを扱えるようになったので、ユーザーのIDを暗号化したものと、
記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成していきます。
ここで使用するのがcookiesメソッドです。
cookiesはハッシュとして扱うことができ、1つのvalueとオプションのexpires(有効期限)から構成されています。
有効期限は省略可能ですが、ここでは20年と設定します。
20年という有効期限はcookiesの設定でよく使われており、Railsではpermanentというcookiesに20年の有効期限を設定するための
専用メソッドが追加されています。
ここで定義するメソッドは、渡されたブラウザが保持しているトークンと、データベース上の記憶ダイジェストが
一致するかを判定するauthenticatedメソッドと、
ブラウザのcookiesにユーザIDと記憶トークンを保存するrememberメソッドです。
(このrememberメソッドは先ほど定義したrememberクラスメソッドとは別物です)
# 渡されたトークンがダイジェストと一致したら true を返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
このメソッドではパスワードの実装でも使用したbcryptを使用して、ブラウザの記憶トークンとデータベース上の記憶ダイジェストが
一致するかを判定しています。
ここまでで、
・記憶トークンの作成
・記憶ダイジェストの保存
・ブラウザ上の記憶トークンとDB上の記憶ダイジェストの比較
上記の機能が実装できました。
後は、cookiesメソッドを使用してブラウザのcookieに記憶トークンと暗号化したユーザーIDを保存すれば、
Remember Me機能の完成です。
上記の処理を行うメソッドが、rememberヘルパーメソッドです。
先述した通り、記憶ダイジェストどデータベース上に保存するクラスメソッドのrememberメソッドとは別物で、
ここでのrememberヘルパーメソッドは、ビューで使用するものです。
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
引数でユーザーを受け取ってそのユーザーの記憶ダイジェストを保存してから、
cookiesメソッドでIDと記憶トークンをブラウザに保存しています。
また、permanentが記憶の期限を20年にするというメソッドで、ユーザーIDの保存に使用されているsignedメソッドが
渡された値を暗号化するというメソッドです。
####1-3. Remember Me機能をログイン処理に組み込む
ここまででRemember Me機能が完成したので、それを実際に使用していきます。
具体的には、ログイン時に永続セッションを作成する処理を組み込むこと。
ログイン中のユーザーを返すメソッドで、一時セッションの情報だけでなくcookieの情報を参照して
ログイン中のユーザーを返せるようにすることの2つを行います。
ログイン時に永続セッションを作成する処理は、createアクションで先ほど定義したtememberメソッドを使用するだけで実現できます。
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
ログイン中のユーザーを返すcerrent_userメソッドでは、これまで一時セッションに保存されている情報を基に
ユーザーを返していました。
しかし、今回のRemember Me機能の実装でcookieにもユーザーの情報が保存されるようになったので、
永続セッションにユーザーの情報があるかどうかも見る必要があります。
# 記憶トークン 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
このコードのelseif以下がcookieの情報を参照している部分です。
条件式の=が1なのでこの条件式は比較を行っているのではなく、「右の値を左の変数に代入した結果、変数の値がnilでなければ」
という条件式になっています。
elseifではcookieのユーザーIDをuser_idという変数に代入して、そのユーザーの記憶ダイジェストが
cookieに保存されている記憶トークンと一致すればユーザーがログイン中と判断しています。
###2. ログアウト機能の改良
####2-1. 記憶したユーザーを忘れる方法
ようやくRemember Me機能の実装まで完了しましたが、この状態ではcookieの情報が20年経たないと消えないため、
ユーザーがログアウトを実行してもcookieに残っている情報でまたログインしてしまいます。
ここではユーザーがログアウトを実行したときに、ユーザーの記憶ダイジェストを破棄するメソッドと、
cookieの情報を破棄するメソッドを定義します。
def forget
update_attribute(:remember_digest, nil)
end
このコードが記憶ダイジェストを破棄するクラスメソッドです。
update_attrubuteメソッドでデータベース上の値をnilにしています。
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
このコードがブラウザ上のcookieの情報を破棄するヘルパーメソッドです。
このメソッドをログアウト時に実行することで、正常にログアウトすることができます。
####2-2. 目立たない2つのバグ
ここまでで、実装が終了と言いたいところですが、まだ小さなバグが2つ残っています。
1つは複数の「タブ」でアプリケーションを操作したときに発生するバグ
もう1つは複数の「ブラウザ」でアプリケーションを操作したときに発生するバグです。
それぞれのバグの内容を具体的に説明していきます。
①複数ののタブでアプリケーションを操作したときに発生するバグ
このバグはユーザーが1つのタブでログアウトした後に、
もう1つのバグで再度ログアウトをしようとする時にエラーが発生するというバグです。
これは1度目のログアウトでcurrent_userがnilになり、
2度目のログアウト時に実行されるforget(current_user)の引数がnilになることで発生するバグです。
②複数のブラウザでアプリケーションを操作したときに発生するバグ
こちらのバグは少々複雑です。
ユーザーが複数のブラウザでログインしており、片方でログアウトした後
もう片方でログアウトせずにブラウザを終了させて、再度ログアウトしてないほうのブラウザで同じページを開いたときに発生します。
ユーザーがFirefoxとChromeの両方でログインしているという例で考えてみましょう。
まずユーザーがFirefoxでログアウトします。
すると、user.forgetメソッドにより、データベース上の記憶ダイジェストがnilになります。
そして、ブラウザに保存されているユーザーIDも削除され、正常にログアウトできます。
その後、chromeをログアウトせずに終了したとします。
この時、一時セッションは破棄されますがcookieの情報は残っています。
よってchromeを再起動して同じページにアクセスすると、cookieに残っているユーザーIDを使用してデータベースからユーザーを検索します。
その結果、以下のコードのコメント部分のif文が実行されます。
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]) #このif文が実行される
log_in user
@current_user = user
end
end
end
このif文の条件式は
・userという変数が存在するか
・cookie上の記憶トークンとデータベース上の記憶ダイジェストが一致するか
上記の2つの条件が評価されます。
1つ目はユーザーIDからユーザーが検索されるためtrueとなります。
問題は2つ目です。
cookieには記憶トークンが残っているのですが、データベース上の記憶ダイジェストは
Firefoxでログアウトしたときにnilになっています。
本来であれば、falseが返されてcurrent_userがnilになって終了なのですが、
authenticatedメソッドでは、bcryptを使用しており単純な「==」による比較ではなく、
BCrypt::Password.new(remember_digest).is_password?(remember_token)
という特殊な比較をしています。
これにより、remember_digestがnilだと例外が発生してしまうということです。
####2-3. バグの解消
先述した2つのバグはバグの原因こそ複雑ですが、解消方法はそこまで難しくありません。
解消方法を順に記していきます。
①の解消方法
このバグはログアウトをした後でもログアウトが実行できることによって発生しているので、
ユーザーがログイン中の場合のみログアウトできるようにすることで解消できます。
これまでに**logged_in?**というユーザーがログイン中かどうかを判別するメソッドを実装しているのでそれを利用します。
def destroy
log_out if logged_in? #logged_in?がtrueの時のみlog_outを実行
redirect_to root_url
end
ログアウト処理を行うlog_outメソッドをlogged_in?がtrueの時(=ログイン中の時)のみ実行するようにしました。
②の解消方法
こちらのバグは、remember_digestがnilの場合に例外が発生していたので、
その場合にはfalseを返す処理を加えれば解消できます。
def authenticated?(remember_token)
return false if remember_digest.nil? #remember_digestがnilの時はfalseを返す
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
例外を発生させていたauthenticated?メソッドに上記のようにreturn文を追加すると解消できます。
###3. Remember Me機能を使用するチェックボックスの追加
最後に、Remember Me機能を使用するかどうかを決めるためのチェックボックスを実装します。
このチェックボックスはビューに以下のように記述することで、配置できます
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
そして、チェックボックスがオンの時のみRemember Me機能を使用するようにします。
paramsハッシュにチェックボックスがオンの時に「1」
オフの時に「0」が入るので、その値からrememberメソッドを実行するかどうかを判断します。
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) #チェックボックスがオンの時rememberを実行
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
上記のコードではチェックボックスがオンの時にrememberメソッドを実行。
オフの時はforgetメソッドを実行しています。
#4. 終わりに
この章で、ログイン機能の実装が終わりました。
以降はユーザーの更新、削除やマイクロポスト(Twitterのツイートのようなもの)の投稿機能を実装していくことになります。
内容が難しくなってきたこともあり、1章のページ数も今まで60ページ前後だったのですが、
この章以降は70ページを超える量になってきています。
前回の記事で、1週間に2章のペースを維持していきたいと書いたばっかりですが、中間テストや就活の準備などもあり
現在進行形でペースが落ちて2週間に3章が目標になりつつあります:(
ただ、残り5章と終わりが見えてきたのであと少しは頑張れそうです。