53日目
今日の学習
Ruby on Rails
Railsチュートリアル 第9章
この章では、永続cookie(permanent cookies)
を学び、ユーザーの任意でログイン情報を記録するかどうかなどの設定を行えるようにする。
昨今のSNSなどでは当たり前のように実装されている機能なので、この章を通じてどのように実装するのか学習していきたい。
Remember me 機能
Remember me機能は、ユーザーのログイン状態が、ブラウザを閉じた後でも有効にする。
これが設定してあると、いちいちサイトを開くたびにログインしなくていいのでありがたい。
また、章の後半ではこの機能をユーザーの任意でオンオフできるようにもしていく。
8章では、Railsのsession
メソッドを利用してユーザーIDを保存したが、この情報はブラウザを閉じると消えてしまう。
セッションを永続化させるために、まずは記憶トークン(remember token)
というものを生成し、cookies
メソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)
によるトークン認証にこの記憶トークン
を活用させる。
*トークンとは
パスワードと同じ秘密情報。
パスワードはユーザーが作成・管理する情報だが、トークンはコンピューターが作成・管理する。
https://wa3.i-3-i.info/word1104.html
どうやらsession
メソッドで保存した情報の場合は、自動的に安全が保たれるがcookies
メソッドに保存する情報は、そのようになっていないらしい。
cookies
を永続化してしまうと、記憶トークンを何らかの手段で奪われてしまうこともあるようで、そのようなことが起こらないように、以下のことを踏まえて永続的セッションを実装するようにする。
記憶トークン
にはランダムな文字列を生成して用いる- ブラウザの
cookies
にトークンを保存するときは有効期限を設定 - トークンはハッシュ値に変換してからDBに保存
- ブラウザのcookiesに保存するユーザーIDは暗号化する
- 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでDBを検索し、
記憶トークン
のcookiesがデータベース内のハッシュ値と一致することを確認
ユーザーログインのときは、メールアドレスをキーにしてユーザーを取り出して、送信されたパスワードがパスワードダイジェストと一致するかどうかを確認していた。
そのとき、データベースにpassword_digest
属性をUserモデルに追加したのと同様に、今回もremember_digest
属性を追加して、上の項目の最後の手順を行えるようにする。
rails g migration add_remember_digest_to_users remember_digest:string rails db:migrate
記憶トークン
に用いるものは、基本的に長くてランダムな文字列であればどんなものでもよい。
Railsチュートリアルでは、Ruby標準ライブラリのSecureRandom
モジュールのurlsafe_base64
メソッドを活用している。
これを使うと、長さが22のランダムな文字列を64種類の英数字記号から生成してくれる。
>> SecureRandom.urlsafe_base64 => "ooL_IDiaVvQhimOLdKAdBA" >> SecureRandom.urlsafe_base64 => "HSZ-hm5FL95s6FO-ZJi8Sg"
ユーザーを記憶するには、記憶トークン
を作成して、そのトークンをダイジェストに変換したものをデータベースに保存する。
fixtureをテストするときに作成したdigestメソッドを作成したときのように、新しいトークンを作成するためのnew_token
メソッドを作成する。
あるメソッドがオブジェクトのインスタンスを必要としない場合は、クラスメソッドとして作成することが重要になる。
今回作成する新しいdigest
メソッドは、ユーザーオブジェクトが不要なので、Userモデルのクラスメソッドとしてapp/models/user.rb
に記述していく。
# ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end
次に、記憶トークン
とユーザーを関連づけて、トークンに対応する記憶ダイジェスト
をデータベースに保存するメソッド、user.remember
を作成する。
Userモデルには、現在remember_digest
属性が追加されているが、remember_token
属性はまだ追加されていない。
そのため、user.remember_token
メソッドを使ってトークンにアクセスできるようにし、かつトークンをデータベースに保存せずに実装する必要がある。
セキュアパスワードを実装したときと同様の手法で実装していく。
このときはhas_secure_password
メソッドを使うことで、仮想のpassword
属性を作成できたが、今回の場合はremember_token
のコードを自分で書く必要がある。
app/models/user.rb
class User <ApplicationRecord # 仮想の属性を作成 attr_accessor :remember_token (中略) # ユーザーをデータベースに記憶する def remember # selfキーワードを与えて、remember_token属性を設定 self.remember_token = User.new_token # 記憶ダイジェストを更新し、バリデーションを素通りさせる update_attribute(:remember_digest, User.digest(remember_token)) end
ログイン状態の保持
user.remember
メソッドが使えるようになった。これで、ユーザーの暗号化済IDと記憶トークン
をブラウザの永続cookiesに保存して、永続セッションを作成する準備ができた。
ここからは、cookies
メソッドを活用して永続的なセッションを作っていく。
session
のときに情報をハッシュとして扱ったのと同じく、cookies
でも8種として扱うことができる。
cookies
は、一つのvalue
と、オプションのexpires
(有効期限)から成る。expires
は省略可能。
以下の様に20年後に期限切れになる記憶トークンと同じ値をcookie
に保存し、永続的なセッションを作ることが可能。
# ハッシュとして操作 cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }
2行目は、Railsのtimeへルパー
を活用している。
20年で期限切れとなるcookies
設定はよく使われているので、Railsにはpermanent
という専用メソッドが追加されたようだ。
cookies.permanent | Railsドキュメント
これを利用することで、先ほどのコードが以下のように簡略化できる。
cookies.permanent[:remember_token] = remember_token
ユーザーIDをcookies
に保存するには、session
メソッドのときと同じ様に保存する。
cookies[:user_id] = user.id
しかし、これだけではIDがそのままの状態でcookies
に保存されて危険なので、保存される前に安全に暗号化する必要がある。
その際に使用するのが署名付きcookie
。signed
メソッドを利用して、デジタル署名と暗号化をまとめて行う。
cookies.signed[:user_id] = user.id
また、ユーザーIDと記憶トークンはペアで扱う必要があるため、cookieも永続化しなくてはならない。そのため、上のコードにpermanent
を繋いで利用する。
cookies.permanent.signed[:user_id] = user.id
cookies
を設定すると、ページのビューから以下の形でcookies
からユーザーを取り出せるようになる。
User.find_by(id: cookies.signed[:user_id])
最後に、渡されたトークンがダイジェストと一致するかどうかを比較するメソッドをUserモデル内に用意する。
# 一致したらtrue def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end
以上で、ログインしたユーザーを記憶する処理の準備ができた。
cookies
メソッドでユーザーIDと記憶トークンの永続cookiesを作成する。
app/helpers/sessions_helper.rb
def remember(user) # ランダムなトークンを作成し、ユーザーをデータベースに記憶する user.remember # ユーザーIDを暗号化して、記憶トークンと紐づける cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token
以前作成したcurrent_userヘルパーメソッドを、記憶トークンcookieに対応するユーザーを返すように変更する必要がある。
永続セッションの場合は、session[:user_id]
が存在すれば一時セッションからユーザーを取り出して、それ以外の場合はcookies[:user_id]
からユーザーを取り出し対応する永続セッションにログインする。
def current_user # ユーザーIDのセッションの存在有無を代入で確認 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
sessionsコントローラのcreateアクションで、log_in user
の後ろにremember user
を付け加えることで、永続ログインができるようになった。
ユーザーを忘れる
ブラウザのcookies
を削除する手段を実装していないため、今のままだとユーザーがログアウトできない状態。
試しにログアウトを押してみたが、ログインされた状態が続いている。
ちゃんとログアウトできるように、ユーザーの情報を忘れるためのメソッドuser.forget
を定義する。
user.forget
メソッドで、user.remember
メソッドを取り消すようにする。
forget
メソッドをUserモデルに追加し、記憶ダイジェストを以下のようにしてnil
で更新する。
def forget update_attribute(:remember_digest, nil) end
続いて、今作成したforget
メソッドをsessions_helper
に追加していく。
forget
メソッドを呼び出して、続いてcookies
もdelete
メソッドで削除する。
def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end def log_out # 以下を追加 forget(current_user) (略)
これでログアウトできるようになった。
二つのバグ
Railsチュートリアルでは、今の状態では2つのバグがあると解説している。
ユーザーがログイン中の場合にのみログアウトさせる
一つ目は、ユーザーがもしも同じサイトを複数のタブで開いていて、一つのタブでログアウトして、もう一つのタブでログアウトをしようとするとエラーが起こる。
そのため、ユーザーがログイン中の場合にのみログアウトができる状態にする必要がある。
複数のブラウザでのバグ
Firefox、Chromeなど異なるブラウザを使用してログインしていたときに発生する。
二つのブラウザ間ではcookies
の同期が起こらず、結果としてエラーが発生することがあると解説されている。
解決方法
Railsチュートリアルでは、テストと並行させながらエラーの解決に当たった。
ユーザーがログイン中の場合にのみログアウトさせる点に関しては、users_login_test.rb
では、わざと二度目のdelete logout_path
を入れて、テストが失敗することを確認する。
その後、ログイン中の場合にのみログアウトするように、sessions
コントローラのdestroy
アクションのコードを書き換える。
def destroy log_out if logged_in? redirect_to root_url end
二番目の問題では、ブラウザの種類を増やしてシミュレートするのは難しいため、同じ問題をUserモデルで直接テストする形になった。
記憶ダイジェスト
を持たないユーザーを用意authenticated?
を呼び出し、記憶トークンを空欄のままにする
test/models/user_test.rb
test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end
今のままではエラーが起こるため、記憶ダイジェスト
が存在しない場合に対応できる様に、authenticated?
メソッドを更新する。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? (略)
nil
の場合にはreturn
で即座にメソッドが終了するようになっている。
処理を途中で終了する場合によく使われるテクニック。
ログイン状態を保存するチェックボックス
日本語だとRemember me
チェックボックスよりも、タイトルのような書き方の方が馴染みがある。
チェックボックスの準備が必要なので、まずはビューファイルにチェックボックスを追加する。
<%= form.label :remember_me, class: "checkbox inlune" do %> <%= form.check_box :remember_me %> <span>Remember me on this compter</span> <% end %>
Bootstrapのcheckbox
とinline
という2つのCSSクラスを使っている。
チェックボックスと、<span>
で囲んだテキスト「Remember~」を同じ行に配置するようにする。
細かいレイアウトの調整は、Railsチュートリアルにある内容に従った。
あとは、チェックボックスから送られてくる値に応じてログイン状態を保存するかしないかを設定できる。
チェックボックスがオンのとき、params
から送られてくるハッシュの中のチェックボックスの値は1
になる。
逆に、オフのときは0
になるので、以下のようにして設定する。
if params[:session][:remember_me] == '1' remember(user) else forget(user) end # 三項演算子で1行にまとめる params[:session][:remember_me] == '1' ? remember(user) : forget(user)
これをsessions
コントローラのcreate
アクションに追加し、チェックボックスの送信結果を処理するようにすることで実装完了。
あとはテストを行って終了したが、この章はなかなかついていくのが難しく、話半分で理解している状態だった。もう一度読み返して、各操作の目的を理解しておきたい。