Quantcast
Channel: 初心者タグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 21491

Railsチュートリアルの走り方を変えてみた: Railsチュートリアル備忘録 - 9章

$
0
0

現在独学で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_usernil
  • ログアウト後は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メソッドと異なる(引数を取っている)

app/controllers/sessions_controller.rb
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の優先順位が理解不十分)

app/helpers/sessions_helper.rb
# ユーザーのセッションを永続的にするdefremember(user)user.remember#Userモデルのメソッド(記憶トークンの生成、ダイジェストトークンのDB保存)cookies.permanent.signed[:user_id]=user.idcookies.permanent[:remember_token]=user.remember_tokenend

current_userがsessionだけでなく
cookieによっても維持されるようにする

app/helpers/sessions_helper.rb
# 記憶トークン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を定義して

app/models/user.rb
# ユーザーのログイン情報を破棄するdefforgetupdate_attribute(:remember_digest,nil)end

つぎにヘルパーメソッドforget(user)を定義する
さっきのuser.forgetでDB上の:remember_digestをnil
ブラウザのcookieをcookies.deleteでnil

/sample_app/app/helpers/sessions_helper.rb
 defforget(user)user.forgetcookies.delete(:user_id)cookies.delete(:remember_token)end

このヘルパーメソッドforget(user)を同じヘルパーメソッドのlog_outで呼び出す

/sample_app/app/helpers/sessions_helper.rb
 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_pathcurrent_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をリクエストすれば良いと考えるとすごくシンプル

test/integration/users_login_test.rb
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
app/controllers/sessions_controller.rb
defdestroylog_outiflogged_in?redirect_toroot_urlend

となる
log_out if logged_in?はおしゃれな書き方で
if...endでおきかえることができる

【Ruby】乱用厳禁!?後置ifで書くとかえって読みづらくなるケース
この方は可読性の観点からこのような指摘をされていて参考にしたい

この状態でrails testは(GREEN)

つぎに、
A、B、2つのブラウザを開いており、片方のブラウザBでログアウトし(DBのremember_digestnil)、
続いてブラウザ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_digestnilになってっているので

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のモデルを用意する

test/models/user_test.rb
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]チェックボックス

チェックボックスはヘールパメソッドで挿入可能

/sample_app/app/views/sessions/new.html.erb
<%=f.label:remember_me,class:"checkboxinline"do%><%=f.check_box:remember_me%><span>Remember me on this computer</span><%end%>

CSSを追加

app/assets/stylesheets/custom.scss
.../*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'の値を受け取る
10でなく'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を用いる

test/test_helper.rb
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章

/sample_app/app/controllers/sessions_controller.rb
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
/sample_app/test/integration/users_login_test.rb
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>で表記する

/sample_app/test/helpers/sessions_helper_test.rb
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

Viewing all articles
Browse latest Browse all 21491

Latest Images

Trending Articles