###8章補足。
sessionを変数と考えていたが、正確には
sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できるということである。
#発展的なログイン機構
sessionだけを使ったログインだとサーバーやブラウザを閉じてしまうと、またログインが必要となる。そこを改善できないものだろうか。
ベターなのは、ユーザーの意思で、期限のあるセッションか、永続的なセッションかを選べるようにするといい。
###cookiesとsessionの違い。
###超わかりやすい説明。
cookiesは診察券、session idは整理番号と考えるとわかりやすい。
cookiesはクライアント側に保存されるので、その情報から、以前何を買ったとかショッピングカートに入れたとかがわかる。
session idもcookieに保存されるのだが、session idはブラウザとサーバーの通信状態を呼ぶから、
別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまう。
なので、たとえcookieからsessionidを盗み出せたとしても、別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまうという理由から、そんなsession idはそもそもハッシュに保存されていませんよーとなってしまう。
ただ、cookieは診察券みたいなものなので、「あ、前回はこの病気を見てもらったんですねー」というようなことができる。
以上!!!!!!!!!!!!!!!!!!!!!!
sessionはサーバとブラウザが相互にやり取りをして、どちらかが切れたら終了というものだった。
###cookieの実装方法
cookieはクライアント側に記憶トークン(rememberトークンともいう)を付与し、それをパスワードダイジェストのように、ハッシュ化したものをDBに保存する。
そのため、まずは記憶トークンをハッシュ化したものを保存する場所をDBに作っていこう。
$ rails generate migration add_remember_digest_to_users remember_digest:string
ここでも、rails g migration add_追加するカラム名_to_テーブル名 カラム名:データ型
という風に指定してあげると、rails側で以下のようなファイルを勝手に作ってくれる。
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :remember_digest, :string
end
end
これを確認したら rails db:migrate
###記憶トークンに使われるランダムな文字列をどうやって作るか?
Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドなら、この用途にぴったり合いそうです3。このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返します (64種類なのでbase64と呼ばれています)。典型的なbase64の文字列は、次のようなものです。
$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"
SecureRandomクラスのクラスメソッドってことだよね。
この文字列をクライアント側に送って、さらに、この文字列をハッシュ化したものをDBに保存する。
###トークン生成用のメソッドの定義
class User < ApplicationRecord
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
end
ランダムなトークンを作るためだけに、インスタンスを生成するのは勿体無い。
なので、user.rbにクラスメソッドとして定義すれば良い。
ただ、今の状態だと、記憶トークンdigestを参照することができるが、記憶トークンの平文を参照することができない。
記憶トークンの平文は、password_digest実装時のpasswordやpassword_confirmationのような仮想的な属性である。
これを実装するにはどうすればいいだろうか?
実は上記のような仮想的な属性はゲッター、セッターの実装ができる。一時的に保存できるがDBに保存はされない。
これは、
attr_accessor :remember_token
とすることで、自動的にゲッターとセッターを実装してくれる。
言い換えると、attr_accessorはメソッドを定義するメソッドと言える。
###attr_accessor :remember_tokenを実装
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
end
次は上記の最後に定義されているrememberメソッドを見ていこう
rememberメソッドはユーザーがチェックボックスにチェックを入れてログインをした。
その時に呼び出されるメソッドである。
ここで注意点
rememberはインスタンスメソッド。これが呼び出されてる時は必ず呼び出し元がいるということ。
で、self.remember_tokenのselfには rememberメソッドの呼び出し元が代入される。
###selfの省略について
update_attributeはself.update_attributeの省略形である。
しかし、一つ前のself.remember_tokenのselfは省略してはいけない。
どのようなルールがあるのだろうか?
省略してはいけないのは、
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
1文目は、selfを省略してしまうと、remember_tokenというローカル変数にUser.new_tokenを代入するという意味になってしまう。
つまり、代入文であり、代入文の左辺だった時はselfが必要。
update_attributeはメソッド(インスタンスメソッド)であることが明白なので、selfを省略してもOK
次は、
###cookieからsessionの状態を復元する機能を実装しよう。
ログインした時にはユーザー自身が入力したemailからユーザーインスタンスをfindし、入力したパスワードを元に@user.authenticateをして認証することができた。
しかし、cookieの場合は、emailが存在しないので、どうやってユーザーインスタンスを引っ張ってくるかが課題になる。
これを解決するために、署名付きユーザーidというものを使う。
これは、cookieを送る時に、@user.idを暗号化したものを一緒に送る。
これを、元の@user.idに復号化してあげて、
そこから得たuser.idを使って、find_byしてインスタンスを引っ張ってくる。
で、authenticateメソッドはパスワードを比較するためのメソッドなので、記憶トークンには使えない。
なので、自分で定義する必要がある。
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
rememberメソッドをsession_controllerのcreateアクションに実装する。
class SessionsController < ApplicationController
def new
end
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
def destroy
log_out
redirect_to root_url
end
end
これは、ユーザーがメールとパスワードを入れてログインしたら、記憶トークンをハッシュ化したものをDBに保存するよーって処理。
ただ、ここでremember userというように引数を取っていることにお気づきだろうか?
実はこのメソッドはsession_helperに定義された別のメソッドだったのだ!!!
実はこのremember(user)メソッドで記憶トークンをクライアント側に送るなどの処理もしている。
このメソッドを定義していこう
module SessionsHelper
# 渡されたユーザーでログインする
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
# 現在ログインしているユーザーを返す (いる場合)
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
sessionメソッドと同様、:user_idをキーにして、user.idを代入できる。
この場合、ユーザーIDが生のテキストとしてcookieに保存される。
署名付きcookieを使うためには、cookies.signedメソッドを使用する。
cookieをブラウザに保存する前に暗号化を行う。
###じゃあ、結局どうやってsession状態を復元するんだよ。
current_userメソッドを変える。
module SessionsHelper
# 渡されたユーザーでログインする
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
# 記憶トークン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
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
簡単にいうと、
sessionでログインできればそれでログインし、
できなければ、cookieを使ってログインしてというメソッド。
if (user_id = session[:user_id])は
user_idにsession[:user_id]を代入した結果、値が存在すればという条件式になる。
elsif (user_id = cookies.signed[:user_id])
また、このコードのsignedは暗号化された文字列を復号化する役割がある。
つまり、signedは暗号化もできるし、復号化することもできる。
ちなみに
if user && user.authenticated?(cookies[:remember_token])
のcookies[:remember_token]は、クライアント側に保存されているもの。
つまり、DBに保存されているユーザーインスタンスの記憶トークンのハッシュ化された値と、クライアント側に保存されている記憶トークンをハッシュ化したものを比較してくれている。
もし、if文もelsif文も失敗したら、nilが返ってくるという仕様。
そのため、logged_in?メソッドをそのまま使える。
この時点でテストは失敗している。
###ユーザーを忘れる。
これはrememberメソッド、remember(user)メソッドの全く逆のことを実装すれば良い
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
これはrememberメソッドと対になるメソッド。
DBの値をnilにしたので、
次は焼いたクッキーを消すメソッドを実装しよう。
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
.
.
.
# 永続的セッションを破棄する
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
forget(user)はremember(user)の対になるメソッド。
また、log_outメソッドに forget(current_user)を追加しないと、cookieを使ってまたログインできてしまう。
さっきテストで失敗してしまったのは、ログアウトしたらログアウトパスが本来0個のはずなのに、cookieをつかったログインが成功してしまい、1つ発見されてしまったからだろう。
実はこれだけじゃあ実装は終わらないぜ
###目立たないバグ潰し
二つのログイン済みのタブがあり、どちらか一方をログアウトさせ、もう片方もログアウトさせようとするとエラーが起こる。
これは1回目でcurrent_userがなくなり、2回目で、nilにforgetメソッドを呼び出そうとしているため、NoMethodErrorが起こってしまうからだ。
これを解決するには、log_outメソッドを使えるのはlog_inしている時のみという条件をつける。
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
これで1つ目のバグは解決。
2つ目のバグを解決しよう!!!
###cookieの暴走問題
1つはSafari、もう1つはChromeでログインする。
そうするとどちらにもcookieが付与された状態になる。
これで、Chromeのタブでログアウトを実行。
さらに、Safariのタブを消してしまう。
それで、Safariでもう一度アプリのページを開こうとするとエラーになってしまう。
これは、Chromeのログアウトの時点でDBのremember_digestはnilになっている。
そしてSafariのcookie情報でcurrent_userを見つけようとするもDBの値がnilになっているので見つけられず、例外を出してエラーを出すようになってしまうかららしい。bcryptによるもの。
じゃあ、どうやって解決する?
まずは回帰バグを防ぐためにテストコードを書こう。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
.
.
.
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
end
最後のテストは、authenticated?()メソッドの引数にnilや空文字を入れたらfalseを返すでしょ?というテストである。ちなみにcookieの暴走バグはfalseすら返さず例外を出しているために起きている。
テスト結果は以下。
ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.47229603000005227]
test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.47s)
BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hash
app/models/user.rb:32:in `new'
app/models/user.rb:32:in `authenticated?'
test/models/user_test.rb:70:in `block in <class:UserTest>'
21/21: [===========================================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.57426s
21 tests, 50 assertions, 0 failures, 1 errors, 0 skips
ちなみに、failuresは期待された値にならなかった時。
errorsは期待された値とか関係なく、例外などが出た時に表示される。
###cookieの暴走バグ解決法
これの解決方法は、remember_digestがnilの時はbcryptを実行せずに、falseを返してあげれば良い
class User < ApplicationRecord
.
.
.
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
これで、cookieの暴走バグを解決できる。
returnを実行されると、それがメソッドの戻り値になるので、以降のメソッド処理は実行されなくなる。
###チェックボックスの実装
まずはチェックボックスをログインフォームに実装しよう
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
チェックボックスを実装すると、
params[:session][:remember_me]
の値が、チェックされてる時は'1'
チェックされてない時は'0'となる。
これを利用していこう。
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
このように条件分岐していけばいいのだが、
これを三項演算子を使うとスマートに実装することができる。
class SessionsController < ApplicationController
def new
end
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
def destroy
log_out if logged_in?
redirect_to root_url
end
end
基本的にチェックボックスにチェックをつけないとcookieは焼かれないが、一応万全を期すためにforget()メソッドを呼び出している。
###Remember meのテストを書こう。
まずテスト環境でログインしたユーザーを作るためにtest_helperにメソッドを定義していく
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
# テストユーザーとしてログインする
def log_in_as(user)
session[:user_id] = user.id
end
end
class ActionDispatch::IntegrationTest
# テストユーザーとしてログインする
def log_in_as(user, password: 'password', remember_me: '1')
post login_path, params: { session: { email: user.email,
password: password,
remember_me: remember_me } }
end
end
下のクラス定義されてるやつはなんぞや??
統合テストは基本的にブラウザでできることができるようなテスト、
だからこのようにいちいち情報を入力してログインしてもらう必要がある。
そのためメソッドを分けて定義している
つまり、上のlog_in_asはケーステスト用のメソッド。
下のlog_in_asは統合テスト用のメソッドとなっている。
ちなみに password: 'password'と remember_me: '1'はデフォルト値として設定されている。
###チェックボックスの統合テストをしよう
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_not_empty cookies['remember_token']
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
end
ここではテスト通る。
###raiseを理解する。
raiseはテストの途中で例外を発生させる機能。
module SessionsHelper
.
.
.
# 記憶トークン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])
raise # テストがパスすれば、この部分がテストされていないことがわかる
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
.
.
.
end
つまり、テストがパスしてしまうと、raise以下のテストが実行されていないことがわかる。
これは問題。
解決するには、raise以下の使うテストを追加してあげれば良い
本当にここテストされてるかなー?って思ったらraiseを使ってみよう
###メンテナンスモードについて
開発者側からはアプリに入れてクライアント側からは入れなくしたい時は、メンテナンスモードをonにする。
heroku maintenance:on
これを解除するには、
$ heroku maintenance:off