認証と認可
ウェブアプリケーションの文脈において、「認証(authentication)」と「認可(authorization)」は以下のような意味で用いられます。
- 認証…サイトのユーザーを識別すること
- 認可…認証したユーザーに対し、当該ユーザーが実行可能な操作を管理すること
対応する英語の綴りが似通っており(最初4文字と最後5文字が同じ)、大変紛らわしいのですが、ともかくこの2つの概念の違いは重要です。
Railsチュートリアルにおいては、第8章で認証の仕組みを実装しました。認証の仕組みが実装されているので、認可の仕組みを実装する準備も整っています。これから実装していくのは、認可の仕組みです。
このパートで実装する内容
現状
ここまでの実装により、editアクションとupdateアクションが完全に動作するようになりました。しかしながら、現状では、以下のようにセキュリティ上の大きな問題を含む実装になっています。
- 全てのユーザーが、あらゆるアクションにアクセスすることができる
- ログインしていないユーザーを含め、誰でもユーザー情報を編集できる
このような実装は明らかにまずいです。実際にそのような実装になっていることが発覚したら、担当者の顔が真っ青になるのは間違いありません。
必要な実装
以下の実装が必要となるので、これから実装していきます。
- ログインしていないユーザーが保護されたページにアクセスできないようにする
- そのようなアクセスが発生した場合、分かりやすいメッセージを表示した上でログインページに転送する
 
- ログイン済みのユーザーが許可されていないページにアクセスできないようにする
- そのようなアクセスが発生した場合、ルートURLにリダイレクトする
 
「ログインしていないユーザーが保護されたページにアクセスしようとした場合に表示される画面のモックアップ」が、Railsチュートリアル本文の図 10.6にて示されています。
ユーザーにログインを要求する
Usersコントローラーのbeforeフィルター
beforeフィルターというのは、Railsに実装されている「before_actionメソッドを使って、何らかの処理が行われる直前に特定のメソッドを実行する」という仕組みのことです。
ユーザーにログインを要求する場面におけるbefore_actionメソッドの利用
現在必要としている実装の内容は、「ログイン済みユーザーであるかどうかを確認し、ログイン済みユーザーでなければログインを要求する」というものです。ということで、before_actionメソッドのユースケースは以下のようになります。
- 
logged_in_userメソッドを定義する
- 
before_actionに:logged_in_userを与える
logged_in_userメソッドの実装
「ユーザーがログインしていなければ、ログインを要求するフラッシュメッセージを出した上で、ログインページにリダイレクトする」という動作になります。コードは以下です。
def logged_in_user
  unless logged_in?
    flash[:danger] = "Please log in."
    redirect_to login_url
  end
end
before_actionの実装
「beforeフィルターにlogged_in_userを追加する。但し、適用されるのは:editアクションと:updateアクションのみ」という実装になります。
before_action :logged_in_user, only: [:edit, :update]
beforeフィルターは、デフォルトではコントローラー内の全てのアクションに適用されます。特定のアクションのみにbeforeフィルターを適用するようにするためには、:onlyオプションの値として対応するアクションを与えればOKです。
Usersコントローラーの変更内容
  class UsersController < ApplicationController
+   before_action :logged_in_user, only: [:edit, :update]
    ...略
    private
      ...略
+
+     # beforeアクション
+
+     # ログイン済みユーザーかどうか確認
+     def logged_in_user
+       unless logged_in?
+         flash[:danger] = "Please log in."
+         redirect_to login_url
+       end
+     end
  end
app/controllers/users_controller.rbには、以下の実装を行っています。
- クラス定義の直後、最初のメソッド定義の前にbefore_actionを追加する
- 
private以降にlogged_in_userメソッドを追加する
ここまでの実装が完了した後、ログインしていないユーザーが保護されたページにアクセスしようとしたらどうなるか
一度ログアウトしたのち、改めて /users/1/edit にアクセスしてみます。結果、Webブラウザに以下のページが表示されました。
 
- フラッシュメッセージに「Please log in.」と書かれている
- ログインページにリダイレクトされている
以上の動作が正しく実装されているようです。
ログインを要求するようになったことに伴うテストの修正
現時点でテストは失敗する
# rails test
Running via Spring preloader in process 605
Started with run options --seed 46346
 FAIL["test_successful_edit", UsersEditTest, 2.683779799990589]
 test_successful_edit#UsersEditTest (2.68s)
        expecting <"users/edit"> but rendering with <[]>
        test/integration/users_edit_test.rb:21:in `block in <class:UsersEditTest>'
 FAIL["test_unsuccessful_edit", UsersEditTest, 2.700610100000631]
 test_unsuccessful_edit#UsersEditTest (2.70s)
        expecting <"users/edit"> but rendering with <[]>
        test/integration/users_edit_test.rb:10:in `block in <class:UsersEditTest>'
  31/31: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.76846s
31 tests, 77 assertions, 2 failures, 0 errors, 0 skips
現時点では、test/integration/users_edit_test.rbの「unsuccessful edit」および「successful edit」の両テストで失敗する状態になっています。これは、「editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストが失敗する」ためです。
editアクションやupdateアクションをテストする前にログインするようにする
この問題を解決するためには、「editアクションやupdateアクションをテストする前にログインするようにする」必要があります。以前似実装したlog_in_asヘルパーを用いることにより、このような動作が実現できます。
  require 'test_helper'
  class UsersEditTest < ActionDispatch::IntegrationTest
    def setup
      @user = users(:rhakurei)
    end
    test "unsuccessful edit" do
+     log_in_as @user
      get edit_user_path(@user)
      ...略
    end
    test "successful edit" do
+     log_in_as @user
      get edit_user_path(@user)
      ...略
    end
  end
テストが成功するようになった
ここまでの実装が完了すると、再びテストが成功するようになります。
# rails test
Running via Spring preloader in process 618
Started with run options --seed 41953
  31/31: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.53240s
31 tests, 84 assertions, 0 failures, 0 errors, 0 skips
セキュリティモデルに関する実装がなければテストが通らないようにする
現時点では、セキュリティモデルに関する実装がなくてもテストが通ってしまう
試しに、app/controllers/users_controller.rbにおけるセキュリティモデルに関する実装をコメントアウトした上でテストを実行してみましょう。
  class UsersController < ApplicationController
-   before_action :logged_in_user, only: [:edit, :update]
+   # before_action :logged_in_user, only: [:edit, :update]
    ...略
  end
# rails test
Running via Spring preloader in process 631
Started with run options --seed 49117
  31/31: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.96846s
31 tests, 84 assertions, 0 failures, 0 errors, 0 skips
なんということでしょう。テストが成功してしまったではないですか。このような巨大なセキュリティホールが存在する実装は、なんとしてもテストで検出できる(テストが失敗する)ようにしなければなりません。
beforeフィルターに対するテスト
beforeフィルターは、基本的にアクションごとに適用していきます。よって、beforeフィルターに対するテストは、アクションごとに書いていくことになります。
具体的なテストの実装方針は以下のようになります。
- 正しい種類のHTTPリクエストを使って、editアクションとupdateアクションをそれぞれ実行させる
- フラッシュメッセージが代入されたかを確認する
- ログイン画面にリダイレクトされたかを確認する
どのHTTPリクエストをテスト対象とするか
- 
editアクションは、/users/:id/edit へのGETリクエストに対応するアクションである
- 
updateアクションは、/users/:id へのPATCHリクエストに対応するアクションである
というわけで、テストの対象は以下のソースコードの通りとなります。テストの名前は、それぞれ「should redirect edit when not logged in」「should redirect update when not logged in」とします。
test "should redirect edit when not logged in" do
  get edit_user_path(@user)
  # TODO:フラッシュメッセージが代入されたか、ログイン画面にリダイレクトされたか
end
test "should redirect update when not logged in" do
  patch user_path(@user), params: { user: { name: @user.name,
                                          email: @user.email } }
  # TODO:フラッシュメッセージが代入されたか、ログイン画面にリダイレクトされたか
end
2つ目のテストでは、patchメソッドを使って、user_path(@user)にPATCHリクエストを送信しています。Railsのルーティング機能により、このリクエストではUsersコントローラーのupdateアクションがきちんと実行されます。
フラッシュメッセージが代入されたかのテスト
assert_not flash.empty?
フラッシュメッセージが空でなければテストは成功となります。
ログイン画面にリダイレクトされたかのテスト
assert_redirected_to login_url
リダイレクト先がlogin_urlであればテストは成功となります。リダイレクトなので、_pathではなく_urlを使います。
テストコードの全体像
test/controllers/users_controller_test.rbに対する変更の全体像は以下のようになります。
  require 'test_helper'
  class UsersControllerTest < ActionDispatch::IntegrationTest
+
+   def setup
+     @user = users(:rhakurei)
+   end
    test "should get new" do
      get signup_path
      assert_response :success
    end
+
+   test "should redirect edit when not logged in" do
+     get edit_user_path(@user)
+     assert_not flash.empty?
+     assert_redirected_to login_url
+   end
+
+   test "should redirect update when not logged in" do
+     patch user_path(@user), params: { user: { name: @user.name,
+                                             email: @user.email } }
+     assert_not flash.empty?
+     assert_redirected_to login_url
+   end
  end
setupメソッドの実装を書き忘れると
なお、setupメソッドの実装を書き忘れた場合は、以下のようなエラーが発生します。
# rails test
Running via Spring preloader in process 657
Started with run options --seed 25742
ERROR["test_should_redirect_edit_when_not_logged_in", UsersControllerTest, 3.0655816000071354]
 test_should_redirect_edit_when_not_logged_in#UsersControllerTest (3.07s)
ActionController::UrlGenerationError:         ActionController::UrlGenerationError: No route matches {:action=>"edit", :controller=>"users", :id=>nil}, missing required keys: [:id]
            test/controllers/users_controller_test.rb:10:in `block in <class:UsersControllerTest>'
ERROR["test_should_redirect_update_when_not_logged_in", UsersControllerTest, 3.074150300002657]
 test_should_redirect_update_when_not_logged_in#UsersControllerTest (3.07s)
ActionController::UrlGenerationError:         ActionController::UrlGenerationError: No route matches {:action=>"show", :controller=>"users", :id=>nil}, missing required keys: [:id]
            test/controllers/users_controller_test.rb:16:in `block in <class:UsersControllerTest>'
  33/33: [=================================] 100% Time: 00:00:03, Time: 00:00:03
UrlGenerationError: No route matches {:action=>"edit", :controller=>"users", :id=>nil}, missing required keys: [:id]というエラーメッセージが出ているのがポイントです。「必要なユーザー情報が与えられていないため、Railsのルーティング機能がリソースへのURLを生成できない」という意味合いのエラーです。
セキュリティモデルに関する実装がないとテストが失敗することを確認する
以上test/controllers/users_controller_test.rbに対する変更を保存した上で、app/controllers/users_controller.rbのbefore_actionをコメントアウトした状態で、改めてテストを実行してみましょう。
class UsersController < ApplicationController
  # before_action :logged_in_user, only: [:edit, :update]
  # ...略
end
# rails test test/controllers/users_controller_test.rb
Running via Spring preloader in process 722
Started with run options --seed 2388
 FAIL["test_should_redirect_edit_when_not_logged_in", UsersControllerTest, 1.9847615000035148]
 test_should_redirect_edit_when_not_logged_in#UsersControllerTest (1.99s)
        Expected true to be nil or false
        test/controllers/users_controller_test.rb:16:in `block in <class:UsersControllerTest>'
 FAIL["test_should_redirect_update_when_not_logged_in", UsersControllerTest, 2.026437199994689]
 test_should_redirect_update_when_not_logged_in#UsersControllerTest (2.03s)
        Expected response to be a redirect to <http://www.example.com/login> but was a redirect to <http://www.example.com/users/959740715>.
        Expected "http://www.example.com/login" to be === "http://www.example.com/users/959740715".
        test/controllers/users_controller_test.rb:24:in `block in <class:UsersControllerTest>'
  3/3: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 2.03119s
3 tests, 5 assertions, 2 failures, 0 errors, 0 skips
テストが失敗しました。
テスト「should redirect edit when not logged in」の失敗
「ログインしていない状態でユーザー情報の編集ページを開こうとすると、ログイン画面にリダイレクトされる」という動作のテストです。「test/controllers/users_controller_test.rbの16行目で、trueを返さなければならないところ、nilまたはfalseを返したためテストが失敗」とあります。私の環境では、同行には以下の記述があります。
assert_not flash.empty?
「フラッシュメッセージが空であってはならない」ということですね。
beforeフィルターが正しく実装されていれば、この場面では「"Please log in."というフラッシュメッセージが定義された上で、ログイン画面にリダイレクトされる」という動作をするはずです。しかしながら、beforeフィルターが実装されていないと、そのままユーザー情報の編集ページを開くことができてしまいます。結果、テストが失敗するのです。
テスト「should redirect update when not logged in」の失敗
「ログインしていない状態でユーザー情報の編集を保存しようとすると、ログイン画面にリダイレクトされる」という動作のテストです。「test/controllers/users_controller_test.rbの24行目で、ログイン画面のURLにリダイレクトされなければならないところ、特定のユーザー情報のURLにリダイレクトされたためテストが失敗」とあります。私の環境には、同行には以下の記述があります。
assert_redirected_to login_url
beforeフィルターが正しく実装されていれば、この場面では「"Please log in."というフラッシュメッセージが定義された上で、ログイン画面にリダイレクトされる」という動作をするはずです。しかしながら、beforeフィルターが実装されていないと、(ユーザー情報の編集が保存された上で)当該ユーザー情報のURLにリダイレクトされることになります。結果、テストが失敗するのです。
セキュリティモデルに関する実装があればテストが成功することを確認する
今度は、app/controllers/users_controller.rbでbefore_actionのコメントアウトを解除した上で、改めてtest/controllers/users_controller_test.rbのテストを実行してみましょう。
  class UsersController < ApplicationController
-   # before_action :logged_in_user, only: [:edit, :update]
+   before_action :logged_in_user, only: [:edit, :update]
    ...略
  end
# rails test test/controllers/users_controller_test.rb
Running via Spring preloader in process 735
Started with run options --seed 41773
  3/3: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.95353s
3 tests, 5 assertions, 0 failures, 0 errors, 0 skips
無事テストが成功しました。
演習 - ユーザーにログインを要求する
1. リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがエラーを検知できるかどうか (テストが失敗するかどうか) 確かめてみましょう。
デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまうはずです (結果としてテストも失敗するはずです)。
  class UsersController < ApplicationController
-   before_action :logged_in_user, only: [:edit, :update]
+   before_action :logged_in_user, #only: [:edit, :update]
    ...略
  end
app/controllers/users_controller.rbを上記のように変更した状態でテストを実行してみます。
# rails test test/controllers/users_controller_test.rb
Running via Spring preloader in process 748
Started with run options --seed 6443
 FAIL["test_should_get_new", UsersControllerTest, 0.8396128000022145]
 test_should_get_new#UsersControllerTest (0.84s)
        Expected response to be a <2XX: success>, but was a <302: Found> redirect to <http://www.example.com/login>
        Response body: <html><body>You are being <a href="http://www.example.com/login">redirected</a>.</body></html>
        test/controllers/users_controller_test.rb:11:in `block in <class:UsersControllerTest>'
  3/3: [===================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.89109s
3 tests, 5 assertions, 1 failures, 0 errors, 0 skips
「should get new」というテストが失敗しています。当該テストの内容は以下のとおりです。
test "should get new" do
  get signup_path
  assert_response :success
end
このテストは、/signup へのGETリクエスト(すなわちUsersコントローラーのnewアクション=ユーザー登録ページの表示)に対して返ってくるHTTPステータスコードは200番台でなければ通りません。しかしながら、beforeフィルターが /signup に適用されると、/signup へのGETリクエストに対して返ってくるHTTPステータスコードが302になってしまいます。そのため、テストが成功しなくなるのです。
正しいユーザーを要求する
現状、必要な実装、実装方針
ログインを要求する仕組みは実装できました。しかしながら、未だ「ログイン済みユーザーが他のユーザーの登録情報を編集できてしまう」という問題は残っています。「ユーザーが自分の情報のみを編集できるようにする」という機能を実装することが必要です。
セキュリティが関係する実装は、確実になされていなければなりません。そうでなければ、「セキュリティ上の深刻な欠陥を見逃す実装」がなされてしまうおそれがあります。先ほどの「ユーザーにログインを要求する」の実装で発生しましたね…。
というわけで、「正しいユーザーを要求する」という仕組みを、テスト駆動開発で実装していく…Railsチュートリアル本文はこのような流れで進んでいきます。
fixtureに新たなユーザーを追加する
「異なるユーザーが、互いに情報を編集できないようにする」という実装を追加するために、まずfixtureに新たなユーザーを追加します(もちろん、正しいUserモデルの実体になるように定義します)。
  rhakurei:
    name: Reimu Hakurei
    email: rhakurei@example.com
    password_digest: <%= User.digest('password') %>
+
+ mkirisame:
+   name: Marisa Kirisame
+   email: example.example@example.org
+   password_digest: <%= User.digest('password') %>
間違ったユーザーが編集しようとしたときのテスト
test "should redirect edit when logged in as wrong user" do
  log_in_as(@other_user)
  get edit_user_path(@user)
  assert flash.empty?
  assert_redirected_to root_url
end
上記のコードは、「あるユーザーでログインした後、別のユーザーのユーザー情報編集ページを開こうとした場合」のテストです。「ルートURLにリダイレクトされる。その際、フラッシュメッセージは空である」という挙動であればテストが通ります。
test "should redirect update when logged in as wrong user" do
  log_in_as(@other_user)
  patch user_path(@user), params: { user: { name: @user.name,
                                            email: @user.email } }
  assert flash.empty?
  assert_redirected_to root_url
end
続いて上記のコードは、「あるユーザーでログインした後、別のユーザーの登録情報を更新しようとした場合」のテストです。これまた「ルートURLにリダイレクトされる。その際、フラッシュメッセージは空である」という挙動であればテストが通ります。
現時点ではテストは失敗する
# rails test test/controllers/users_controller_test.rb
Running via Spring preloader in process 865
Started with run options --seed 61640
 FAIL["test_should_redirect_update_when_logged_in_as_wrong_user", UsersControllerTest, 0.7539288999978453]
 test_should_redirect_update_when_logged_in_as_wrong_user#UsersControllerTest (0.76s)
        Expected false to be truthy.
        test/controllers/users_controller_test.rb:39:in `block in <class:UsersControllerTest>'
 FAIL["test_should_redirect_edit_when_logged_in_as_wrong_user", UsersControllerTest, 2.5815383000008296]
 test_should_redirect_edit_when_logged_in_as_wrong_user#UsersControllerTest (2.58s)
        Expected response to be a <3XX: redirect>, but was a <200: OK>
        test/controllers/users_controller_test.rb:32:in `block in <class:UsersControllerTest>'
  5/5: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.61645s
5 tests, 8 assertions, 2 failures, 0 errors, 0 skips
テスト「should redirect edit when logged in as wrong user」の失敗理由
テスト「should redirect edit when logged in as wrong user」のほうは、test/controllers/users_controller_test.rbの32行目でテストが失敗しています。具体的には、以下の記述がされた行です。
assert_redirected_to root_url
失敗した理由は、「HTTPリクエストに対するレスポンスがリダイレクトであるべきところが、200 OK を返してきている」というものです。「 / へのリダイレクトが正しく機能していない」ということですね。
テスト「should redirect update when logged in as wrong user」の失敗理由
一方の「should redirect update when logged in as wrong user」のほうは、test/controllers/users_controller_test.rbの39行目でテストが失敗しています。具体的には、以下の記述がされた行です。
assert flash.empty?
失敗した理由は「フラッシュメッセージは空でなければならないのに、空でないフラッシュメッセージが渡されてきた」というものです。「誰でも無条件にユーザー情報の内容を更新できてしまう」という現状の実装では、このときのフラッシュメッセージは「Profile updated」になりますね。具体的には、UsersController#updateの以下の部分です。
def update
  if @user.update_attributes(user_params)
    flash[:success] = "Profile updated"  # <=この部分
    # ...略
  else
    # ...略
  end
end
正しいユーザーかどうか確認するコードをUsersコントローラーに追加する
def correct_user
  @user = User.find(param[:id])
  redirect_to(root_url) unless @user == current_user
end
上記のコードは、「HTTPリクエストで編集・更新の対象として与えられたユーザーが現在ログイン中のユーザーと同一であることを確認し、同一でなければ / にリダイレクトする」という動作をするコードです。Usersコントローラーのprivateメソッド以降に追加していきます。current_userは、Rails本体で定義された、現在ログイン中のユーザーを返すメソッドです。
before_action :correct_user,   only: [:edit, :update]
上記のコードは、「beforeフィルターにcorrect_userを追加する。但し、適用されるのは:editアクションと:updateアクションのみ」というコードです。
  class UsersController < ApplicationController
    before_action :logged_in_user, only: [:edit, :update]
+   before_action :correct_user,   only: [:edit, :update]
    ...略
    def edit
-     @user = User.find(params[:id])
    end
    def update
-     @user = User.find(params[:id])
      if @user.update_attributes(user_params)
        flash[:success] = "Profile updated"
        redirect_to @user
      else
        render 'edit'
      end
    end
    private
      def user_params
        params.require(:user).permit(:name, :email, :password, :password_confirmation)
      end
      # beforeアクション
      # ログイン済みユーザーかどうか確認
      def logged_in_user
        unless logged_in?
          flash[:danger] = "Please log in."
          redirect_to login_url
        end
      end
+
+     # 正しいユーザーかどうか確認
+     def correct_user
+       @user = User.find(params[:id])
+       redirect_to(root_url) unless @user == current_user
+     end
  end
app/controllers/users_controller.rb全体の変更は、上記ソースコードのようになります。
小さなリファクタリング
「editおよびupdateの両アクションの前で、常にcorrect_userメソッドが呼び出される」という前提の場合、以下の処理はcorrect_user内で実装されているため、editおよびupdateには不要になります。
@user = User.find(params[:id])
ゆえに、当該処理のコードはeditおよびupdateから削除しています。「重複する記述を一本化する」というのは、リファクタリングの常套手段ですよね。
テストが成功することを確認する
改めて、test/controllers/users_controller_test.rbに対応するテストを実行してみます。
# rails test test/controllers/users_controller_test.rb
Running via Spring preloader in process 891
Started with run options --seed 52467
  5/5: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.17585s
5 tests, 9 assertions, 0 failures, 0 errors, 0 skips
今度こそテストが問題なく成功することが確認できました。
さらなるリファクタリング - current_user?メソッドを実装する
current_user?メソッドの実装に至る前提
UsersController#correct_user内には、以下のコードが存在します。
unless @user == current_user
上記コードでももちろん期待する動作は実現できます。しかしながら、ロジックを実装するコードにこのような書き方が存在するというのは、「Ruby的」とは言い難く、ソースコード全体のエレガントさを損ねるものです。エレガントで「Ruby的」なソースコードは、もっとこう、以下の例のような記述を期待しますよね。
!current_user.nil?
if logged_in?
unless logged_in?
if page_title.empty?
assert flash.empty?
…ならばそのようにしてしまいましょう、というのがRailsチュートリアル本文の流れです。
current_user?メソッドの実装
当該メソッドの名前はcurrent_user?とします。メソッド全体のコードは以下のようになります。
def current_user?(user)
  user == current_user
end
current_user?メソッドは、correct_user内部で使えるようにしたいので、実装箇所はSessionsヘルパーとなります。
  module SessionsHelper
    ...略
+
+   # 渡されたユーザーがログイン済みユーザーであればtrueを返す
+     def current_user?(user)
+       user == current_user
+     end
    # 現在ログイン中のユーザーを返す(いる場合)
    def current_user
      ...略
      end
    end
   ...略
  end
current_user?メソッドの使用
実際のUsersController#correct_userメソッドでcurrent_user?メソッドを使用するようにコードを変更します。
  class UsersController < ApplicationController
    ...略
    private
      ...略
      # beforeアクション
      ...略
      # 正しいユーザーかどうか確認
      def correct_user
        @user = User.find(params[:id])
-       redirect_to(root_url) unless @user == current_user
+       redirect_to(root_url) unless current_user?(@user)
      end
  end
演習
1. 何故editアクションとupdateアクションを両方とも保護する必要があるのでしょうか? 考えてみてください。
editアクションを保護する必要性
editアクションが保護されていないと、「ログインユーザーが他のユーザーのユーザー情報編集ページを開くことができてしまう」という事態が発生します。
確かにupdateアクションが保護されていれば、上記の状況でユーザー情報の編集がRDBに反映されることはありません。しかし、「他のユーザーのユーザー情報編集ページを開くことができてしまう」という事態そのものが、ユーザー体験としてよろしくないのは容易に想像できます。
updateアクションを保護する必要性
現在のページ構成上、Webブラウザによるアクセスであれば、常に「updateアクションはeditアクションの後に呼び出される」という実装になります。
しかしながら、以下のようなフローが発生する可能性は十分に考えられます。
- 
editアクションが実行され、ユーザー情報の編集画面が開かれた後に、当該画面を残したまま、同一ブラウザの別のタブでログアウトする
- 
editアクションが実行され、ユーザー情報の編集画面が開かれた後に、当該画面を残したまま、同一ブラウザの別のタブで別のユーザーにログインする
また、Webサービスには、Webブラウザ以外によるアクセスも考えられます。「cURLなどのツールにより、POSTやPATCHが直接呼び出される」という可能性も想定しておかなければなりません。
上記のような状況でも意図せぬRDBの更新を防ぐ必要があります。そのため、updateアクションは保護される必要があるのです。
2. 上記のアクションのうち、どちらがブラウザで簡単にテストできるアクションでしょうか?
現在のページ構成上、Webブラウザでupdateアクションをテストするには、「ユーザーの編集ページ」を開いて「Save changes」ボタンをクリックする必要があります。そのためには、まずeditアクションが正常に動作しなければなりません。
ゆえに、editアクションのほうがテストは容易です。
フレンドリーフォワーディング
現状の実装の問題点
現状の実装は、「保護されたページにアクセスしようとすると、問答無用で自分のプロフィールページに移動させられてしまう」というものです。
「保護されたページに非ログイン状態でアクセスし、その後ログインする」というユースケースに対しては、「ログイン後には、非ログイン状態で開こうとしていたページを開く」という動作のほうがよりユーザーフレンドリーな動作といえるでしょう。具体的には、例えば「非ログイン状態でユーザー情報編集ページにアクセスしようとする→ログイン」というユースケースの場合、ログイン後には(ユーザーのプロフィールページではなく)ユーザー情報編集ページが開かれるようにしたい、ということです。
フレンドリーフォワーディングのテスト
実現したい動作は、「編集画面を開いた後にログインすると、編集ページにリダイレクトされる」という動作です。対応するテストは以下のようになります。
test "successful edit with friendly forwarding" do
  get edit_user_path(@user)
  log_in_as @user
  assert_redirected_to edit_user_url(@user)
  name = "Foo Bar"
  email = "foo@bar.com"
  patch user_path(@user), params: {user: {  name: name,
                                            email: email,
                                            password: "",
                                            password_confirm: ""} }
  assert_not flash.empty?
  assert_redirected_to @user
  @user.reload
  assert_equal name, @user.name
  assert_equal email, @user.email
end
このテストを書くファイルは、test/integration/users_edit_test.rbとなります。既存のテスト「successful edit」を書き換えていきます。
  require 'test_helper'
  class UsersEditTest < ActionDispatch::IntegrationTest
    ...略
-   test "successful edit" do
+   test "successful edit with friendly forwarding" do
-     log_in_as @user
      get edit_user_path(@user)
+     log_in_as @user
-     assert_template 'users/edit'
+     assert_redirected_to edit_user_url(@user)
      name = "Foo Bar"
      email = "foo@bar.com"
      patch user_path(@user), params: {user: {  name: name,
                                                email: email,
                                                password: "",
                                                password_confirm: ""} }
      assert_not flash.empty?
      assert_redirected_to @user
      @user.reload
      assert_equal name, @user.name
      assert_equal email, @user.email
    end
  end
なお、リダイレクトによってedit用のテンプレートが描画されなくなったことに対応し、assert_templateを削除しています。
現時点でテストは通らない
当然といえば当然かもしれませんが、現時点で上記のテストは通りません。
# rails test test/integration/users_edit_test.rb
Running via Spring preloader in process 917
Started with run options --seed 41025
 FAIL["test_successful_edit_with_friendly_forwarding", UsersEditTest, 0.5618743999948492]
 test_successful_edit_with_friendly_forwarding#UsersEditTest (0.56s)
        Expected response to be a redirect to <http://www.example.com/users/959740715/edit> but was a redirect to <http://www.example.com/users/959740715>.
        Expected "http://www.example.com/users/959740715/edit" to be === "http://www.example.com/users/959740715".
        test/integration/users_edit_test.rb:23:in `block in <class:UsersEditTest>'
  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.95843s
2 tests, 5 assertions, 1 failures, 0 errors, 0 skips
「リダイレクト先が編集ページであるべきところ、実際にはプロフィールページになっている」という趣旨のメッセージが見受けられますね。
フレンドリーフォワーディングの実装
フレンドリーフォワーディングの実装に必要な仕組み
ログイン時にユーザーを希望のページに転送させるには、以下の機能を実装する必要があります。
- リクエストの時点のページを記憶する(メソッド名はstore_location)
- ログイン後、記憶しておいた場所のURLにリダイレクトさせるようにする(メソッド名はredirect_back_or)
フレンドリーフォワーディングに必要な仕組みを実際に実装する
リクエストの時点のページを記憶する
def store_location
  session[:forwarding_url] = request.original_url if request.get?
end
上記はstore_locationメソッドのコードです。ポイントは以下です。
- 転送先のURLを記憶する場所は一時cookies
- 
sessionメソッドでアクセスする
- キーの名前はforwarding_urlとする
 
- 
- リクエスト先は、request.original_urlというメソッドで取得できる
- リクエスト内容がGETである場合のみ、転送先のURLを記憶する- リクエスト内容がPOST、PATCH、DELETEである場合に対する備え
 
- リクエスト内容が
リクエスト内容がGETでない場合と、その対処は
「リクエスト内容がGETではない場合」というのは、例えば「ログインしていないユーザーがフォームから送信する」ようなユースケース、より具体的には「ユーザーが一時cookiesを手動で削除した上でフォームから送信する」などといったユースケースで発生しえます。
POST、PATCH、DELETEが期待されているURLに対してGETリクエストが送られるというのは想定外の動作です。場合によってはエラーが発生することにもなりかねません。
このような場合に転送先のURLを記憶しないようにするため、if request.get?という条件文を用い、「リクエスト内容がGETである場合のみ転送先URLを記憶する」というをするように実装しています。
ログイン後、記憶しておいた場所のURLにリダイレクトさせるようにする
def redirect_back_or(default)
  redirect_to(session[:forwarding_url] || default)
  session.delete(:forwarding_url)
end
上記はredirect_back_orメソッドのコードです。ポイントは以下です。
- 
redirect_toメソッドの引数内で||演算子を使っている
- 
redirect_toメソッドが呼び出された後、記憶されていたリダイレクト先URLが削除されている
- 
redirect_toメソッドが呼び出されても、直ちにリダイレクトはされない
- 
redirect_toメソッドが呼び出された後、リダイレクトが実行されるためには、さらに以下いずれかの条件が満たされる必要がある- 明示的にreturn文が呼び出される
- 
redirect_toメソッドが呼び出されたメソッドの最終行が呼び出され、その評価が完了する
 
- 明示的に
redirect_toメソッドの引数
redirect_toメソッドの引数は以下のようになっています。
session[:forwarding_url] || default
上記コードは、Ruby初心者にとってその挙動が分かりづらいコードです。解説の文章がが少々長くなるので、redirect_toメソッドの引数内で || 演算子を用いる。その動作の解説という別項目を立てて解説しています。
記憶されていたリダイレクト先URLを削除する
session.delete(:forwarding_url)
上記コードは「redirect_toメソッドが呼び出された後、記憶されていたリダイレクト先URLを削除する」というコードです。このコードがないと、「ログアウト→再ログインした際、保護されたページに転送される」という動作がブラウザを終了する(=一時cookieが削除される)まで続くことになります。再ログイン時のリダイレクト先は、ユーザーのプロフィールページである方が望ましいですよね。
Usersコントローラーに「ログインしていないとき、一時cookiesにリダイレクト先のURLを保存する」という仕組みを実装する
store_locationメソッドを適切な場所で呼び出せば、必要な実装が完成します。実装箇所は、Usersコントローラーのbeforeフィルターで用いるlogged_in_userメソッド内です。
def logged_in_user
  unless logged_in?
    store_location
    flash[:danger] = "Please log in."
    redirect_to login_url
  end
end
app/controllers/users_controller.rbそのものの変更内容は以下のようになります。
    class UsersController < ApplicationController
      before_action :logged_in_user, only: [:edit, :update]
      before_action :correct_user,   only: [:edit, :update]
      ...略
      private
        def user_params
          params.require(:user).permit(:name, :email, :password, :password_confirmation)
        end
        # beforeアクション
        # ログイン済みユーザーかどうか確認
        def logged_in_user
          unless logged_in?
+           store_location
            flash[:danger] = "Please log in."
            redirect_to login_url
          end
        end
        # 正しいユーザーかどうか確認
        def correct_user
          @user = User.find(params[:id])
          redirect_to(root_url) unless current_user?(@user)
        end
    end
Sessionsコントローラーでフレンドリーフォワーディングの仕組みを使うようにする
以上でフレンドリーフォワーディング機構の実装を書き終えました。実際に機構を使うために、Sessionsコントローラーのcreateメソッドを書き換える必要があります。Sessionsコントローラーのcreateメソッドの新たなコードは以下です。
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_back_or @user
  else
    flash.now[:danger] = 'Invalid email/password combination'
    render 'new'
  end
end
上述createアクションの新たな実装を踏まえた上で、app/controllers/sessions_controller.rbに変更を反映すると以下のようになります。
  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
+       redirect_back_or user
      else
        ...略
      end
    end
    def destroy
      ...略
    end
  end
改めてテストを実行する
ここまでの実装が完了したところで、改めてtest/integration/users_edit_test.rbに対してテストを実行します。
# rails test test/integration/users_edit_test.rb
Running via Spring preloader in process 996
Started with run options --seed 18863
  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.84389s
2 tests, 9 assertions, 0 failures, 0 errors, 0 skips
無事テストが成功しました。
演習 - フレンドリーフォワーディング
1. フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認してみましょう。次回以降のログインのときには、転送先のURLはデフォルト (プロフィール画面) に戻っている必要があります。
ヒント: リスト 10.29のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。
「次回以降のログインのときには、転送先のURLはデフォルト (プロフィール画面) に戻っている」という動作を実現するためには、「ログインが成功した時点で、session[:forwarding_url]がnilになっている」必要があります。
対応するテストコードの変更内容は、test/integration/users_edit_test.rb内以下の部分となります。
  class UsersEditTest < ActionDispatch::IntegrationTest
    ...略
    test "successful edit with friendly forwarding" do
      get edit_user_path(@user)
      log_in_as @user
      assert_redirected_to edit_user_url(@user)
      name = "Foo Bar"
      email = "foo@bar.com"
      patch user_path(@user), params: {user: {  name: name,
                                                email: email,
                                                password: "",
                                                password_confirm: ""} }
      assert_not flash.empty?
      assert_redirected_to @user
      @user.reload
+     assert_nil session[:forwarding_url]
      assert_equal name, @user.name
      assert_equal email, @user.email
    end
  end
本当に正しいテストなのか
「ログインが成功した時点で、session[:forwarding_url]をnilにする」という動作は、SessionsHelper#redirect_back_orメソッド内の以下のコードになります。
session.delete(:forwarding_url)
当該部分をapp/helpers/sessions_helper.rbからコメントアウトします。
  module SessionsHelper
    ...略
    # 記憶したURL(もしくはデフォルト値)にリダイレクト
    def redirect_back_or(default)
      redirect_to(session[:forwarding_url] || default)
-     session.delete(:forwarding_url)
+     # session.delete(:forwarding_url)
    end
  end
test/integration/users_edit_test.rbを対象としてテストを実行します。
# rails test test/integration/users_edit_test.rb
Running via Spring preloader in process 1134
Started with run options --seed 6312
 FAIL["test_successful_edit_with_friendly_forwarding", UsersEditTest, 1.9922731000115164]
 test_successful_edit_with_friendly_forwarding#UsersEditTest (1.99s)
        Expected "http://www.example.com/users/959740715/edit" to be nil.
        test/integration/users_edit_test.rb:33:in `block in <class:UsersEditTest>'
  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 1.99795s
2 tests, 8 assertions, 1 failures, 0 errors, 0 skips
テストが失敗し、下記のメッセージが出力されています。
Expected "http://www.example.com/users/959740715/edit" to be nil.
test/integration/users_edit_test.rb:33
私の環境では、test/integration/users_edit_test.rbの33行目は以下の内容です。
assert_nil session[:forwarding_url]
まさしくたった今追加したテストですね。テストコードは正しいようです。
実装に問題はないのか
session.delete(:forwarding_url)
app/helpers/sessions_helper.rbから、上記コードのコメントアウトを解除します。
  module SessionsHelper
    ...略
    # 記憶したURL(もしくはデフォルト値)にリダイレクト
    def redirect_back_or(default)
      redirect_to(session[:forwarding_url] || default)
-     # session.delete(:forwarding_url)
+     session.delete(:forwarding_url)
    end
  end
app/helpers/sessions_helper.rbに変更を保存した上で、改めてtest/integration/users_edit_test.rbを対象としてテストを実行します。
# rails test test/integration/users_edit_test.rb
Running via Spring preloader in process 1147
Started with run options --seed 11229
  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 2.00460s
2 tests, 10 assertions, 0 failures, 0 errors, 0 skips
テストが成功しました。実装も問題ないといえますね。
1.発展. 「ログインしていない状態でユーザー情報編集ページにアクセスしようとした」という状況で、フレンドリーフォワーディングのリダイレクト先のURLが正しく渡されていることを、テストを書いて確認してみましょう。
→Railsチュートリアル 第10章 フレンドリーフォワーディングの追加演習 - フレンドリーフォワーディングのリダイレクト先のURLが正しく渡されていることのテスト(editアクション編)
1.発展. 「ログインしていない状態でRDB上のユーザー情報を更新しようとした」という状況で、フレンドリーフォワーディングのリダイレクト先のURLが渡されていないことを、テストを書いて確認してみましょう。
2. session[:forwarding_url]にリダイレクト先のURLが保存される状況において、値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう)。
具体的には、以下のような手順になります。
- 
7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置く
- その後、ログアウトして /users/1/edit にアクセスする
まずは、app/controllers/sessions_controller.rbに以下の変更を行います。
  class SessionsController < ApplicationController
    def new
+     debugger
    end
    ...略
  end
その上で、Webブラウザで以下の操作を行います。
- すでにサンプルアプリケーションにログインしている場合、ログアウトする
- /users/1/edit にアクセスする
この時点で、rails serverのログ画面は以下のようになります。
Started GET "/login" ...略
[1, 10] in /var/www/sample_app/app/controllers/sessions_controller.rb
    1: class SessionsController < ApplicationController
    2:   def new
    3:     debugger
=>  4:   end
    ...略
(byebug) 
session[:forwarding_url]の値が正しいことを確認する
session[:forwarding_url]の内容は以下のようになります。
(byebug) session[:forwarding_url]
"http://localhost:8080/users/1/edit"
「/users/1/edit にアクセスした」というのが前提なので、session[:forwarding_url]の値は確かに正しいですね。
なお、この時点でのflashの内容は以下のようになります。
(byebug) pp(flash)
# <ActionDispatch::Flash::FlashHash:0x00007f4d1935f850
 @discard=#<Set: {"danger"}>,
 @flashes={"danger"=>"Please log in."},
 @now=nil>
...略
newアクションにアクセスしたときのrequest.get?の値
「/users/1/edit にWebブラウザからアクセスした」というのは、「/users/1/edit にGETリクエストを送出した」ともいえます。この時点では、request.get?はtrueを返します。
(byebug) request.get?
true
演習の後処理
最後はdebuggerメソッドが実行されなくなるようにしましょう。
  class SessionsController < ApplicationController
    def new
-     debugger
    end
    ...略
  end