プログラミング備忘録

プログラミングの学習状況をメモしています

53日目

今日の学習

Ruby on Rails

Railsチュートリアル 第9章

railstutorial.jp

この章では、永続cookie(permanent cookies)を学び、ユーザーの任意でログイン情報を記録するかどうかなどの設定を行えるようにする。

昨今のSNSなどでは当たり前のように実装されている機能なので、この章を通じてどのように実装するのか学習していきたい。

Remember me 機能

Remember me機能は、ユーザーのログイン状態が、ブラウザを閉じた後でも有効にする。

これが設定してあると、いちいちサイトを開くたびにログインしなくていいのでありがたい。

また、章の後半ではこの機能をユーザーの任意でオンオフできるようにもしていく。

8章では、Railssessionメソッドを利用してユーザー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行目は、Railstimeへルパーを活用している。

Railsで日付処理 | GENDOSU@NET

20年で期限切れとなるcookies設定はよく使われているので、Railsにはpermanentという専用メソッドが追加されたようだ。

cookies.permanent | Railsドキュメント

これを利用することで、先ほどのコードが以下のように簡略化できる。

cookies.permanent[:remember_token] = remember_token

ユーザーIDをcookiesに保存するには、sessionメソッドのときと同じ様に保存する。

cookies[:user_id] = user.id

しかし、これだけではIDがそのままの状態でcookiesに保存されて危険なので、保存される前に安全に暗号化する必要がある。

その際に使用するのが署名付きcookiesignedメソッドを利用して、デジタル署名と暗号化をまとめて行う。

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メソッドを呼び出して、続いてcookiesdeleteメソッドで削除する。

def forget(user)
  user.forget
  cookies.delete(:user_id)
  cookies.delete(:remember_token)
end

def log_out
# 以下を追加
  forget(current_user)
(略)

これでログアウトできるようになった。

二つのバグ

Railsチュートリアルでは、今の状態では2つのバグがあると解説している。

ユーザーがログイン中の場合にのみログアウトさせる

一つ目は、ユーザーがもしも同じサイトを複数のタブで開いていて、一つのタブでログアウトして、もう一つのタブでログアウトをしようとするとエラーが起こる。

そのため、ユーザーがログイン中の場合にのみログアウトができる状態にする必要がある。

複数のブラウザでのバグ

FirefoxChromeなど異なるブラウザを使用してログインしていたときに発生する。

二つのブラウザ間では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のcheckboxinlineという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アクションに追加し、チェックボックスの送信結果を処理するようにすることで実装完了。

あとはテストを行って終了したが、この章はなかなかついていくのが難しく、話半分で理解している状態だった。もう一度読み返して、各操作の目的を理解しておきたい。