#9章 目標
8章でおこなった基本的なログイン機構にremember me機能の追加を永続クッキーを生成して目指す
永続的セッションシステムの構築を目指す
##セッションの期限切れはいつ?
扱うサイトによる。
例えば振込みは等重要などでは数分
銀行口座サイト等機密情報を扱う場合はブラウザごとかもしれないし
SNSなんかはとりあえずずっと(任意)、、、なんて
#9.1 Remamber me
本節では、ユーザーのログイン状態をブラウザを閉じた後でも有効にする [remember me] 機能を実装していきます。この機能を使うと、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができる
$git checkout -b advanced-login
でブランチをきって進めて行こう
##9.1.1 記憶トークンと暗号化
セッションの永続化の第一歩として記憶トークン (remember token) を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト (remember digest) によるトークン認証にこの記憶トークンを活用します
◎トークン...「トークン」とは、パスワードの平文と同じような秘匿されるべき情報を指します。パスワードとトークンとの一般的な違いは、パスワードは使用者が自身で作成・管理する情報であるのに対し、トークンはコンピューターなどが生成した情報である点です。
手順としては以下の様に進めて行く。
1、記憶トークンにはランダムな文字列を生成して用いる。
2、ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
3、トークンはハッシュ値に変換してからデータベースに保存する。
4、ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
5、永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
と、いうことで記憶トークンをハッシュ化した値を保存する場所remember_digestカラムを追加、マイグレーションする
$ rails generate migration add_remember_digest_to_users remember_digest:string
#トークンはユーザーがいじるものじゃない(かついじられてはいけない)から完成したファイルに特にインデックスを追加するなどはないのでそのまま
$ rails db:migrate
①、トークン生成メソッドを追加して
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
モデルにクラスメソッドとして定義
ここのSecureRandom.urlsafe_base64とはSecureRandomモジュールにあるurlsafe_base64メソッド(ランダムな文字64種から22字の字列を作成)
続いてuser.rememberメソッドを作成していく
機能としては
③記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存
マイグレーションファイルを作成した際にremember_digestカラムは作成しましたが、remember_token(記憶トークン)カラムは作成していません。なので、いつものようにいきなりuser.remember_tokenとはできないのでattr_accessorメソッドでremember_tokenを宣言し、Userインスタンスに値を持たせる際には暗号化しremember_digestとします。
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
注目1-マイグレーションを行ってあるので、Userモデルには既にremember_digest属性が追加されていますが、remember_token属性はまだ追加されていません。user.remember_token
でトークンにアクセス出来るように、かつトークンをデータベースに保存せずに実装する必要があるためattr_accessorを使ってアクセス可能な属性を作成している
注目2-selfを付けないとremember_tokenがローカル変数になってしまうただの変数代入になってしまうのがRuby。今欲しいのはローカル変数ではありません。selfキーワードを与えると、この代入によってユーザーのremember_token属性が期待どおりに設定されます
rememberメソッド の2行目でremember_digestをバリデーションを通さず更新している点も注目
引用:https://www.slideshare.net/yasulab/rails-9-68247055
勉強していてイメージがつきずらかったところで上の画像にはたすけられました
##9.1.2 ログイン状態の保持
user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができました
②cookiesメソッドは、sessionのときと同様にハッシュとして扱えます。
個別のcookiesは、1つのvalue (値) と、オプションのexpires (有効期限) からできています
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
ただし20年期限は定番化しているので
cookies.permanent[:remember_token] = remember_token
parmanentメソッドで省略できる
④続いてユーザーIDを取得する為にsignedメソッド(暗号化させる)を使う
cookies.signed[:user_id] = user.id
これをメソッドチェーンでつないで
cookies.permanent.signed[:user_id] = user.id
cookiesを設定すると、以後のページのビューでこのようにcookiesからユーザーを取り出せるようになります。
User.find_by(id: cookies.signed[:user_id])
cookies.signed[:user_id]では自動的にユーザーIDのcookiesの暗号が解除され、元に戻ります(便利!!)
最後に渡されたトークンがユーザーの記憶ダイジェストと一致することを確認します
BCrypt::Password.new(remember_digest) == remember_token
このコードをじっくり調べてみると、実に奇妙なつくりになっています。bcryptで暗号化されたパスワードを、トークンと直接比較しています。ということは、==で比較する際にダイジェストを復号化しているのでしょうか。しかし、bcryptのハッシュは復号化できないはずなので、復号化しているはずはありません。そこでbcrypt gemのソースコードを詳しく調べてみると、なんと、比較に使っている==演算子が再定義されています。実際の比較をコードで表すと、次のようになっています。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では、==の代わりにis_password?という論理値メソッドが使われています
⑤これを記憶トークンと記憶ダイジェストを比較するauthenticated?メソッドとして定義して
まとめると
class User < ApplicationRecord
・
・
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
このメソッドのremember_tokenはローカルメソッドである
remember_digestはUsersカラムの属性である。 まちがえないように
セッションヘルパーに
userモデル定義のrememberメソッドを呼びだして記憶トークンを作成→データベースにダイジェストを記録→その語cookiesに引数で値(ID)とオプションの期限を与える
といった流れのrememberメソッドを定義
このrememberヘルパーメソッドはsessionsコントローラーのヘルパーメソッドであり、先ほどモデルで定義したuser.rememberメソッドとは違う点に注意してください。実際に、user.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
@current_user ||= User.find_by(id: session[:user_id])
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
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
しかし今のままであると
# 現在ログインしているユーザーを返す (いる場合)
def current_user
@current_user ||= User.find_by(id: session[:user_id])
end
current_userメソッドでは一時セッションしか扱っていないので、このままでは正常に動作しません。
永続セッションの場合は、session[:user_id]が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]からユーザーを取り出して、対応する永続セッションにログインする必要があります。書き換えると
# 記憶トークンcookieに対応するユーザーを返す
def current_user
if (user_id = session[:user_id]) #<--このコードを言葉で表すと、
#「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザー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
となる
##9.1.3 ユーザーを忘れる
記憶と逆を行って行きます
user.forgetメソッドによって、user.rememberが取り消されます。
具体的には、記憶ダイジェストをnilで更新します
class User < ApplicationRecord
・
・
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
同じ流れです。
Forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出します
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.forgetを呼んでからuser_idとremember_tokenのcookiesを削除していることがわかります。
##2つの必然的発生のバグ
実は小さなバグが2つ残っていて、この2つのバグは互いに強く関連しています。
1つ目のバグ
ユーザーは場合によっては、同じサイトを複数のタブ (あるいはウィンドウ) で開いているとき1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまう。
1つ目のタブで "Log out" リンクをクリックすると、current_userがnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうから。
2番目のバグ
ユーザーが複数のブラウザ (FirefoxやChromeなど) でログインしていたときに起こります。例えば、Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くと、ログイン出来る問題が発生します
chromeブラウザのセッションはブラウザを閉じた時に期限切れになるがcookiesが中にのこりつずけているためである
引用:同上
テスト駆動開発は、この種の地味なバグ修正にはうってつけです。そこで、2つのエラーをキャッチするテストから書いていくことにしましょう。
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
.
.
.
test "login with valid information followed by logout" do
get login_path
post login_path, params: { session: { email: @user.email,
password: 'password' } }
assert is_logged_in?
assert_redirected_to @user
follow_redirect!
assert_template 'users/show'
assert_select "a[href=?]", login_path, count: 0
assert_select "a[href=?]", logout_path
assert_select "a[href=?]", user_path(@user)
delete logout_path
assert_not is_logged_in?
assert_redirected_to root_url
# 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
delete logout_path
follow_redirect!
assert_select "a[href=?]", login_path
assert_select "a[href=?]", logout_path, count: 0
assert_select "a[href=?]", user_path(@user), count: 0
end
end
このテストを成功させます。具体的にはリスト 9.16のコードで、logged_in?がtrueの場合に限ってlog_outを呼び出すように変更します。
ログイン中の場合のみログアウトする green
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
続いて2番目の問題について。記憶ダイジェストを持たないユーザーを用意し (setupメソッドで定義した@userインスタンス変数ではtrueになります)、続いてauthenticated?を呼び出します
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
このテストを greenにするためには、記憶ダイジェストがnilの場合にfalseを返すようにすれば良さそうです (リスト 9.19)。
authenticated?を更新して、ダイジェストが存在しない場合に対応 green
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
#9.2 ここまででプロ仕様のログイン機能の構築は完了なのでremember me チェックボックスを作成実装していく。
チェックボックスは、他のラベル、テキストフィールド、パスワードフィールド、送信ボタンと同様にヘルパーメソッドで作成できます。ただし、チェックボックスが正常に動作するためには、次のようにラベルの内側に配置する必要があります。
app/views/sessions/new.html.erb
<% 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>
該当するラベルに2つのCSSクラスcheckboxとinlineを使っています。
Bootstrapではこれらをチェックボックスとテキスト「Remember me on this computer”」として同じ行に配置します。その為タグを使用してインラインで埋め込んでいる。
スタイルを整えるため、もう少しCSSルールを追加します
.
.
.
/* forms */
.
.
.
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
フォームが作成完了なので機能を持たせていきます
チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにします。
実はParamsハッシュには既にチェックボックスの値が含まれています
ログインに無効な情報を送ればデバック情報からも確認できる。
簡潔にいうと
params[:session][:remember_me]
この上の値がチェックボックスがオンのときに’1’になり、オフのときに’0’になります。
これを利用してif文でまとめると
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
###三項演算子 (ternary operator)
ここで役にたつのが三項演算子で、上のif文を一行にまとめられるというもの
if boolean?
何かをする
else
別のことをする
end
Rubyや他の言語 (C/C++、Perl、PHP、Javaなど) では、上のようなフローをよりコンパクトな三項演算子 (ternary operator) と呼ばれる表現で置き換えることができます (3つの部分から構成されるためそのように呼ばれます)。
論理値? ? 何かをする : 別のことをする
今回のtutorialに置き換えると
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
となる。
8章でやったUser.digestメソッドのなかでもコストパラメータの部分でつかわれていた
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
コストパラメータをテスト中は最小にし、本番環境ではしっかりと計算する
といったものでした
ミスしやすいポイントとして、
params[:session][:remember_me] ? remember(user) : forget(user)
params[:session][:remember_me]の値は’0’または’1’のいずれかになりますが、そこに罠があり、0も1もRubyの論理値ではtrueであることを思い出してください。したがって、値は常にtrueになってしまい、チェックボックスは常にオンになっているのと同じ動作になってしまうのです
##9.3 チェックボックスのテスト
###9.3.1
テストを書いていくにあたって先ず最初の手順にログインが必要なので
こちらのヘルパーメソッドを作成する事からしていきます
8章では、postメソッドと有効なsessionハッシュを使ってログインしましたが、毎回このようなことをするのは面倒です。そこで、log_in_asというヘルパーメソッドを作成してテスト用の特別なログインができるようにし、無駄な繰り返しを排除します
単体テストか統合テストかを意識せずに、ログイン済みの状態をテストしたいときはlog_in_asメソッドをただ呼び出せば良い、というものにしたいので同じメソッド名でそれぞれ必要な場所に定義
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<<--注目1
# テストユーザーとしてログインする
def log_in_as(user, password: 'password', remember_me: '1')<--注目2
post login_path, params: { session: { email: user.email,
password: password,
remember_me: remember_me } }
end
end
注目1・・・統合テストの場合はsessionを直接扱う事はできないので8章と同じ手法でのログインになります
注目2・・・キーワード引数の値はそれぞれデフォルトとしてpassword , 1をあたえている
注目2の引数の部分に1か0を渡す事で2つのテストを作成します。チェックボックスがオンになっている場合とオフになっている場合のテストです
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']<--注目1
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
注目1--ここでシンボルではなく文字列としてcookiesに引数を渡している
###assign メソッド
統合テストからは仮想属性であるremember_tokenはいじれないので上のように文字列で渡している。(カラムで属性があるのではなく、メソッドで無理やり作りだしたもの)
しかしRuby的ではないコードなのでこれを修正したい。
コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。
例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。
このtutorialではcreateアクションではuserをローカル変数で使っていたのでこれをインスタンス変数に変更すればテスト側にも引っ張ってこれるはず!
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 #↑↑このへんのuserすべてに@をつけた↑↑
def destroy
log_out if logged_in?
redirect_to root_url
end
end
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_equal cookies[:remember_token], assigns(:user).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
##9.2.3 テスト 、 テスト漏れの確認
9.1.2では、それまでの節で実装した永続的セッションが動作するかどうかを手動で確認していました。しかし実は、current_user内のある複雑な分岐処理については、これまでまったくテストが行われていないのです。
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
例外分子がいるのにも関わらずテストはgreen
ということはテスト自体がこのraiseの上段階で終わっていて以降をチェックしていないということ!
こちらをテストしたいがこのcurrent_userメソッド、今さっき定義したlog_in_asメソッドでuser_id = session[:user_id]と定義してしまったため統合テストは難しくなってしまった。
そこでSessionsヘルパーのテストでcurrent_userを直接テストすれば、この制約を突破することができます。
$ touch test/helpers/sessions_helper_test.rb
手順は、
1、fixtureでuser変数を定義する
2、渡されたユーザーをrememberメソッドで記憶する
3、current_userが、渡されたユーザーと同じであることを確認します
require 'test_helper'
class SessionsHelperTest < ActionView::TestCase
def setup
@user = users(:michael)
remember(@user)
end
test "current_user returns right user when session is nil" do
assert_equal @user, current_user
assert is_logged_in?
end
test "current_user returns nil when remember digest is wrong" do
@user.update_attribute(:remember_digest, User.digest(User.new_token))
assert_nil current_user
end
end
これでredになりraiseを外すとgreen