現在独学でRailsチュートリアル1周目ですが
2周目に挑戦するのであれば
テスト駆動開発(TDD)を試したいと思い
各章のテーマのなかで実装されるべき要件をリストアップしておくことにした
2週目でイメージしているタスクフローとして
要件定義(1週目でここを残す) > テストを書く(2週目のここに繋げる) > 実装
(現実とかけ離れていたらどなたか早めにご指摘ください)
目標: より長期間、かつ安全にログイン状態を維持できるようにする
この章の気付き
- ログイン情報を維持する仕組みにはブラウザに保存されたcookieが使用されている
- Railsの変数が持つ、有効範囲(スコープを)体感できた(ローカル変数とインスタンス変数)
-
assigns(:user)
とすることで直前のインスタンス変数@userにアクセスできるようになる - 敢えて、コードに例外を発生させる
raise
を含ませることで テストに内包されているかどうか確認できる -
assert_equal
は<expected>, <actual>
で表記する - Herokuに一時的にアクセスできない場合にメンテナンスページを表示する方法がある
heroku maintenance:on (off)
本章の内容と関係ないが
学習の質向上のためアウトプットをテンプレート化し
圧倒的に量を増やした(iuput : output = 2:8くらい)
進行速度は大幅に低下したが学びの質は上がった気がする
時間効率と学習効果のバランスが難しい
少しでも時間効率を上げるためにmac用markdownエディタを導入した
必要要件
ログインする際にブラウザを閉じても維持されるcookieを生成する
- ユーザーIDと記憶トークンをcookieに保存する
- 永続化するcookieとする
- ユーザーIDは暗号化して保存する(署名)
- 記憶トークンとしてランダムな文字列を用いる
- トークンはハッシュ値に変換してからデータベースに保存する(ダイジェスト記憶)
- チェックボックスを用いてログインの維持を選択できるようにする
ブラウザを閉じた(current_user = nil
)場合にcookieと一致するDB上のユーザでログインを維持する
-
current_user = nil
でない場合以下の処理は不要 - cookieに保存されたユーザIDでDBを検索、一致するユーザー(user)を抽出
- cookieの記憶トークンをハッシュ化したものと、userのダイジェスト記憶(DB)が一致することを確認
- userでログインする(
current_user = nil
でなくなる)
正常に完全なログアウトが可能
- ログアウトするとsessionが
nil
かつ、記憶トークン(cookie)およびダイジェスト記憶(DB)がnil
とする - ログアウトすると
current_user
がnil
- ログアウト後はroot_urlにリダイレクト(実装済み)
備忘録: 第9章発展的なログイン機構
9.1 Remember me 機能
9.1.1 記憶トークンと暗号化
cookieが曝されるリスクと対策すべき内容
管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
> 対策: SSL化(対応済み)
データベースから記憶トークンを取り出す
> 対策:DBに保存されるトークンをハッシュ化する
ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る
> 対策: ログアウトした際にトークンを変更する
(Railsチュートリアル 第6版)
remember_digest
属性をUserモデルに追加
$ rails generate migration add_remember_digest_to_users remember_digest:string
$ rails db:migrate
Userモデルでremember_token
属性を扱えるようにしたいが
この属性はDBには保存しない
> attr_accessor
を用いる
attr_accessorが必要な理由と、不要な理由(Railsさんありがとう): Railsチュートリアル備忘録 - 9章
UserモデルにUser.new_token
を定義(記憶トークンを返す)
記憶トークンの生成にはSecureRandom.urlsafe_base64
が適する
$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"
def User.new_token
SecureRandom.urlsafe_base64
end
記憶トークンをハッシュ化してDBに保存する
remember
をUserモデルに定義
ハッシュ化にはUser.digest(string)
が使える
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
self.
は必要
あとはログイン時にrememberが実行されるようにしたい
それはこの後
###9.1.2 ログイン状態の保持
ユーザーIDをブラウザに保存したい
署名つきcookieを用いるsigned
cookies.signed[:user_id] = user.id
cookies.signed[:user_id]
で暗号解除される
cookieの永続化はpermanent
で可能
メソッドチェーンで
cookies.permanent.signed[:user_id] = user.id
Userモデルにauthenticated?
を定義
passwordのときと挙動は同じでイメージできるけどBCrypt...の部分理解不十分
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
引数remember_token
はローカル変数
ログインした際の挙動を変更する
remember (user)
を追加
これはUserモデルのrememberメソッドと異なる(引数を取っている)
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
remember(user)
はヘルパーとして定義
ログインする際に呼び出して、
記憶トークンの生成とcookieへ保存、ダイジェストトークンのDB保存を行っている
(ヘルパーで使い分ける意義、同名のmethodの優先順位が理解不十分)
# ユーザーのセッションを永続的にする
def remember(user)
user.remember#Userモデルのメソッド(記憶トークンの生成、ダイジェストトークンのDB保存)
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
current_user
がsessionだけでなく
cookieによっても維持されるようにする
# 記憶トークン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
(user_id = session[:user_id])
で繰り返しの省略が可能
=
は論理演算ではない、代入
この時点でrails test
は(RED)
FAIL["test_login_with_valid_information_followed_by_logout", #<Minitest::Reporters::Suite:0x0000556848d6b040 @name="UsersLoginTest">, 1.7997455329999923]
test_login_with_valid_information_followed_by_logout#UsersLoginTest (1.80s)
Expected at least 1 element matching "a[href="/login"]", found 0..
Expected 0 to be >= 1.
test/integration/users_login_test.rb:36:in `block in <class:UsersLoginTest>'
loginへのリンクが表示されていない
つまりログアウトできていないものと思われる
(cookieによってcurrent_userが維持されていると予想)
###9.1.3 ユーザーを忘れる
ログアウトする際にDBのremember_digest
と
ブラウザのcookieを削除する(nilで更新)
まずはDBを操作するforget
を定義して
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
つぎにヘルパーメソッドforget(user)
を定義する
さっきのuser.forget
でDB上の:remember_digest
をnil
ブラウザのcookieをcookies.delete
でnil
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
このヘルパーメソッドforget(user)
を同じヘルパーメソッドのlog_out
で呼び出す
def log_out
forget(current_user)
session.delete(:user_id)
@current_user = nil
end
これでlog_out
はsessionとcookieのいずれも空にできるので
完全なログアウトが可能
rails test
は(GREEN)
###9.1.4 2つの目立たないバグ
複数のタブやブラウザを使用した際に生じるバグを解決する
まず、ログインした状態の複数のタブを同時に開いておけば、
Logout(delete logout_path
)を複数回踏むことができることから生じる問題
1回目のdelete logout_path
でcurrent_user = nil
となるので
再度delete logout_path
をリクエストすると、コントローラーはもう一度log_out
メソッドを呼び出し
forget(current_user)
を実行しようとしたところでcurrent_user = nil
なのでエラーとなる
NoMethodError: undefined method `forget' for nil:NilClass
app/helpers/sessions_helper.rb:36:in `forget'
app/helpers/sessions_helper.rb:24:in `log_out'
app/controllers/sessions_controller.rb:20:in `destroy'
これをテストで検出できるようにするには
再度delete logout_path
をリクエストすれば良いと考えるとすごくシンプル
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
# ↓ここで再度delete logout_pathをリクエスト
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
テストを走らせると(RED)
うまくバグを再現できることを確認
ログインしていないときは
delete logout_path
リクエストに対して
log_out
メソッドが呼び出されないようにすれば良い
ログインの確認はlogged_in
メソッドが使える
def logged_in?
!current_user.nil?
end
def destroy
log_out if logged_in?
redirect_to root_url
end
となる
log_out if logged_in?
はおしゃれな書き方で
if...end
でおきかえることができる
【Ruby】乱用厳禁!?後置ifで書くとかえって読みづらくなるケース
この方は可読性の観点からこのような指摘をされていて参考にしたい
この状態でrails test
は(GREEN)
つぎに、
A、B、2つのブラウザを開いており、片方のブラウザBでログアウトし(DBのremember_digest
はnil
)、
続いてブラウザAを閉じると(ブラウザAのsession[:user_id]はnil
)
ブラウザAに保存されたcookieのみが残ることによって生じる問題
この状態でブラウザAを再度開いた場合,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]) #ここでtrueになる
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?(cookies[:remember_token])
が実行されるが
別のブラウザの挙動によってremember_digest
がnil
になってっているので
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)#remember_digest = nil
end
cookieの内容と一致しないことによる例外を返してしまう
BCrypt::Errors::InvalidHash: invalid hash
これをテストで検出できるようにするために
ブラウザAの状況をUserモデルのオブジェクトで再現すれば良い
つまりremember_digest = nil
のモデルを用意する
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
setupメソッドで作った@user
はちょうどそれに該当するのでこれを使用できる
現状remember_digest = nil
であるので
@user.authenticated?('')
は例外を返しテストは(RED)
remember_digest = nil
の場合は
@user.authenticated?('')
がfalse
を返すようにしたいので
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
とすればいい
ProgateだとIf文の最後に使うことが多かったので気づかないが
return
はそこでメソッドを終了し値を返す
だからそれ以下は実行されない
これでテストは(GREEN)
9.2 [Remember me]チェックボックス
チェックボックスはヘールパメソッドで挿入可能
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
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[:session][:remember_me]
が
オンなら'1'
オフなら'0'
の値を受け取る
1
か0
でなく'1'
か'0'
Sessionsコントローラのcreate
アクションに以下を加える
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
上記は以下のように書き換えられる(3項演算子)
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
ここまでで永続的なログイン機構の完成
9.3 [Remember me]のテスト
9.3.1 [Remember me]ボックスをテストする
テスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義する
単体テスト、統合テストそれぞれで使用できるように
class ActiveSupport::TestCase,
class ActionDispatch::IntegrationTestのそれぞれで
log_in_asメソッドを定義
統合テストではsession
を直接扱えないとの理由から
統合テスト用のlog_in_as
メソッドではpost login_path
を用いる
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
rails test
(GREEN)
9.3.1 演習 :
2つの問題を含んでいるようにおもう
統合テストではコントローラーで定義したインスタンス変数にアクセスできない
>assigns(:user)
とすることで直前のインスタンス変数@userにアクセスできるようになる
そもそもSessionsコントローラのcreateメソッドでローカル変数user
が用いられており
インスタンス変数(@user
)が定義されていない
> Sessionsコントローラのcreateメソッドで*インスタンス変数(@user
)を定義する
前提として統合テストにおけのsetup
メソッドで定義された@user
は
remember_token属性を含まない
attr_accessorが必要な理由と、不要な理由(Railsさんありがとう): Railsチュートリアル備忘録 - 9章
def create
@user = User.find_by(email: params[:session][:email].downcase)
if @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
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_equal cookies[:remember_token], assigns(:user).remember_token
end
9.3.2 [Remember me]をテストする
current_userの挙動についてのテスト
assert_equal <expected>, <actual>
で表記する
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
9.4 最後に
以下のようにしておくと一時的にアクセスできない間
メンテナンスページを表示することができる
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off