LoginSignup
0
0

More than 3 years have passed since last update.

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

Posted at

現在独学で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"
  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メソッドと異なる(引数を取っている)

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

app/helpers/sessions_helper.rb
  # ユーザーのセッションを永続的にする
  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によっても維持されるようにする

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

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

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

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

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

/sample_app/app/helpers/sessions_helper.rb
 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_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'

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
app/controllers/sessions_controller.rb
  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_digestnil)、
続いてブラウザ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_digestnilになってっているので

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

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

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

/sample_app/app/views/sessions/new.html.erb
      <%= f.label :remember_me, class: "checkbox inline" 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アクションに以下を加える

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を用いる

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

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0