認証と認可
ウェブアプリケーションの文脈において、「認証(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