10.2 認可
ウエブアプリの文脈では
認証(authentication)→サイトのユーザーを識別すること
認可(authorization)→そのユーザーが実行可能な操作を管理すること
第8章で認証システムを構築したことで、認可のためのシステムを実装する準備もできた。
edit
アクションとupdate
アクションはセキュリティ上の大穴が1つあいてる。それは、誰でも(ログインしていないユーザーでも)ユーザー情報を編集できてしまうこと。
ここでは、ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御してみる。
10.2.1 ユーザーにログインを要求
ログインしていないユーザーが保護されたページにアクセスしようとした際、ログインページに転送して、分かりやすッメッセージも表示するようにしたい。このように転送する仕組みを実装したいときは、Users
コントローラの中でbefore
フィルターを使う。
before
フィルターは、before_action
メソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組み。
controllers/users_controller.rb
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
①:デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるので、:only
オプション(ハッシュ)を渡すことで、:edit
と:update
アクションだけにこのフィルタが適用されるよう制限。
②:ユーザーがログインしていなければ(false)処理を行う。ここでlogged_in?
は
def logged_in? !current_user.nil? end
before
フィルターを使って実装した結果、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスしてみることで確認できる。
$ rails test
今の段階では、テストは失敗。
原因は、edit
アクションやupdate
アクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストで失敗してしまうから。
このため、edit
アクションやupdate
アクションをテストする前にログインしておく必要がある。
解決策は簡単で、9.3で開発したlog_in_as
ヘルパー
test/test_helper.rb
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
を使うこと。
test/integration/users_edit_test.rb
def setup @user = users(:michael) 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
これでテストはパスする。
しかし、実はbefore
フィルターの実装はまだ終わっていない。セキュリティモデルに関する実装を取り外してもテストが成功になるかどうか、実際にコメントアウトして確かめてみる。
controllers/users_controllr.rb
#before_action :logged_in_user, only: [:edit, :update]
なんと、すべてのテストが成功してしまった。before
フィルターをコメントアウトして巨大なセキュリティーホールが作られたら、テストスイートでそれを検出できるべき。テストを書いて、この問題に対処
する。
対処
before
フィルターは基本的にアクションごとに適用していくので、Users
コントローラのテストもアクションごとに書いていく。具体的には、
- 正しい種類のHTTPリクエストを使って
edit
アクションとupdate
アクションをそれぞれ実行させてみて - flashにメッセージが代入されるかどうか確認、
- ログイン画面にリダイレクトされたかどうか確認
HTTPリクエスト | アクション |
---|---|
GET | edit |
PATCH | update |
この表より、適切なHTTPリクエストは,edit
にはGET、update
にはPATCHである
test/controllers/users_controller_test.rb
require 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) 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
①:ここで、patch
メソッドを使ってuser_path(@user)
にPATCHリクエストを送信している点に注目!このリクエストはUsersコントローラーのupdate
アクションへと適切につないでくれる。
beforeフィルターのコメントアウトを元に戻すと、これでテストはパスする。
10.2.2 正しいユーザーの要求
ユーザーが自分の情報だけを編集できるようにする必要がある。
ここでは、セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。
したがって、Users
コントローラのテストを補完するように、テストを追加していく。
まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう1人追加する。
ユーザー用のfixtureファイルに2人目のユーザーを追加してみる。
test/fixtures/users.yml
michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %>
次に、log_in_as
メソッドを使って、edit
アクションとupdate
アクションをテストする。既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトしている点に注意!
間違ったユーザーが編集しようとしたときのテスト
test/controllers/users_controller_test.rb
def setup @user = users(:michael) @other_users =users(:archer) ←2人目追加 end 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 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
①:@other_user
でログイン
②:@user
の編集ページを取得
③:flashが空(エラーメッセージが表示されない)ならtrue
④:root_urlへリダイレクトしたらtrue
⑤:フォームに入力したnameとemailを送信
テストは失敗する。
別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、
correct_user
というメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。
beforeフィルターのcorrect_user
で@user
変数を定義しているため、edit
とupdate
の各アクションから、@user
への代入文を削除している点にも注意!
controllers/users_controller.rb
class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] ←追加 : def edit end def update if @user.update_attributes(user_params) flash[:success] ="Profile update" 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
今度はテストが成功。
current_user?
メソッド
最後に、リファクタリングではあるが、current_user?
という論理値を返すメソッドを実装する。correct_user
の中で使えるようにしたいので、Sessionsヘルパーの中にこのメソッドを追加する。
このメソッドを使うと今までの
unless @user == current_user
といった部分が、次のように(少し)わかりやいコードになる。
unless current_user?(@user)
app/helpers/sessions_helper.rb
module SessionsHelper : # 渡されたユーザーがログイン済みユーザーであればtrueを返す ←追加 def current_user?(user) user == current_user end
controllers/users_controller.rb
class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] : def edit end def update if @user.update_attributes(user_params) flash[:success] ="Profile update" 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 current_user?(@user) ←変更 end
10.2.3 フレンドリーフォワーディング
現在、保護されたページにアクセスしようとすると、問題無用で自分のプロフィールページに移動させられてしまう。別の言い方をすれば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後には、その編集ページにリダイレクトされるようにするのが望ましい動作。リダイレクト先は、ユーザーが開こうとしていたページにしてあげるが親切というもの。
実際のコードは少し複雑だが、フレンドリーフォワーディングのテストは非常にシンプルに書くことができる。ログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけ。
実際のテストは
(1)まず編集ページにアクセスし、
(2)ログインした後に(デフォルトプロフィールページでなく)編集ページにリダイレクトされているかどうかチェックする
(なお、リダイレクトによってedit用のテンプレが描画されなくなったので、edit_testにあるassert_template 'users/edit'を削除している)
test/integration/users_edit_test.rb
def setup @user = users(:michael) end test "successful edit with friendly forwarding" do get edit_user_path(@user) ←① log_in_as(@user) ←② assert_redirected_to edit_user_path(@user) ←③ log_in_as(@user) get edit_user_path(@user) name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end
①:@user
のユーザー編集ページを取得
②:@user
でログイン
③:@user
のユーザー編集ページへリダイレクト
テストは失敗する。
ようやくフレンドリーフォワーディングを実装する準備ができた。ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。この動作をstore_location
とredirect_back_or
の2つのメソッドを使って実現してみる。なお、これらのメソッドはSessionsヘルパーで定義している。
app/helpers/sessions_helper.rb
: # 記憶したURL(もしくはデフォルト値)にリダイレクト def redirect_back_or(default) redirect_to(session[:forwarding_url] || default) session.delete(:forwarding_url) end # アクセスしようとしたURLを覚えておく def store_location session[:forwarding_url] = request.original_url if request.get? ←① end
①:転送先のURLを保存する仕組みは、8.2.1でユーザーをログインさせたときと同じで、session
変数を使う。また、request
オブジェクトも使っている。
①では、リクエストが送られたURLをsession
変数の:forwarding_url
キーに格納している。ただし、
GETリクエストが送られたときだけ格納するようにしておく。これによって、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようできる。これは稀なケースだが起こり得る。例えば、ユーザがセッション用のcookieを手動で削除してフォームから送信するケースなど。
こういったケースに対処しておかないと、POSTやPATCH、DELETEリクエストを期待しているURLに対して、(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。(これらの問題をif request.get?という条件文を使って対応している)
先ほど定義したstore_location
メソッドを使って、早速beforeフィルター(logged_in_user
)を修正してみる。
controllers/users_controller.rb
#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
フォワーディング自体を実装するには、redirect_back_or
メソッドを使う。リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトする。デフォルトのURLは、Sessionsコントローラのcreate
アクションに追加し、サインイン成功後にリダイレクトする。redirect_back_or
メソッドでは、次のようにor演算子||を使う。
session[:forwarding_url] || default
このコードは、値がnilでなければsession[:forwarding_url]
を評価し、そうでなければデフォルトのURLを使っている。また、redirect_back_or(default)
メソッドでは、session.delete(:forwarding_url)
という行を通して転送用のURLを削除している点にも注意。これをしておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまう。あちゃー
ちなみに、最初にredirect
文を実行しても、セッションが削除される点を覚えておくとよい!実は、明示的にreturn
文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しない。したがって、redirect文の後にあるコードでも、そのコードは実行される。
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 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
チュートリアルでは@user
ではなくuser
が使われているが、users_login_test
でエラーが発生するので@user
を使うことにする。
これで、フレンドリーフォワーディング用統合テストもパスする。