noggy’s blog

自分用の備忘録です。。。

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変数を定義しているため、editupdateの各アクションから、@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_locationredirect_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を使うことにする。
これで、フレンドリーフォワーディング用統合テストもパスする。