4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Ruby on Rails Tutorial 10章 メモ

Posted at

基本用語

認証(authentication)

サイトのユーザーを識別すること。

認可(authorization)

そのユーザーが実行可能な操作を管理すること。

文法

target="_blank"

target="_blank"を使うとリンク先を新しいタブ(またはウィンドウ)で開くようになるので、別のWebサイトへリンクするときなどに便利。
rel属性に"noopener"を設定すると、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えなくなり、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入させられてしまう可能性がなくなる。

<a href="http://gravatar.com/emails" target="_blank" rel="noopener">change</a>

toggle!メソッド

属性の状態をfalseからtrueに反転させる。

rails console --sandbox
>> user = User.first
>> user.admin?
=> false
>> user.toggle!(:admin)
=> true
>> user.admin?
=> true

ユーザーを更新する

編集フォーム

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def edit
    @user = User.find(params[:id])
  end
  .
  .
  .
end
app/views/users/edit.html.erb
 <% provide(:title, "Edit user") %>
<h1>Update your profile</h1>

<div class="row">
  <div class="col-md-6 col-md-offset-3">
    <%= form_for(@user) do |f| %>
      <%= render 'shared/error_messages' %>

      <%= f.label :name %>
      <%= f.text_field :name, class: 'form-control' %>

      <%= f.label :email %>
      <%= f.email_field :email, class: 'form-control' %>

      <%= f.label :password %>
      <%= f.password_field :password, class: 'form-control' %>

      <%= f.label :password_confirmation, "Confirmation" %>
      <%= f.password_field :password_confirmation, class: 'form-control' %>

      <%= f.submit "Save changes", class: "btn btn-primary" %>
    <% end %>

    <div class="gravatar_edit">
      <%= gravatar_for @user %>
      <a href="http://gravatar.com/emails" target="_blank">change</a>
    </div>
  </div>
</div>

editフォームのformタグは以下のように表示されている。

<form accept-charset="UTF-8" action="/users/1" class="edit_user"
      id="edit_user_1" method="post">
  <input name="_method" type="hidden" value="patch" />
  .
  .
  .
</form>

WebブラウザはネイティブではPATCHリクエスト(RESTの慣習として要求されている)を送信できないので、RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを「偽造」している。

<input name="_method" type="hidden" value="patch" />

new_record?論理値メソッド

上のform_for(@user)のコードは、app/views/users/new.html.erbのform_for(@user)のコードと完全に同じ。
だとすると、Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのか。
Railsは、ユーザーが新規なのか、それともデータベースに存在する既存のユーザーであるかを、Active Recordのnew_record?論理値メソッドを使って区別している。

app/views/layouts/_header.html.erb
 <header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", '#' %></li>
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li> # ユーザー設定へのリンクを更新する
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

編集の失敗

update_attributesを使って送信されたparamsハッシュに基いてユーザーを更新する。無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。
update_attributesへの呼び出しでuser_paramsを使っており、ここではStrong Parametersを使ってマスアサインメントの脆弱性を防止している。

app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def create
    @user = User.new(user_params)
    if @user.save
      log_in @user # ユーザー登録中にログインする
      flash[:success] = "Welcome to the Sample App!"
      redirect_to @user
    else
      render 'new'
    end
  end
  
  def edit
    @user = User.find(params[:id])
  end
  
  def update
    @user = User.find(params[:id])
    if @user.update_attributes(user_params) # Strong Parametersを使ってマスアサインメントの脆弱性を防止している
      # 更新に成功した場合を扱う。
    else
      render 'edit' # 編集の失敗したとき
    end
  end
  
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end
end

編集失敗時のテスト

rails generate integration_test users_edit

編集失敗時の簡単なテストを追加する。
まず編集ページにアクセスし、editビューが描画されるかどうかをチェックする。
その後、無効な情報を送信してみて、editビューが再描画されるかどうかをチェックする。
ここで、PATCHリクエストを送るためにpatchメソッドを使っているが、これはgetやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。

test/integration/users_edit_test.rb
 require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "unsuccessful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    patch user_path(@user), params: { user: { name:  "",
                                              email: "foo@invalid",
                                              password:              "foo",
                                              password_confirmation: "bar" } }

    assert_template 'users/edit'
  end
end

TDDで編集を成功させる

より快適にテストをするためには、アプリケーション用のコードを「実装する前に」統合テストを書いた方が便利。
そういったテストのことは「受け入れテスト (Acceptance Tests)」として呼ばれていて、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている。
今回はテスト駆動開発を使ってユーザーの編集機能を実装してみる。

まずユーザー情報を更新する正しい振る舞いをテストで定義する。
次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェックする。
また、データベース内のユーザー情報が正しく変更されたかどうかも検証する。
パスワードとパスワード確認が空であるのは、ユーザー名やメールアドレスを編集するときに毎回パスワードを入力するのは不便なので、(パスワードを変更する必要が無いときは)パスワードを入力せずに更新できると便利。
また、@user.reloadを使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している。(こういった正しい振る舞いというのは一般に忘れがちだが、受け入れテスト(もしくは一般的なテスト駆動開発)では先にテストを書くので、効果的なユーザー体験について考えるようになる。)

test/integration/users_edit_test.rb
 require 'test_helper'

class UsersEditTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "successful edit" do
    get edit_user_path(@user)
    assert_template 'users/edit'
    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
app/controllers/users_controller.rb
 class UsersController < ApplicationController
  .
  .
  .
  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
  .
  .
  .
end
app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
  .
  .
  .
end

このテストはまだredのままになっているはず。
パスワードの長さに対するバリデーションがあるので、パスワードやパスワード確認の欄を空にしておくとこれに引っかかってしまうから。
テストが greenになるためには、パスワードのバリデーションに対して、空だったときの例外処理を加える必要がある。
こういったときに便利なallow_nil: trueというオプションがあるので、これを validatesに追加する。

app/models/user.rb
 class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name, presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }, allow_nil: true # パスワードが空のままでも更新できるようにする
  .
  .
  .
end

新規ユーザー登録時に空のパスワードが有効になってしまうのかと心配になるかもしれないが、has_secure_passwordでは(追加したバリデーションとは別に)オブジェクト生成時に存在性を検証するようになっているため、空のパスワード(nil)が新規ユーザー登録時に有効になることはない。

認可

認証(authentication)はサイトのユーザーを識別することであり、認可(authorization)はそのユーザーが実行可能な操作を管理すること。
認証システムを構築したことで、認可のためのシステムを実装する準備もできた。

editアクションとupdateアクションはすでに完全に動作していますが、セキュリティ上の大穴が1つ空いている。
どのユーザーでもあらゆるアクションにアクセスできるため、誰でも(ログインしていないユーザーでも)ユーザー情報を編集できてしまう。
ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御してみる(こういったセキュリティ上の制御機構をセキュリティモデルと呼ぶ)。

ログインしていないユーザーが保護されたページにアクセスしようとした際は、ログインページに転送して、そのときに分かりやすいメッセージも表示するようにする。
一方で、許可されていないページに対してアクセスするログイン済みのユーザーがいたら(例えば他人のユーザー編集ページにアクセスしようとしたら)、ルートURLにリダイレクトさせるようにする。

ユーザーにログインを要求する

before_actionメソッド

Usersコントローラの中でbeforeフィルターを使う。
beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組み。
今回はユーザーにログインを要求するために、logged_in_userメソッドを定義してbefore_action :logged_in_userという形式で使う。

デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるので、ここでは適切な:onlyオプション (ハッシュ) を渡すことで、:editと:updateアクションだけにこのフィルタが適用されるように制限をかけていく。

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

before_actionメソッドのテスト

今の段階ではテストは redになる。
原因は、editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストが失敗するようになったため。
このため、editアクションやupdateアクションをテストする前にログインしておく必要がある。
解決策は簡単で、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) # log_in_asヘルパーを使う
    get edit_user_path(@user)
    .
    .
    .
  end

  test "successful edit" do
    log_in_as(@user) # log_in_asヘルパーを使う
    get edit_user_path(@user)
    .
    .
    .
  end
end

これでテストスイートがパスするようになったが、実はbeforeフィルターの実装はまだ終わっていない。
今のままでは、セキュリティモデルに関する実装を取り外してもテストがgreenになってしまう。
beforeフィルターをコメントアウトして巨大なセキュリティーホールが作られたら、テストスイートでそれを検出できるべき。
テストを書いて、この問題に対処する。

beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書いていく。
具体的には、正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行させてみて、flashにメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認する。

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

2つ目のテストでは、patchメソッドを使ってuser_path(@user)にPATCHリクエストを送信している。前述したように、このリクエストはUsersコントローラのupdateアクションへと適切に繋いでくれる。

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

当然のことだが、ログインを要求するだけでは十分ではない。
ユーザーが自分の情報だけを編集できるようにする必要がある。
ここでは、セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。
まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加する。

test/fixtures/users.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

archer: # fixtureファイルに2人目のユーザーを追加する
  name: Sterling Archer
  email: duchess@example.gov
  password_digest: <%= User.digest('password') %>

次に、log_in_asメソッドを使って、editアクションとupdateアクションをテストする。
このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルート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フィルターからこのメソッドを呼び出すようにする。
beforeフィルターのcorrect_userで@user変数を定義しているため、editとupdateの各アクションから、@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 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

最後に、リファクタリングではあるが、一般的な慣習に倣ってcurrent_user?という論理値を返すメソッドを実装する。
correct_userの中で使えるようにしたいので、Sessionsヘルパーの中にこのメソッドを追加する。

# リファクタリング前
unless @user == current_user

# リファクタリング後
unless current_user?(@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
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 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) # current_user?メソッドを使う
    end
end

フレンドリーフォワーディング

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

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

実際のコードは少し複雑だが、フレンドリーフォワーディングのテストは非常にシンプルに書くことができる。
ログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけ。
実際のテストはまず編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく)編集ページにリダイレクトされているかどうかをチェックするといったテスト。(なお、リダイレクトによってedit用のテンプレートが描画されなくなったので、該当するテストを削除しています。)

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

フレンドリーフォワーディングの実装

ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
この動作をstore_locationとredirect_back_orの2つのメソッドを使って実現してみる。

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

転送先のURLを保存する仕組みは、8.2.1でユーザーをログインさせたときと同じで、session変数を使う。
また、requestオブジェクトも使っている(request.original_urlでリクエスト先が取得できる)。

store_locationメソッド

store_locationメソッドでは、リクエストが送られたURLをsession変数の:forwarding_urlキーに格納している。
ただし、GETリクエストが送られたときだけ格納するようにしておく。
これによって、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにできる。
これは稀なケースですが起こり得る。
例えばユーザがセッション用のcookieを手動で削除してフォームから送信するケースなど。
こういったケースに対処しておかないと、POSTやPATCH、DELETEリクエストを期待しているURLに対して、(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。
このため、if request.get?という条件文を使ってこのケースに対応している。

先ほど定義したstore_locationメソッドを使って、早速beforeフィルター(logged_in_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
  .
  .
  .
  private

    def user_params
      params.require(:user).permit(:name, :email, :password,
                                   :password_confirmation)
    end

    # beforeアクション

    # ログイン済みユーザーかどうか確認
    def logged_in_user
      unless logged_in?
        store_location # 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

redirect_back_orメソッド

フォワーディング自体を実装するには、redirect_back_orメソッドを使う。
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトする。
デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトする。
redirect_back_orメソッドでは、次のようにor演算子||を使う。

session[:forwarding_url] || default

このコードは、値がnilでなければsession[:forwarding_url]を評価し、そうでなければデフォルトのURLを使っている。
また、redirect_back_orメソッドでは、session.delete(:forwarding_url)という行を通して転送用のURLを削除している。
これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまう。
ちなみに、最初にredirect文を実行しても、セッションが削除される。
実は、明示的にreturn文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しない。
したがって、redirect文の後にあるコードでも、そのコードは実行される。

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

すべてのユーザーを表示する

ユーザーアクションであるindexアクションを追加する。
このアクションは、すべてのユーザーを一覧表示する。
その際、データベースにサンプルデータを追加する方法や、将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネーション(pagination=ページ分割)を使う。
また、管理者権限を新たに実装し、ユーザーの一覧ページから(管理者であれば)ユーザーを削除できる機能も実装していく。

ユーザーの一覧ページ

ユーザーのshowページについては、今後も(ログインしているかどうかに関わらず)サイトを訪れたすべてのユーザーから見えるようにしておくが、ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限する。

indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書く。

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 index when not logged in" do # indexアクションのリダイレクトをテストする
    get users_path
    assert_redirected_to login_url
  end
  .
  .
  .
end
app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update] # indexアクションにはログインを要求する
  before_action :correct_user,   only: [:edit, :update]

  def index
    @users = User.all
  end

  def show
    @user = User.find(params[:id])
  end
  .
  .
  .
end
app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

Gravatarヘルパーにデフォルト以外のサイズを指定するオプションを渡す。

app/helpers/users_helper.rb
 module UsersHelper

  # 渡されたユーザーのGravatar画像を返す
  def gravatar_for(user, options = { size: 80 })
    gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
    size = options[:size]
    gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
    image_tag(gravatar_url, alt: user.name, class: "gravatar")
  end
end
app/assets/stylesheets/custom.scss
 .
.
.
/* Users index */

.users {
  list-style: none;
  margin: 0;
  li {
    overflow: auto;
    padding: 10px 0;
    border-bottom: 1px solid $gray-lighter;
  }
}

サイト内移動用のヘッダーにユーザー一覧表示用のリンクを追加する。

app/views/layouts/_header.html.erb
 <header class="navbar navbar-fixed-top navbar-inverse">
  <div class="container">
    <%= link_to "sample app", root_path, id: "logo" %>
    <nav>
      <ul class="nav navbar-nav navbar-right">
        <li><%= link_to "Home", root_path %></li>
        <li><%= link_to "Help", help_path %></li>
        <% if logged_in? %>
          <li><%= link_to "Users", users_path %></li> # ユーザー一覧ページへのリンクを更新する
          <li class="dropdown">
            <a href="#" class="dropdown-toggle" data-toggle="dropdown">
              Account <b class="caret"></b>
            </a>
            <ul class="dropdown-menu">
              <li><%= link_to "Profile", current_user %></li>
              <li><%= link_to "Settings", edit_user_path(current_user) %></li>
              <li class="divider"></li>
              <li>
                <%= link_to "Log out", logout_path, method: :delete %>
              </li>
            </ul>
          </li>
        <% else %>
          <li><%= link_to "Log in", login_path %></li>
        <% end %>
      </ul>
    </nav>
  </div>
</header>

サンプルのユーザー

indexページに複数のユーザーを表示させるためには、ブラウザからユーザー登録ページへ行って手作業で1人ずつ追加するという方法もできるが、せっかくなのでRubyを使ってユーザーを一気に作成してみる。

まず、GemfileにFaker gemを追加する。
これは、実際にいそうなユーザー名を作成するgem。
ちなみにfaker gemは開発環境以外では普通使わないが、今回は例外的に本番環境でも適用させる予定なので、次のようにすべての環境で使えるようにしている。

source 'https://rubygems.org'

gem 'rails',          '5.1.4'
gem 'bcrypt',         '3.1.11'
gem 'faker',          '1.7.3'
.
.
.

サンプルユーザーを生成するRubyスクリプト(Railsタスクとも呼ぶ)を追加してみる。
Railsではdb/seeds.rbというファイルを標準として使う。

db/seeds.rb
 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar")

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

Example Userという名前とメールアドレスを持つ1人のユーザと、それらしい名前とメールアドレスを持つ99人のユーザーを作成する。
create!は基本的にcreateメソッドと同じものだが、ユーザーが無効な場合にfalseを返すのではなく例外を発生させる点が異なる。
こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になる。

データベースをリセットして、Railsタスクを実行(db:seed)してみる。

rails db:migrate:reset
rails db:seed

データベース上にデータを追加するのは遅くなりがちで、システムによっては数分かかることもあり得る。
また、何人かの読者からの報告によると、Railsサーバーを動かしている状態だとrails db:migrate:resetコマンドがうまく動かない時もある。
db:seedでRailsタスクを実行し終わると、サンプルアプリケーションのユーザーが100人になっている。

ページネーション

今の状態では、1つのページに大量のユーザーが表示されてしまっている。
これを解決するのがページネーション(pagination)というもので、この場合は、例えば1つのページに一度に30人だけユーザーを表示するというもの。

Railsには豊富なページネーションメソッドがある。
今回はその中で最もシンプルかつ堅牢なwill_paginateメソッドを使ってみる。
これを使うためには、Gemfileにwill_paginate gem とbootstrap-will_paginate gemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する必要がある。

source 'https://rubygems.org'

gem 'rails',                   '5.1.4'
gem 'bcrypt',                  '3.1.11'
gem 'faker',                   '1.7.3'
gem 'will_paginate',           '3.1.6'
gem 'bootstrap-will_paginate', '1.0.0'

ページネーションが動作するには、ユーザーのページネーションを行うようにRailsに指示するコードをindexビューに追加する必要がある。
また、indexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もある。

app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %> # ページネーション

<ul class="users">
  <% @users.each do |user| %>
    <li>
      <%= gravatar_for user, size: 50 %>
      <%= link_to user.name, user %>
    </li>
  <% end %>
</ul>

<%= will_paginate %> # ページネーション

will_paginateメソッドは少々不思議なことに、usersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成する。
ただし、ビューはこのままでは動かない。
これは、現在の@users変数にはUser.allの結果が含まれているが、will_paginateではpaginateメソッドを使った結果が必要だから。
必要となるデータの例は次のとおり。

rails console
>> User.paginate(page: 1)
  User Load (1.5ms)  SELECT "users".* FROM "users" LIMIT 30 OFFSET 0
   (1.7ms)  SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1,...

paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る。
User.paginateは、:pageパラメーターに基いて、データベースからひとかたまりのデータ(デフォルトでは30)を取り出す。
したがって、1ページ目は1から30のユーザー、2ページ目は31から60のユーザーといった具合にデータが取り出す。
ちなみにpageがnilの場合、 paginateは単に最初のページを返す。

paginateを使うことで、サンプルアプリケーションのユーザーのページネーションを行えるようになる。
具体的には、indexアクション内のallをpaginateメソッドに置き換える。
ここで:pageパラメーターにはparams[:page]が使われているが、これはwill_paginateによって自動的に生成される。

app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update]
  .
  .
  .
  def index
    @users = User.paginate(page: params[:page])
  end
  .
  .
  .
end

will_paginateをユーザーリストの上と下の両方に配置してあるので、ページネーションのリンクもページの上と下の両方に表示される。

ユーザー一覧のテスト

ページネーションに対する簡単なテストも書いておく。
今回のテストでは、ログイン、indexページにアクセス、最初のページにユーザーがいることを確認、ページネーションのリンクがあることを確認、といった順でテストしていく。
最後の2つのステップでは、テスト用のデータベースに31人以上のユーザーがいる必要がある。

ユーザー用fixtureファイルのpassword_digest属性で使ったように、fixtureでは埋め込みRubyをサポートしている。
これを利用してさらに30人のユーザーを追加してみる。
なお、今後必要になるので、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') %>

lana:
  name: Lana Kane
  email: hands@example.gov
  password_digest: <%= User.digest('password') %>

malory:
  name: Malory Archer
  email: boss@example.gov
  password_digest: <%= User.digest('password') %>

<% 30.times do |n| %>
user_<%= n %>:
  name:  <%= "User #{n}" %>
  email: <%= "user-#{n}@example.com" %>
  password_digest: <%= User.digest('password') %>
<% end %>

fixtureファイルができたので、indexページに対するテストを書いてみる。
まずは、いつものように統合テストを生成する。

rails generate integration_test users_index
test/integration/users_index_test.rb
 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    User.paginate(page: 1).each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
    end
  end
end

パーシャルのリファクタリング

Railsにはコンパクトなビューを作成するための素晴らしいツールがいくつもある。
ここではそれらのツールを使って一覧ページのリファクタリング(動作を変えずにコードを整理すること)を行うことにする。

リファクタリングの第一歩は、ユーザーのliをrender呼び出しに置き換えること。

app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <% @users.each do |user| %>
    <%= render user %> # render呼び出しに置き換える
  <% end %>
</ul>

<%= will_paginate %>

ここでは、renderをパーシャル (ファイル名の文字列) に対してではなく、Userクラスのuser変数に対して実行している。
この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探しにいくので、このパーシャルを作成する必要がある。

app/views/users/_user.html.erb
 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
</li>

ここで終わらせず、さらに改良してみる。
今度はrenderを@users変数に対して直接実行する。

app/views/users/index.html.erb
 <% provide(:title, 'All users') %>
<h1>All users</h1>

<%= will_paginate %>

<ul class="users">
  <%= render @users %> # 
</ul>

<%= will_paginate %>

Railsは@users をUserオブジェクトのリストであると推測する。
さらに、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力する。
これにより、コードは極めてコンパクトになる。

ユーザーを削除する

あとはdestroyを実装することで、RESTに準拠した正統なアプリケーションとなる。
ここでは、ユーザーを削除するためのリンクを追加し、削除を行うのに必要なdestroyアクションも実装する。
しかしその前に、削除を実行できる権限を持つ管理(admin)ユーザーのクラスを作成する。

管理ユーザー

特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。
こうすると自動的にadmin?メソッド (論理値を返す) も使えるようになるので、これを使って管理ユーザーの状態をテストできる。

マイグレーションを実行してadmin属性を追加する。
ターミナル上で、この属性の型をbooleanと指定する。

rails generate migration add_admin_to_users admin:boolean

マイグレーションを実行するとadminカラムがusersテーブルに追加される。
default: falseという引数をadd_columnに追加している。
これは、デフォルトでは管理者になれないということを示すため(default: false引数を与えない場合、 adminの値はデフォルトでnilになるが、これはfalseと同じ意味なので、必ずしもこの引数を与える必要はない。ただし、このように明示的に引数を与えておけば、コードの意図をRailsと開発者に明確に示すことができる)。

db/migrate/[timestamp]_add_admin_to_users.rb
 class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

Railsコンソールで動作を確認すると、期待どおりadmin属性が追加されて論理値をとり、さらに疑問符の付いたadmin?メソッドも利用できるようになっている。

rails console --sandbox
>> user = User.first
>> user.admin?
=> false
>> user.toggle!(:admin)
=> true
>> user.admin?
=> true

ここではtoggle!メソッドを使って admin属性の状態をfalseからtrueに反転している。

db/seeds.rb
 User.create!(name:  "Example User",
             email: "example@railstutorial.org",
             password:              "foobar",
             password_confirmation: "foobar",
             admin: true) # サンプルデータ生成タスクに管理者を1人追加する

99.times do |n|
  name  = Faker::Name.name
  email = "example-#{n+1}@railstutorial.org"
  password = "password"
  User.create!(name:  name,
               email: email,
               password:              password,
               password_confirmation: password)
end

データベースをリセットして、サンプルデータを再度生成する。

rails db:migrate:reset
rails db:seed

Strong Parameters、再び

上のコードでは、初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしていた。
ここでは、Web世界にオブジェクトをさらすことの危険性を改めて強調する。
もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくるかもしれない。

patch /users/17?admin=1

このリクエストは、17番目のユーザーを管理者に変えてしまう。
ユーザーのこの行為は、少なくとも重大なセキュリティ違反となる可能性があるし、実際にはそれだけでは済まない。

このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になる。
そのため、Strong Parametersを使って対策する。
具体的には、 次のようにparamsハッシュに対してrequireとpermitを呼び出す。

def user_params
  params.require(:user).permit(:name, :email, :password,
                               :password_confirmation)
end

上のコードでは、許可された属性リストにadminが含まれていない。
これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
この問題は重大であるため、編集可能になってはならない属性に対するテストを作成することをぜひともオススメする。

admin属性のテスト

Web経由でadmin属性を変更できないことを確認してみる。
具体的には、PATCHを直接ユーザーのURL (/users/:id) に送信するテストを作成してみる。

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 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

  test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: {
                                    user: { password:              @other_user.password,
                                            password_confirmation: @other_user.password,
                                            admin: true } }
    assert_not @other_user.reload.admin?
  end
  .
  .
  .
end

destroyアクション

destroyアクションへのリンクの追加

Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加する。
まず、ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。
これによって、現在のユーザーが管理者のときに限り[delete]リンクが表示されるようになる。

app/views/users/_user.html.erb
 <li>
  <%= gravatar_for user, size: 50 %>
  <%= link_to user.name, user %>
  <% if current_user.admin? && !current_user?(user) %>
    | <%= link_to "delete", user, method: :delete,
                                  data: { confirm: "You sure?" } %>
  <% end %>
</li>

必要なDELETEリクエストを発行するリンクの生成は、method: :deleteによって行われている。
また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしている。

ブラウザはネイティブではDELETEリクエストを送信できないため、RailsではJavaScriptを使って偽造する。
つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になる。
JavaScriptをサポートしないブラウザをサポートする必要がある場合は、フォームとPOSTリクエストを使ってDELETEリクエストを偽造することもできる。
こちらはJavaScriptがなくても動作する。

destroyアクションの追加

削除リンクが動作するためには、destroyアクションを追加する必要がある。
このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーindexに移動する。
ユーザーを削除するためにはログインしていなくてはならないので、:destroyアクションもlogged_in_userフィルターに追加している。

app/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 destroy
    User.find(params[:id]).destroy
    flash[:success] = "User deleted"
    redirect_to users_url
  end

  private
  .
  .
  .
end

destroyアクションのアクセス制御

destroyアクションでは、findメソッドとdestroyメソッドを1行で書くために2つのメソッドを連結 (chain) している。

User.find(params[:id]).destroy

結果として、管理者だけがユーザーを削除できるようになる (より具体的には、削除リンクが見えているユーザーのみ削除できる)。
しかし、実はまだ大きなセキュリティホールがある。
ある程度の腕前を持つ攻撃者なら、コマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができる。
サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要がある。
これを実装してようやく、管理者だけがユーザーを削除できるようにする。

今回はbeforeフィルターを使ってdestroyアクションへのアクセスを制御する。

app/controllers/users_controller.rb
 class UsersController < ApplicationController
  before_action :logged_in_user, only: [:index, :edit, :update, :destroy]
  before_action :correct_user,   only: [:edit, :update]
  before_action :admin_user,     only: :destroy
  .
  .
  .
  private
    .
    .
    .
    # 管理者かどうか確認
    def admin_user
      redirect_to(root_url) unless current_user.admin?
    end
end

ユーザー削除のテスト

ユーザーを削除するといった重要な操作については、期待された通りに動作するか確かめるテストを書くべき。
そこで、まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にしてみる。

test/fixtures/users.yml
 michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>
  admin: true
 .
 .
 .

Usersコントローラをテストするために、アクション単位でアクセス制御をテストする。
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。
このとき2つのケースをチェックする。
1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされること。
もう1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされること。

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 destroy when not logged in" do
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to login_url
  end

  test "should redirect destroy when logged in as a non-admin" do
    log_in_as(@other_user)
    assert_no_difference 'User.count' do
      delete user_path(@user)
    end
    assert_redirected_to root_url
  end
end

このとき、assert_no_differenceメソッドを使って、ユーザー数が変化しないことを確認している。

管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、ユーザー一覧のテストに今回のテストを追加していくことにする。
管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストをすべてまとめると、以下のようになる。

test/integration/users_index_test.rb
 require 'test_helper'

class UsersIndexTest < ActionDispatch::IntegrationTest

  def setup
    @admin     = users(:michael)
    @non_admin = users(:archer)
  end

  test "index as admin including pagination and delete links" do
    log_in_as(@admin)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination'
    first_page_of_users = User.paginate(page: 1)
    first_page_of_users.each do |user|
      assert_select 'a[href=?]', user_path(user), text: user.name
      unless user == @admin
        assert_select 'a[href=?]', user_path(user), text: 'delete'
      end
    end
    assert_difference 'User.count', -1 do
      delete user_path(@non_admin)
    end
  end

  test "index as non-admin" do
    log_in_as(@non_admin)
    get users_path
    assert_select 'a', text: 'delete', count: 0
  end
end

各ユーザーの削除リンクをテストするときに、ユーザーが管理者であればスキップしている。
これはdestroyアクションへのリンクの追加の際、管理者であれば削除リンクが表示されないようになっているから。

詰まったところ

cloud9上のファイルがずっと読み込み状態になる

解決策
Chromeのキャッシュを削除する。
(ログインし直したり、PCを再起動しても直らなかった。)

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?