LoginSignup
2
1

More than 3 years have passed since last update.

【Rails】cookiesによる永続的なログイン【Rails Tutorial 9章まとめ】

Last updated at Posted at 2019-11-29

永続ログインとcookies

一時的なログインの維持にはsessionメソッドを使用する。
しかし、ブラウザを閉じると自動的にログアウトしてしまう。

ログイン状態を維持するためには、cookiesメソッドと、記憶トークンと呼ばれるデータを使用する。
記憶トークンはユーザーごとに固有な、パスワードのようなものである。
具体的な手順はこうなる。
①ユーザーごとに記憶トークンを作成する。
②記憶トークンをハッシュ化してUserモデルのカラム(remember_digest)に保存する。
③cookiesに記憶トークンとユーザーIDを入れる。
④ユーザーIDが入ったcookiesを受け取ったら、そのIDをもとにデータベースからユーザーを探し出し、そのremember_digestカラムに保存されているハッシュ値と、cookiesに入っているトークンをハッシュ化した値が一致するか確認する。

記憶トークンの生成と保存

remember_digestカラム

マイグレーションファイルを作成して、Userモデルにremember_digestカラムを追加する。
このカラムには、記憶トークンをハッシュ化して保存する。

$ rails generate migration add_remember_digest_to_users remember_digest:string
db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶トークンの生成

記憶トークンはどんな文字列でもよいが、パスワードと違って個人が覚えておく必要がなく、かつセキュリティ上ランダムな文字列であることが望ましい。
ランダムな文字列を生成するために、ここではSecureRandomモジュールにあるurlsafe_base64メソッドを使う。

$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"

これを使って、トークンを生成するメソッドをUserモデルに定義する。

app/models/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オブジェクトで使用することはないので、クラスメソッドとしている。

rememberインスタンスメソッド

User.new_tokenメソッドで生成したトークンをユーザーに結びつけ、remember_digestカラムにハッシュ化して保存するrememberメソッドを定義する。

ところで、パスワードを保存する場合は次のような手順を踏んでいた。
①passwordというUserモデルの仮想の属性に生のパスワードを入れる。
つまり、@user.password = "foobar"のようにしていた。
②それがbcryptとhas_secure_passwordによってハッシュ化されてpassword_digestカラムに保存される。

これをトークンでも同じようにするのだが、トークンにはbcryptのような便利なものが無いので、今回はpassword属性に相当する仮想のremember_token属性を自分で作らねばならない。
仮想の属性を作るためには、attr_accessorを使う。

app/models/user.rb
attr_accessor :remember_token
.
.
.
 # 渡された文字列のハッシュ値を返す
  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

attr_accessorで作った仮想の属性をメソッドの中で使うには、self.を頭につける。
(ところで、@remember_tokenではダメなんだろうか?)
update_attributeメソッドを使って、remember_digestカラムにハッシュ化したremember_tokenを代入する。

ログイン状態の保持

cookiesとrememberヘルパーメソッド

記憶トークンを作成し、各ユーザーのデータベースに保存できるようになったので、次はcookiesメソッドに記憶トークンとユーザーIDを入れる。
session[:user_id]にユーザーIDを入れたのと同様に、cookies[:user_id]にユーザーIDを、cookies[:remember_token]にremember_tokenを入れる。

cookies[:user_id] = user.id
cookies[:remember_token] = user.remember_token

ここで、cookiesを永続化するために.permanentメソッドを使用する。
また、ユーザーIDはセキュリティ上の問題から.signedメソッドを使用して暗号化する。
これは署名つきcookieと呼ばれる。

cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token

署名つきcookieは自動で複合化されるので、そのままユーザー検索に使える。

User.find_by(id: cookies.signed[:user_id])

これをSessionsコントローラのログイン部分で使用するために、Sessionsコントローラのヘルパーメソッドとして定義する。

app/helpers/sessions_helper.rb
  # 渡されたユーザーでログインする
  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

ここで非常にややこしいと思うのだが、このrememberヘルパーメソッドはデータベースにトークンを保存するために定義したrememberメソッドとは別物である。
前者はSessionsコントローラのアクション内で単独で使うが、後者はUserオブジェクトにつけて使うインスタンスメソッドである。

このメソッドは、まず後者のrememberインスタンスメソッドでユーザーとトークンを紐付け(remember_digestカラムにトークンをハッシュ化して保存)、その後cookiesにユーザーIDとトークンを入れている。

記憶トークンの照合

cookiesに入ったトークンをハッシュ化して、Userオブジェクトのremember_digestカラムに入っているハッシュ値と比較する。
これが少し分かりにくく感じるが...
①cookiesには生のトークンを入れる。
②remember_digestにはハッシュ化されたトークンが入っている。
③cookiesの生のトークンをハッシュ化してみて、それが②と一致するか確かめる。
ということらしい。
これはログイン機能で使用したauthenticateメソッドと同じである。
authenticateメソッドは次のように定義されている。

BCrypt::Password.new(password_digest)is_password?(unencrypted_password)

左の()内にあるのはpassword_digestカラムに入っているハッシュ化されたパスワードである。
右の()内にあるのは生のパスワードである。

これをトークンに当てはめるとこうなる。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

これをもとに、authenticated?メソッドをUserモデルに定義する。

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

ここで使われているremember_tokenはこのauthenticated?メソッドでのみ使用できるローカル変数で、attr_accessorで作成したUserモデルの仮の属性ではない。
(これはややこしいので、適宜名前を変えた方がよいと思う。)
左の(remember_digest)はUserモデルのremember_digest属性のことで、self.remember_digestと同じである。

永続的なログインの実装

必要なメソッドが揃ったので、Sessionsコントローラのcreateアクションを編集して、永続ログインを実装する。
ログインした後、rememberヘルパーメソッドを使用する。

app/controllers/sessions_controller.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

current_userメソッドの修正

永続ログイン機能を実装できたが、一つ問題がある。
現在ログインしているユーザーを返すcurrent_userメソッドは、session[:user_id]からユーザーを探している。
ログインフォームからログインした場合はsessionにもcookiesにもユーザーIDを入れるので問題ない。
しかし、その後ブラウザを閉じると、cookiesは残るがsessionが消えてしまうので現在のユーザーが取り出せなくなる。
つまりログインしているのに現在のユーザーを見つけられないことになる。

これを、sessionが無い場合はcookiesからユーザーを探すように修正する。

app/helpers/sessions_helper.rb
  # 記憶トークン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
  end

cookiesからユーザーを見つけたら、(この時点ではログインできていないはずなので)ログインする。
ここは、ログイン時にemailからユーザーを見つけ、パスワードをauthenticateメソッドで照合するのと全く同じ要領である。
cookies[:user_id]からユーザーを見つけ、トークンをauthenticated?メソッドで照合する。
最後に、現在のユーザーを返す。

なお、sessionとcookiesは繰り返し処理を省くために、if文の条件式でローカル変数user_idに入れてから使っている。

永続セッションからのログアウト

永続ログイン状態を解除するために、永続ログインで作った2つのrememberメソッドにそれぞれ対応し、逆の操作をする2つのforgetメソッドを定義して、ログアウト処理に組み込む。

forgetインスタンスメソッド

Userモデルのrememberメソッドでは、Userオブジェクトのremember_digest属性にトークンをハッシュ化して保存した。
forgetメソッドはその逆で、remember_digest属性を空にする。

app/models/user.rb
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end

forgetヘルパーメソッド

Sessionsヘルパーのremember(user)メソッドでは、rememberインスタンスメソッドを呼び出した後、cookiesにユーザーIDとトークンを入れた。
forget(user)メソッドはその逆である。

app/helpers/sessions_helper.rb
  # 永続的セッションを破棄する
   def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

log_outメソッドの修正

最後に、log_outメソッドにforget(user)メソッドを追加すればログアウト処理が完成する。

app/helpers/sessions_helper.rb
  # 現在のユーザーをログアウトする
   def log_out
    forget current_user
    session.delete(:user_id)
    @current_user = nil
  end

2つのバグ

完成した永続ログイン機能は、小さなバグが2つあるらしい。
…が、今のところ重要ではなさそうなので割愛する。

永続ログインのテスト

テスト部分は別記事にまとめることにする。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1