Edited at

Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #9 永続セッション, cookie編


こんな人におすすめ


  • プログラミング初心者でポートフォリオの作り方が分からない

  • Rails Tutorialをやってみたが理解することが難しい

  • ポートフォリオを作成しながら勉強したい

前回:#8 ログイン/ログアウト, FactroyBot編

次回:#10 リメンバーミー機能編


こんなことが分かる


  • 永続化するメソッドが知れる

  • cookieについて分かる

  • attr_accessorについて分かる

  • クラスメソッドとインスタンスメソッドの違いが分かる

一緒に勉強しませう:bow:


今回の流れ


  1. 永続化の手順をざっくり解説

  2. 各単語やメソッドを確認

  3. 永続化を実装

  4. バグを除去

こういう流れでやって行きます。


どうやってログインを永続化させる?

ログインを永続化させるリメンバーミー機能。

(リメンバーミー機能の実装は次回)

流れ的にはこうらしい(以下Tutorial 9.1 Remember me 機能を引用)



  1. 記憶トークンにはランダムな文字列を生成して用いる

  2. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する

  3. トークンはハッシュ値に変換してからデータベースに保存する

  4. ブラウザのcookiesに保存するユーザーIDは暗号化しておく

  5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する


なんのこっちゃ。

それによく分からない言葉やメソッドやらがいっぱいある。


  • cookie

  • remember_digest

  • remember_token

  • User.new_token

  • User.digest

  • remember

  • attr_accessor ← これはRubyの話だけど

これも1つずつ解説しよう。


永続化の仕組みをざっくり解説

これから以下の工程で永続化を行います。

超ざっくりにまとめるとこんな感じ。


  1. ログイン時に記憶トークンとIDの暗号作る

  2. 暗号をPCのデータベースに、トークンをサーバに保存する

  3. 次来た時保存した暗号をサーバと照らし合わせる

  4. OKだったら勝手にログインする


クッキー(cookie)とは?

クッキーはパソコンとサーバの橋渡しです。

...説明が質素すぎますね。

クッキーちゃんというキャラを想像してみよう(強引)

:cookie: ...ここにいました。

:cookie:はサーバと私たちのパソコンを繋ぐメモ係です。

私たちが「次もログインしたままがいいなあ、サーバさんに伝えといてくれる?」

とお願いすると、:cookie:は「分かった!」と言ってくれました。

しかし:cookie:はか弱いです。

サーバに重要な情報を渡す途中で盗まれたりしたら大変!

なのでサーバさんには名前をつけた情報(remember_token)を渡すことに、私たちのパソコンにはそれを暗号にしたもの(remember_digest)を保存することにしました。

これで安全!:cookie:はサーバさんに情報を伝えるおつかいに行ったのでした。

分かりやすい解説↓

クッキー(cookie)とは?初心者でも分かるように図解


remember_token / remember_digestとは?

remember_tokenを暗号化 → remember_digestに代入

こういう関係性。


User.new_token / User.digest / rememberとは?

User.new_token → remember_tokenとかを作るメソッド

User.digest → remember_tokenとかの値を暗号にするメソッド

remember → 暗号をremember_digestに代入するメソッド

User.digestと関連する解説↓

ハッシュ値 (hash value)とは


remember_digestに保存するまでを実装する

ここまでくると各メソッドがどういう風に動くかが見えてくる。


  1. User.new_tokenメソッドでremember_tokenを作る

  2. User.digestメソッドでremember_tokenを暗号化する

  3. rememberメソッドで暗号化したremember_tokenをremember_digestに保存する

では早速実装しよう。

その前に1つ。

User.digestやUser.new_tokenはこんな風に書き換えできる。

class << self

def digest(string)
end

def new_token
end
end

これを踏まえた上で実装する。

まずUserモデルにremember_digest属性を追加。


bash

$ rails generate migration add_remember_digest_to_users remember_digest:string

$ rails db:migrate

その後user.rbにメソッドを書く。


app/models/user.rb

class User < ApplicationRecord

attr_accessor :remeber_token
# 中略

class << self
def digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end

def new_token
SecureRandom.urlsafe_base64
end
end

def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
end



attr_accessorは何をしているのか

勘のいい方は分かるかもしれませんが、

本来remember_tokenなんて属性はUserモデルにありません。

(あるのはさっきmigrationで作ったremember_digestだけ)

だからremember_token属性に代入できるわけがない。

それをいい感じにやってくれるのがattr_accessor。

attr_accessor :remember_token

こうするとremember_token属性を仮で作ってくれる。

だから存在しないはずのremeber_tokenに代入することができる。

分かりやすい解説↓

Rubyのattr_accessorって何?[和訳]

Railsから入った人へ【attr_accessor】って?

永続cookiesでガチセッションするRailsチュートリアル9章


どうしてUser.digestやUser.new_tokenにはUser.がつくのか

これも素朴な疑問です。

これらはクラスメソッドと呼ばれるもので、今まで定義してきたインスタンスメソッドとは少々異なります。


  • クラスメソッド → クラスオブジェクトからでしか呼び出せない

  • インスタンスメソッド → インスタンスオブジェクトからでしか呼び出せない

例えばよくこんなことをしますよね。

user = User.new

ここでのuserはインスタンスオブジェクト。

この時、↓できるのがインスタンスメソッド。

user.hoge

今のができず、↓するのがクラスメソッド。

User.hoge


どうしてTutorialでは使い分けているのか

User.digestやUser.new_tokenはユーザーオブジェクトが不要です。

つまり暗号化するだけ、トークンを作るだけの処理にユーザ情報は不要

必要なのは作ってからremember_digestで固有のユーザに代入するときだけ。

(だからrememberはインスタンスメソッドです)

それを明示的に示すためにクラスメソッドを使用したのです。

クラスメソッドはクラスそのものの変更や参照する役割にも使用される。

そのような場合にもクラスメソッドを使用する余地がありそう。

分かりやすい解説↓

【Ruby】クラスメソッドとインスタンスメソッドについてザクッと分かりやすく説明してみる

Rubyのクラスメソッドとインスタンスメソッドの例


クッキーに保存する

続いてIDとトークンをクッキーに保存する。

クッキーを保存するcookiesメソッドがあるので簡単。

クッキーの保存期間を20年にするpermanentメソッド、

IDは暗号化していないので暗号化するsignedメソッドも使用する。

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

cookies.permanent[:remember_token] = user.remember_token

そしてSessions内で保存までを一気に行うメソッドがあると便利。

だからここまで作ってきたメソッドをrememberメソッドとしてSessionsヘルパーに組み込もう。


app/helpers/sessions_helper.rb

module SessionsHelper

def log_in(user)
# 中略

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

def current_user
# 中略


さっき作ったusers.rbに作ったrememberメソッドとは違う。

メソッド内に入っているuser.rememberこそが、user.rbのrememberメソッドです。

たった今作ったSessionsヘルパーのrememberメソッドの方は、


  • remember_tokenとremember_digestと作成

  • IDとremember_tokenとクッキーに保存

これを一気に行うものなのですね。


クッキーのremember_tokenとUserモデル属性のremember_digestを照らし合わせる

じゃあSessionsコントローラに実装しよう。

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
# 中略
end
end

これでひとまず新規ユーザ作成したらクッキーに保存されるようになった。

ただこのままだとcurrent_user(#8参照)がクッキーを参照しない。

そのためには、


  • 一時セッションの場合

  • 永続セッションの場合

これらを分けた上で処理を書く必要がある。

簡潔にまとめていただいている記事があるので、

35歳だけどRailsチュートリアルやってみた。[第4版 9章 9.1 Remember me 機能 まとめ&解答例]から内容をお借りします。


永続セッションの場合

session[:user_id]が存在すれば、一時セッションからユーザーを取得

cookies[:user_id]が存在すれば、永続セッションからユーザーを取得

if (user_id = session[:user_id])

# 一時セッションからユーザーを取り出す
elsif (user_id = cookies[:user_id])
# 永続セッションからユーザーを取り出す
if # ユーザーが存在し、永続セッションの中の記憶トークンがDBの値と一致する
# ログイン処理
end
end

というわけで処理を書いていくわけだけど、


  • 記憶トークンがデータベース内の記憶ダイジェストの値が一致するか

このメソッドがない。

BCryptを参考にして書こう。


app/models/user.rb

class User < ApplicationRecord

# 中略
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end

このメソッドの、

* 引数remember_tokenはあくまでただのローカル変数

* 引数remember_digestはUserモデルの属性(始めにmigrationで生成したもの)

なので混同しないよう注意が必要。

準備ができたのでcurrent_userを編集する。


app/helpers/sessions_helper.rb

module SessionsHelper

# 中略
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
# 中略

これで永続セッションのログインが完了になる。


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

今の状態だと、ログアウトが正しく機能しない。

log_outメソッドが一時セッションにしか対応していないからだ。

実装の手順はこう。


  1. Userモデルにremember_digestを破棄するメソッドを定義

  2. Sessionsヘルパーに実際のログアウトを行うメソッドを定義

というわけで実装しよう。


remember_digestを破棄するメソッドを定義する

remember_digestにnilを代入することでログイン情報を破棄できる。

それをforgetメソッドとして定義。


app/models/user.rb

class User < ApplicationRecord

# 中略
def forget
update_attribute(:remember_digest, nil)
end
end


実際にログアウトを行うメソッドを定義

Sessionsヘルパーのlog_outメソッドに永続セッションを破棄する処理を追加。

その処理にforgetメソッドを新たに定義する。


app/helpers/sessions_helper.rb

module SessionsHelper

# 中略
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
end


Sessionsヘルパーのforgetメソッドもまた、Userモデルのforgetメソッドと異なるので注意。

(Userモデルのforgetメソッドは、Sessionsヘルパーのforgetメソッドの中のuser.forget)

これで一応ログアウトが可能になる。


細かなバグ修正

現状2つのバグが残っている。


  • 複数のタブで開いたサイトからログアウトが二重に行われる時のエラー

  • 複数のブラウザのうち1つはログアウト、もう1つはログアウトせず終了後再び画面を開く時のエラー

まずはバグを検知するテストを書き、その後対応しよう。


複数タブのエラー対処

エラーの理由:

1度目のログアウトでcurrent_userがnilなのにも関わらず、log_outでforget(current_user)しようと試みるから。

よって、ログインしているか確認した上でログアウトするように変更する。


spec/requests/users_logins_spec.rb

# 中略

it "does not log out twice" do
get login_path
post login_path, params: {
session: {
email: user.email,
password: user.password
}
}
expect(is_logged_in?).to be_truthy
follow_redirect!
expect(request.fullpath).to eq '/users/1'
delete logout_path
expect(is_logged_in?).to be_falsey
follow_redirect!
expect(request.fullpath).to eq '/'
delete logout_path
follow_redirect!
expect(request.fullpath).to eq '/'
end
# 中略


app/controllers/sessions_controller.rb

class SessionsController < ApplicationController

# 中略
def destroy
log_out if logged_in?
redirect_to root_url
end
end


複数ブラウザのエラー対処

エラーの理由:

1つ目のブラウザでremember_digestをnilしたのにも関わらず、もう1つのブラウザではクッキーが残っているので、authenticated?に例外が起こるから。

よってauthenticated?内でremember_digestがnilなら即座に認証を終了させる。


spec/models/user_spec.rb

# 中略

describe "User model methods" do
describe "authenticated?" do
it "return false for a user with nil digest" do
expect(user.authenticated?('')).to be_falsey
end
end
end


app/models/user.rb

class User < ApplicationRecord

# 中略
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
# 中略

Rubyの慣習的に、1文で済むif文は処理の後に書く。

よってreturn falseを先に、ifはその後。

これでようやくバグも取り除いた。


次回はリメンバーミー機能を実装

Tutorialは9.2 [Remember me] チェックボックス直前まできた。

次はリメンバーミー機能を実装する。

Tutorialの解説記事になっている?同じ工程だから仕方ない!!

(そのうちポートフォリオ感出てくることを信じて...:relaxed:


追記

[2019年8月17日]

テストを追加しました。

前回:#8 ログイン/ログアウト, FactroyBot編

次回:#10 リメンバーミー機能編