Help us understand the problem. What is going on with this article?

Railsチュートリアル第9章まとめ

More than 1 year has passed since last update.

Railsチュートリアルをまとめる

Railsチュートリアル第9章から多くのメソッドが入り乱れて訳が分からなくなりがちだったので、それぞれのメソッドとその役割を一章ごとにまとめていこうと思う。

Railsチュートリアル第8章までのコードを全て実装済であり、usersテーブルにremember_digestカラムを追加してあることが前提。
また、テストのことまで書くと膨大になってしまうため、開発環境の解説に留める。

9.1 Remember_me機能

user.rb
  # 渡された文字列のハッシュ値を返す
  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
user.rb
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メソッドでは、コメントに『永続セッションのためにユーザーをデータベースに記憶する』とあるように、そのような処理が書かれている。
attr_accessorで定義したremember_tokenにランダムなトークンを代入し、二行目でそれを暗号化して自身のrememer_digestカラムを上書きする、という流れ。
これで特定のuserに対してrememberメソッドを用いればremember_digestカラムに値が保存されるようになる。
最終的に、このremember_digestとremember_tokenが一致するかどうかを検証して永続ログインができるようにしていく。
ここから似た名前が連続して出てくるため紛らわしくなっていく。

user.rb
# 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

remember_digestとremember_tokenが同一かどうか調べるためのメソッド。
しかしこのメソッドを実際に使うのはもう少し後なので、解説は後回しにする。

sessions_controllet.rb
 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

createアクション内に永続ログイン用の処理を書き込む。
しかしここでもこの処理はまだ実装していないので、エラーになる。
上のuser.rbで定義したrememberメソッドとは名前が同じだけでここでの処理とは関係ない。上のメソッドには引数が必要ないことを確認。

sessions_helper.rb
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

このメソッドで永続ログインのためにcookieを設定する。
cookieはブラウザに保存されるテキスト情報のこと。
ここでuser.rbで定義したrememberメソッドが使用されている。このメソッドで行っているのはremember_tokenをremember_digestに保存すること。
つまり、ここでは引数で渡されたuserのremember_digestを更新している。
その下でcookiesメソッドでcookieを設定する。

cookies.permanent.signed[:user_id] = user.id
渡されたuserのidをsignedで保存される前に安全のために暗号化し、permanentで有効期限を20年に設定する。

cookies.permanent[:remember_token] = user.remember_token
これも上とほぼ同じ。ただ違うのが、こちらではsignedを使っていないこと。userの持つremember_tokenはランダムなトークンを設定するUser.new_tokenメソッドで生成されており、それをさらに暗号化する必要がないため、だと思う(違っていたらどなたかご指摘お願いします)。

user.rb
# 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

先ほど解説を後回しにしたauthenticatedメソッド。このメソッドは、この下のcurrent_userメソッド内で使用する。
引数がremember_tokenとなっているが、これはattr_accessorで定義したremember_tokenとは無関係のただの引数であることに注意。ここにはログイン時にremember(user)メソッドで設定したcookieに保存されたremember_tokenが代入される。
つまり、remember_tokenを使って設定されたcookieとremember_digestカラムの値が一致するかを検証しており、一致すればtrueを返す。

session_helper.rb
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

if user && user.authenticated?の部分で検証を行っている。
cookie.signed[:user_id]で検索したuserが存在し、かつ、そのuserのremember_digestとcookie[:remember_token]が一致すれば、という条件式。

user.rb
 def forget
    update_attribute(:remember_digest, nil)
  end

sessions_helper.rb
 # 永続的セッションを破棄する
  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

ログアウトの処理。ログアウトはだいぶシンプル。
forgetメソッドはremember_digestをnilに更新しているだけ。
def forget(user)はforgetメソッドでremember_digestをnilにし、二つのcookieを削除しているだけ。
最後にlog_outメソッド(実際にコントローラーに埋め込むメソッド)。
forgetメソッドの引数にcurrent_userを渡してそれに関わる上記三つのデータを削除し、sessionの値とcurrent_userをnilにしてログイン状態を抜ける。

続いて、このログアウトによって想定される二つのバグを消していく。
想定されるバグとは、二つ以上のタブで同時にアプリにアクセスし、片方でログアウトして片方でログインし続けると、ログアウトが正常に動かなくなってしまうこと。
もう一つは、別々のブラウザでアクセスし、片方だけでログアウトした時、ログインを続けていたブラウザを終了させて再度アクセスしたときにエラーが発生すること。

sessions_controller.rb
 def destroy
    log_out if logged_in?
    redirect_to root_url
  end

一つ目の問題は、ログイン中でないとログアウトができないと設定してしまえば解決。

user.rb
 def authenticated?(remember_token)
    return false if remember_digest.nil?
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
 end

二つ目の問題では、authenticated?メソッドをいじることで解決する。
他のブラウザでログアウトしてremember_digestがnilになってしまっていた場合にはfalseを返す。
つまり、current_user内のif user && user.authenticated?(cookies[:remember_token])の条件式がfalseになるため、またこの時点でsession[user_id]も削除されているため、ログインしていない状態に戻される。

9.2 [Remember me] チェックボックス

new.html.erb
 <%= f.label :remember_me, class: "checkbox inline" do %>
   <%= f.check_box :remember_me %>
   <span>Remember me on this computer</span>
 <% end %>

params[:session][:remember_me]で1か0を取得できるチェックボックスをビューに設置。
1(チェックがついたとき)に永続ログインを行うようにする。

sessions_controller.rb
 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

アクションの中にこれを組み込めば実装終了。
1(チェックがついたとき)だった場合にはremember(user)を、0(チェックがなかったとき)にはforget(user)を使う。
一応補足しておくと、def forget(user)はremember_digestとcookieを削除するメソッドなので、sessionには影響しない。
これでcookieを使った永続ログインかsessionのみの普通のログインかを選択できるようになっているはず。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away