Help us understand the problem. What is going on with this article?

RailsTutorialの記録と備忘録 #22 『ユーザー更新の認可』

ウェブアプリケーションの文脈では、認証 (authentication) はサイトのユーザーを識別することであり、認可 (authorization) はそのユーザーが実行可能な操作を管理することです。第8章で認証システムを構築したことで、認可のためのシステムを実装する準備もできました。

Authentication」と「Authorization」は違うらしい。

  • Authenticationは今アクセスしているユーザーは誰か
  • Authorizationはそのユーザーが何ができるか

認可(Authorization)

editアクションとupdateアクションは動作するが、どのユーザーでも実行でき、誰でもユーザー情報を編集できてしまう。
ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。

リダイレクト

ログインしていないユーザーが、保護されたページにアクセスしようとした際、ログインページに転送する。
その時にログインを要求するメッセージも表示する。

また、ログイン済みのユーザーが許可されていないページアクセスしたら、ルートURLにリダイレクトさせる。
例えば他人のユーザー編集ページにアクセスするなど。

beforeフィルター

何らかの処理が実行される直前に特定の関数を実行する仕組み。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_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?
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end

logged_in_user関数を定義。
before_action :logged_in_userで実行したい関数を指定する。
onlyオプションでeditアクションとupdateアクションの直前にのみbeforeフィルターを実行するように制限できる。

テストの修正

editupdateでログインを要求するようになったため、今の状態のテストだとエラーになる。

以前定義したlog_in_asを使ってログインする処理を追加する。

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  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
end

ログインしていない状態のテストを作成

今のテストだと、beforeフィルターをコメントアウトしてもテストは正常に通ってしまう。

ログインしていない状態でユーザー編集画面にアクセスした時に正しくリダイレクトされるかのテストを作成する。

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
end

正しいユーザーを要求する

ログインを要求するだけでなく、自分の情報だけを編集できるようにする。
セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進める。

サンプルユーザーを追加

他のユーザーから編集できないことを確認するために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') %>

テストを作成

他のユーザーの編集ページにアクセスしたらルートURLにリダイレクトするテスト

test/controllers/users_controller_test.rb
require 'test_helper'

class UsersControllerTest < ActionDispatch::IntegrationTest

  def setup
    @user       = users(:michael)
    @other_user = users(:archer)
  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
end

コントローラーを修正

先ほど作成したテストをパスするため、ユーザーが編集画面(とその更新)にアクセスしたら本人かどうか確認するcorrect_user関数を定義し、beforeフィルターに追加する。

また、correct_user内で@userインスタンス変数にユーザーを代入しているのでeditupdateアクションの変数代入は不要になる。

app/controllers/users_controller.rb
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

SessionsHelperにcurrent_user?関数追加

慣習的に作成するらしい。
真理値を返すとシンプルになる。

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーをログイン
  def log_in(user)
    session[:user_id] = user.id
  end

  # 永続セッションとしてユーザーを記憶する
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 渡されたユーザーがログイン済みユーザーであればtrueを返す
  def current_user?(user)
    user == current_user
  end

  # 記憶トークン (cookie) に対応するユーザーを返す
  def current_user

    # ...

  end

  # ...

end

Usersコントローラー編集

さきほど作成したcurrent_user?を使う

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]

  # ...

  def edit
  end

  def update
    if @user.update_attributes(user_params)
      flash[:success] = "Profile updated"
      redirect_to @user
    else
      render 'edit'
    end
  end

  # ...

  private
    # ...

    # 正しいユーザーかどうか確認
    def correct_user
      @user = User.find(params[:id])
      redirect_to(root_url) unless current_user?(@user)
    end
end

フレンドリーフォワード

フレンドリーフォワードは、ログインしていないユーザーが認証で保護されたページにアクセスした時、ログインページでログインを促した後にユーザーが開こうとページにアクセスさせること。(たぶん

ここまででWebサイトの認可機能は完成したかのように見えますが、後1つ小さなキズがあります。保護されたページにアクセスしようとすると、問答無用で自分のプロフィールページに移動させられてしまいます。別の言い方をすれば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後にはその編集ページにリダイレクトされるようにするのが望ましい動作です。リダイレクト先は、ユーザーが開こうとしていたページにしてあげるのが親切というものです。

フレンドリーフォワードのテスト

test/integration/users_edit_test.rb
require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  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_url(@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
end

フレンドリーフォワード実装

リクエスト時のページを保存しておき、ログイン後にそのページにリダイレクトさせる必要がある。

SessionsHelper

SessionsHelperstore_locationredirect_back_orを定義

app/helpers/sessions_helper.rb
module SessionsHelper

  # ...

  # 記憶した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
end

before filter修正

ログインしていないユーザーをログイン画面にリダイレクトさせる時に元のURLを保持しておく。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :logged_in_user, only: [:edit, :update]
  before_action :correct_user,   only: [:edit, :update]

  # ...

  def edit
  end

  # ...

  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
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  # ...

  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

  # ...

end
kide
主に備忘録を書いていきます。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away