現在独学で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"
defUser.new_tokenSecureRandom.urlsafe_base64end
記憶トークンをハッシュ化してDBに保存するremember
をUserモデルに定義
ハッシュ化にはUser.digest(string)
が使える
defrememberself.remember_token=User.new_tokenupdate_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を返すdefauthenticated?(remember_token)BCrypt::Password.new(remember_digest).is_password?(remember_token)end
引数remember_token
はローカル変数
ログインした際の挙動を変更するremember (user)
を追加
これはUserモデルのrememberメソッドと異なる(引数を取っている)
defcreateuser=User.find_by(email: params[:session][:email].downcase)ifuser&&user.authenticate(params[:session][:password])log_inuserrememberuserredirect_touserelseflash.now[:danger]='Invalid email/password combination'render'new'endend
remember(user)
はヘルパーとして定義
ログインする際に呼び出して、
記憶トークンの生成とcookieへ保存、ダイジェストトークンのDB保存を行っている
(ヘルパーで使い分ける意義、同名のmethodの優先順位が理解不十分)
# ユーザーのセッションを永続的にするdefremember(user)user.remember#Userモデルのメソッド(記憶トークンの生成、ダイジェストトークンのDB保存)cookies.permanent.signed[:user_id]=user.idcookies.permanent[:remember_token]=user.remember_tokenend
current_user
がsessionだけでなく
cookieによっても維持されるようにする
# 記憶トークンcookieに対応するユーザーを返すdefcurrent_userif(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)ifuser&&user.authenticated?(cookies[:remember_token])log_inuser@current_user=userendendend
(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
を定義して
# ユーザーのログイン情報を破棄するdefforgetupdate_attribute(:remember_digest,nil)end
つぎにヘルパーメソッドforget(user)
を定義する
さっきのuser.forget
でDB上の:remember_digest
をnil
ブラウザのcookieをcookies.delete
でnil
defforget(user)user.forgetcookies.delete(:user_id)cookies.delete(:remember_token)end
このヘルパーメソッドforget(user)
を同じヘルパーメソッドのlog_out
で呼び出す
deflog_outforget(current_user)session.delete(:user_id)@current_user=nilend
これで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'classUsersLoginTest<ActionDispatch::IntegrationTest...test"login with valid information followed by logout"dogetlogin_pathpostlogin_path,params: {session: {email: @user.email,password: 'password'}}assertis_logged_in?assert_redirected_to@userfollow_redirect!assert_template'users/show'assert_select"a[href=?]",login_path,count: 0assert_select"a[href=?]",logout_pathassert_select"a[href=?]",user_path(@user)deletelogout_pathassert_notis_logged_in?assert_redirected_toroot_url# ↓ここで再度delete logout_pathをリクエストdeletelogout_pathfollow_redirect!assert_select"a[href=?]",login_pathassert_select"a[href=?]",logout_path,count: 0assert_select"a[href=?]",user_path(@user),count: 0endend
テストを走らせると(RED)
うまくバグを再現できることを確認
ログインしていないときはdelete logout_path
リクエストに対してlog_out
メソッドが呼び出されないようにすれば良い
ログインの確認はlogged_in
メソッドが使える
def logged_in?
!current_user.nil?
end
defdestroylog_outiflogged_in?redirect_toroot_urlend
となる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が残っているので
defcurrent_userif(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)ifuser&&user.authenticated?(cookies[:remember_token])#エラーlog_inuser@current_user=userendendend
if user && user.authenticated?(cookies[:remember_token])
が実行されるが
別のブラウザの挙動によってremember_digest
がnil
になってっているので
defauthenticated?(remember_token)BCrypt::Password.new(remember_digest).is_password?(remember_token)#remember_digest = nilend
cookieの内容と一致しないことによる例外を返してしまう
BCrypt::Errors::InvalidHash: invalid hash
これをテストで検出できるようにするために
ブラウザAの状況をUserモデルのオブジェクトで再現すれば良い
つまりremember_digest = nil
のモデルを用意する
require'test_helper'classUserTest<ActiveSupport::TestCasedefsetup@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"doassert_not@user.authenticated?('')endend
setupメソッドで作った@user
はちょうどそれに該当するのでこれを使用できる
現状remember_digest = nil
であるので@user.authenticated?('')
は例外を返しテストは(RED)
remember_digest = nil
の場合は@user.authenticated?('')
がfalse
を返すようにしたいので
defauthenticated?(remember_token)returnfalseifremember_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:"checkboxinline"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
アクションに以下を加える
ifparams[:session][:remember_me]=='1'remember(user)elseforget(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'...classActiveSupport::TestCasefixtures:all# テストユーザーがログイン中の場合にtrueを返すdefis_logged_in?!session[:user_id].nil?end# テストユーザーとしてログインするdeflog_in_as(user)session[:user_id]=user.idendendclassActionDispatch::IntegrationTest# テストユーザーとしてログインするdeflog_in_as(user,password: 'password',remember_me: '1')postlogin_path,params: {session: {email: user.email,password: password,remember_me: remember_me}}endend
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章
defcreate@user=User.find_by(email: params[:session][:email].downcase)if@user&.authenticate(params[:session][:password])log_in@userparams[:session][:remember_me]=='1'?remember(@user):forget(@user)redirect_to@userelseflash.now[:danger]='Invalid email/password combination'render'new'endend
test"login with remembering"dolog_in_as(@user,remember_me: '1')assert_equalcookies[:remember_token],assigns(:user).remember_tokenend
9.3.2 [Remember me]をテストする
current_userの挙動についてのテスト
assert_equal <expected>, <actual>
で表記する
require'test_helper'classSessionsHelperTest<ActionView::TestCasedefsetup@user=users(:michael)remember(@user)endtest"current_user returns right user when session is nil"doassert_equal@user,current_userassertis_logged_in?endtest"current_user returns nil when remember digest is wrong"do@user.update_attribute(:remember_digest,User.digest(User.new_token))assert_nilcurrent_userendend
9.4 最後に
以下のようにしておくと一時的にアクセスできない間
メンテナンスページを表示することができる
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off