#近況報告
エンジニア転職成功しました。YouTubeもはじめました。
著者略歴
著者:YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目
#第10章 ユーザーの更新・表示・削除 難易度 ★★★ 5時間
挫折しないRailsチュートリアルの進め方を先にお読みください↓↓
この章ではUsersリソース用のRESTアクションのうち、これまで未実装だった
- edit
- update
- index
- destroy
アクションを加えてRESTアクションを完成させる。
まずはユーザーが自分のプロフィールを更新できるようにする。
その際、8章で実装した認証用のコードを使う(認可モデル(Authorization Model)についても考える)
次に、全てのユーザーを一覧できるようにする。
ここでページネーションと呼ばれるページ分割機能を導入する。
最後に、ユーザーを削除し、DBから完全に消去する機能を追加する。
ユーザーの削除には、管理ユーザーという特権クラスを作成し、このユーザーにのみ削除を許可する。
##10.1 ユーザーを更新する
ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている。
例えば、新規ユーザー用のビューはnewアクションで、createアクションへPOSTリクエストしていたが、
ユーザー情報を編集する場合は、newの代わりにeditアクション、createアクションの代わりにPATCHリクエストに対応するupdateアクションを作成する。
ここでの最大の違いは、ユーザーの登録は誰でも実行できるが、
ユーザー情報を更新できるのはそのユーザー自身に限られるということ。
8章で実装した認証機構を使えば、beforeフィルダーを使ってこのアクセス制御を実現できるとのこと。
git checkout -b updating-users
###10.1.1 編集フォーム
編集フォームのモックアップ
このようなページを動かすには、Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装する必要がある。
まずはeditアクションの実装から始めるが、ここではDBから適切なユーザーデータを読み込む必要がある。
ここで注意しておきたいのは、Usersリソースが提供するユーザー編集ページの正しいURLガ/users/1/edit
となっている点。
ユーザーのidはparams[:id]
変数で取り出すことができるので、以下のようにeditアクションを指定することで、ユーザーを受け取ることができる。
def edit
@user = User.find(params[:id]) # URLのユーザーidと同じユーザーをDBから取り出して@userに代入
end
次にユーザー編集ページに対応するビューを作成する。
<% provide(:title, "Ebit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<!--formの送信先を指定-->
<%= form_for(@user) do |f| %>
<!--エラーメッセージ-->
<%= render 'shared/error_messages' %>
<!--form作成-->
<%= f.label :name %>
<%= f.text_field :name, class: 'form-control' %>
<%= f.label :email %>
<%= f.text_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :password_contfirmation, "Confirmation" %>
<%= f.password_field :password_confirmation, class: "form-control" %>
<%= f.submit "Create my account", class: "btn btn-primary" %>
<% end %>
<!--編集完了ボタンと画像を表示-->
<div class="grabatar_edit">
<%= gravatar_for @user %>
<a fref="http://gravatar.com/emails" target="_blank">編集</a>
</div>
</div>
</div>
上のコードでは、newビューで使ったerror_messages
パーシャルを再利用している。
一度設定したので、エラーメッセージは使い回しでOK。
また、Gravatarへのリンクでtarget="_blank"
を使っているが、これを使うと新しいタブが開くようになっているが、セキュリティ上の問題もある。(後の演習で解説する)
@userインスタンス変数を使ったことで、編集ページうまく描画されていることを確認する。
ここで、NameやEmailの部分に名前やメールアドレスのフィールドの値が自動的に入力されていることが分かる。
これは、editアクションでDBからidのユーザーを取り出して、fブロック変数で一個一個のフォームに入れてるからである。
ここで、HTMLソースを確認。
<form class="edit_user" id="edit_user_1" action="/users/1" accept-charset="UTF-8" method="post">
<input name="utf8" type="hidden" value="✓" />
<input type="hidden" name="_method" value="patch" />
さらに、入力フィールドに隠し属性があることに注目。
<input type="hidden" name="_method" value="patch" />
WebブラウザはネイティブではPATCHリクエストを送信できない。
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを偽造している。
(単語集のhiddenを参照)
patchの値がhidden属性によって、_method
という名前と共に、隠して送信される。
ここでもう一つ、form_for(@user)のコードは、newビューと完全に同じ。
しかし、Railsはどうやって新規ユーザー用のPOSTリクエストと
ユーザー編集用のPATCHリクエストを区別するのか。
その答えは、Railsは新規ユーザーか、既存のDBにいるユーザーか、
Active Recordのnew_record?
論理値メソッドを使って区別できるから。
Rails Consoleで確認してみる。
>> User.new.new_record?
=> true
>> User.first.new_record?
User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> false
Railsは、form_for(@user)
を使ってフォームを構成すると、@user.new_record?
がtrueの時にはPOSTリクエスト、falseのときにはPATCHリクエストを使う。
仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新する。
Usersリソースの名前付きルートである、edit_user_path
と、current_user
というヘルパーメソッドを使うと、実装が簡単。
<%= link_to "Settings", edit_user_path(current_user) %>
これを、headerパーシャルに差し込む。
<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> <!-- ログインURLを自動生成-->
<% end %>
</ul>
</nav>
</div>
</header>
演習
1:target="_blank"
で新しいページを開くときには、セキュリティ上の小さな問題がある。
それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点。
例えばフィッシングサイトのような悪意のあるサイトを導入させられてしまう恐れがあるので、
それを防ぐために、aタグのrel(relationship)属性にrel="noopener"
と設定するだけ。
Gravatarの編集ページへのリンクにこの設定をしてみる。
<!--編集完了ボタンと画像を表示-->
<div class="grabatar_edit">
<%= gravatar_for @user %>
<a fref="http://gravatar.com/emails" target="_blank" rel="noopener">編集</a>
</div>
<% provide(:title, 'Sign up') %>
<% provide(:button_text, "新規登録") %>
<h1>新規登録</h1>
<%= render 'form' %>
2:_form.html.erb
のパーシャルを使って、new.html.erb
ビューと、edit.html.erb
ビューをリファクタリングしてみる。三章で使った、provide
メソッドを使って重複を取り除く。
<!--ボタンと画像を表示-->
<div class="row">
<div class="col-md-6 col-md-offset-3">
<!-- formの送信先を指定 -->
<%= form_for(@user) do |f| %> <!-- 、fブロックに代入-->
<%= render 'shared/error_messages', object: @user %> <!-- エラーメッセージ用のパーシャルを表示 -->
<!-- form作成-->
<%= f.label :name %> <!-- Userモデルの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 yield(:button_text), class: "btn btn-primary" %>
<% end %>
</div>
</div>
<% provide(:title, 'Sign up') %>
<% provide(:button_text, "新規登録") %>
<h1>新規登録</h1>
<%= render 'form' %>
これで綺麗にまとまった。
###10.1.2 編集の失敗
ユーザー登録に失敗した時と似た方法で、編集に失敗した場合について扱っていく。
まずはupdateアクションの作成から進める。
update_attributes
を使って送信されたparmasハッシュに基づいて、ユーザーを更新する。
無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。
この構造は、create
アクションの最初のバージョンと極めて似通っている。
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
# 更新に成功した場合を扱う
else
render 'edit'
end
end
更新に失敗したらelseに分岐して編集ページをレンダリング。
update_attributes
での呼び出しで、user_params
を引数に取っている点に注目
7章で扱ったStrong Parametersを使って、マスアサインメントの脆弱性を防止している。
Userモデルのバリデーション、エラーメッセージのパーシャルは既にあるので
無効な情報を送信するとエラーメッセージが表示されるようになった。
演習
1:編集フォームから有効でない値を送信して、編集に失敗することを確認。
確認済み。
###10.1.3 編集失敗時のテスト
編集失敗のエラーを検知するための統合テストを書いていく。
とりあえずまずは統合テストを生成
$ rails g integration_test users_edit
最初のテストは、まず編集ページにアクセスし、editビューが描画されるかどうかをチェック。
次に、無効な情報を送信してみて、editビューが再描画されるかどうかをチェック。
require 'test_helper'
class UsersEditTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
test "unsuccessful edit" do
get edit_user_path(@user) # userIDを取得(michael)
assert_template 'users/edit' # editビューが描画できてたらtrue
patch user_path(@user), params: { user: { name: "", # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
email: "foo@invalid",
password: "foo",
password_confirmation: "bar" } }
assert_template 'users/edit' # editビューが描画できてたらtrue
end
end
ここで、重要なのは、PATCHリクエストを送るためにpatchメソッド
を使っている所。
これはgetやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。
テストを調べる。
FAIL["test_invalid_signup_information", UsersSignupTest, 0.4198383340044529]
test_invalid_signup_information#UsersSignupTest (0.42s)
Expected at least 1 element matching "form[action="/signup"]", found 0..
Expected 0 to be >= 1.
test/integration/users_signup_test.rb:19:in `block in <class:UsersSignupTest>'
28/28: [================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.46134s
28 tests, 67 assertions, 1 failures, 0 errors, 0 skips
エラーやんけ!
実際にnewビューで送信してみると、URLが/users
になる件。
そーいえば以前にもこんなのあったな。
ん、待てよ
<% provide(:url, user_path) %>
<% provide(:url, signup_path) %>
こうやってそれぞれにurlを指定し、
<%= form_for(@user, url: yield(:url)) do |f| %> <!-- 、fブロックに代入-->
ってやればいいんじゃね。
Finished in 0.54764s
8 tests, 31 assertions, 0 failures, 0 errors, 0 skips
よしきた。どうやら前回の演習でミスをしたようだw
####演習
1:assert_select
を使ってalert
クラスのdivタグを探しだし、The form contains 4 errors.
というテキストを精査してみる。
assert_select "div.alert", "The form contains 4 errors."
###10.1.4
今度は編集フォームが動作するようにする。
プロフィール画像の編集は画像アップロードをGravatarに任せてあるので、既に動作するようになっている。
changeボタンをクリックすれば、Gravatarを編集できる。
ここで、より快適にテストするためには、アプリ用のコードを実装する前に統合テストを書いた方が便利ということで、そういった受け入れテストを行う。
受け入れテストは、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている。
今回はテスト駆動開発を使ってユーザーの編集機能を実装してみる。
まずは、ユーザー情報を更新する正しい振る舞いをテストで定義する。
次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェックする。
また、DB内のユーザー情報が正しく変更されたかどうかも検証する。
test "successful edit" do
get edit_user_path(@user) # userIDを取得(michael)
assert_template 'users/edit' # editビューが描画できてたらtrue
name = "Foo Bar" # フォーム欄に値を入力する
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name, # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty? # エラー文が空じゃなければtrue
assert_redirected_to @user # michaelのユーザーidページへ移動できたらtrue
@user.reload
assert_equal name, @user.name # DB内の名前と@userの名前が一致していていたらtrue
assert_equal email, @user.email # DB内のEmailと@userの名前が一致
end
パスワードとパスワード確認が空であることに注目。
ユーザー名やメルアドを編集する時に毎回パスワードを入力するのは不便なので、
わざとパスワードを入力せずに更新している。
また、user.reloadを使ってDBから最新のユーザー情報を読み込み直して、正しく更新されたかどうか確認している点にも注目。
受け入れテストでは先にテストを書くので、効果的なユーザー体験について考えるようになる。
テストにパスする必要のあるupdateアクションは、createアクションの最終的なフォームとほぼ同じである。
def update
@user = User.find(params[:id])
if @user.update_attributes(user_params)
flash[:success] = "プロフィール更新完了"
redirect_to @user
else
render 'edit'
end
end
このテストはまだ失敗する。
理由として、パスワードの長さに対するバリデーションがあるので、パスワードやパスワード確認の欄を空にしておくと引っかかってしまう恐れがあるから。
テストをパスさせるためには、空だった時の例外処理を加える必要がある。
そのために、Userモデルのバリデーションにallow_nil :trueというオプションを使う。
class User < ApplicationRecord
# インスタンス変数の定義
attr_accessor :remember_token
before_save { email.downcase! } #DB保存前にemailの値を小文字に変換する
validates :name, presence: true, length: { maximum: 50 } #nameの文字列が空でなく、50文字以下ならtrue
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i #正規表現でemailのフォーマットを策定し、定数に代入
validates :email, presence: true, length: { maximum: 255 }, #emailの文字列が空でなく、255文字以下ならtrue
format: { with: VALID_EMAIL_REGEX }, #emailのフォーマットを引数に取ってフォーマット通りか検証する。
uniqueness: { case_sensitive: false } #大文字小文字を区別しない(false)に設定する このオプションでは通常のuniquenessはtrueと判断する。
has_secure_password #passwordとpassword_confirmation属性に存在性と値が一致するかどうかの検証が追加される
validates :password, presence: true,length: { minimum: 6 }, allow_nil: true #passwordの文字列が空でなく、6文字以上ならtrue。例外処理に空(nil)の場合のみバリデーションを通す(true)
これでpasswordが空だったとしたらvalidationをスルー(true)する例外処理を加えたが、
has_secure_passwordではDBにレコード(オブジェクト)が生成された時だけ存在性(nilかどうか)のvalidationを行う
性質があるので、実際にpasswordを作成する際は、nilかどうかの検証を行ってくれる。
つまり、実行環境でpasswordが空だった場合のvalidation機能を保持したまま、テストで空だった場合にvalidationを通すことができるという訳。(今回のテストではuserオブジェクトを生成していないので)
また、allow_nilのおかげでhas_secure_passwordによるバリデーションがそれぞれ実行され、二つのエラーメッセージが表示されるバグも解決できた。(オブジェクト生成時と、実際に空かどうかの二つの検証を行っていた為)
ほんとだ。
$ rails t
実際に編集もできた。
演習
1:実際に編集が成功するかどうか、有効な情報を送信して確かめる
確認済み
2:Gravatarと紐づいていない適当なメールアドレスに変更した場合、画像がどう変化するか確認
Gravatarとの連動も確認
##10.2 認可
第8章ではauthentication(認証)システムを構築することで、サイトのユーザーを識別する機能を加えたが、今回はそのユーザーをauthorization(認可)することで、ログインユーザーのみが実行可能な操作を実現する。
editアクションとupdateアクションにはセキュリティ上の大穴が一つある。
それは、どのユーザーでもあらゆるアクションにアクセスできる為、誰でもユーザー情報を編集出来てしまうところ。
今回はユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御してみる。
さらに、ログインしていないユーザーが保護されたページにアクセスしようとした際に、ログインページに転送して、分かりやすいメッセージを表示させる仕組みも作る。
また、許可されていないページに対してアクセスするログイン済みのユーザーがいたら、ルートURLにリダイレクトさせるようにする。
モックアップはこちら
出典:図 10.6: 保護されたページにアクセスしたときのページのモックアップ
###10.2.1 ユーザーにログインを要求する
モックアップのように転送させる仕組みを実装したい時は、Usersコントローラの中でbeforeフィルター
を使う。
beforeフィルターは、before_action
メソッドを使って、何らかの処理が実行される直前に特定のメソッドを実行する仕組み。
ユーザーにログインを要求するために、logged_in_user
メソッドを定義して、before_action:logged_in_user
という形式で使う。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:edit, :update] # editとupdateアクションにlogged_in_userメソッドを適用
def logged_in_user # ログイン済みユーザーかどうか確認
unless logged_in? # ユーザーがログインしていなければ(false)処理を行う
flash[:danger] = "Please log in." # エラーメッセージを書く
redirect_to login_url # ログインユーザーのidを引数に取ったURLのページへ飛ぶ
end
end
beforeフィルターはデフォルトで、コントローラ内の全てのアクションに適用されるので、:onlyオプションを渡すことで、editとupdateというアクションのみフィルタを適用されるよう制限を掛けた。
beforeフィルターを使って実装した結果、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスしてみることで確認できる。
怒られてloginページに飛ばされた。
また、テストはこの時点で失敗。
理由は、editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストで失敗してしまう。
このため、テストではeditアクションやupdateアクションを実行する前にログインしておく必要がある。
解決策は簡単。test_helperのlog_in_as
ヘルパーを使うだけ。
test "unsuccessful edit" do
log_in_as(@user) # ログインする
get edit_user_path(@user)
test "successful edit" do
log_in_as(@user) # ログインする
これでテストはパスする。
setupメソッド内でログイン処理をまとめてしまうことも可能だが、後のテストでログインする前に編集ページにアクセスするように変更したいので、あえてこうしている。
2 tests, 8 assertions, 0 failures, 0 errors, 0 skips
しかし、実はbeforeフィルターの実装はまだ終わっておらず、セキュリティモデルに関する実装を取り外してもテストが失敗するかどうか、実際にコメントアウトして確かめる必要がある。
#before_action :logged_in_user, only: [:edit, :update] # editとupdateアクションにlogged_in_userメソッドを適用
何とテストが通ってしまった。
$ rails t
これでは問題なので、対処をする。
beforeフィルターは基本的にアクションごとに適用していくので、
Usersコントローラのテストもアクションごとに書いていく。
具体的には
①正しい種類のHTTPリクエストを使う
②editアクションとupdateアクションをそれぞれ実行させてみる
③flashにメッセージが代入されるかどうか検証
④ログイン画面にリダイレクトされたかどうか
を確認する。
HTTPリクエストは、 editにはget、updateにはpatchを割り当てる。
require 'test_helper'
class UsersControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
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? # flashが空でないならtrue
assert_redirected_to login_url # ログインユーザーのidのURLへ飛べたらtrue
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? # flashが空でないならtrue
assert_redirected_to login_url # ログインユーザーのidのURLへ飛べたらtrue
end
end
これで先ほどのbefore_actionをコメントアウトするとテストが失敗する。
$ rails t
test/controllers/users_controller_test.rb:24:in `block in <class:UsersControllerTest>'
test/controllers/users_controller_test.rb:16:in `block in <class:UsersControllerTest>'
11 tests, 41 assertions, 2 failures, 0 errors, 0 skips
どうやらflashメッセージが表示されないのと、ログインページへリダイレクトできないのが原因見たい。
これでコメントアウトを解除すればtrue
$ rails t
11 tests, 41 assertions, 0 failures, 0 errors, 0 skips
演習
1:beforeフィルターを全てに適用して見てテストがそのエラーを検知できるかどうか確認。
$ rails t
FAIL["test_valid_signup_information", UsersSignupTest, 0.35811705499872915]
test_valid_signup_information#UsersSignupTest (0.36s)
"User.count" didn't change by 1.
Expected: 2
Actual: 1
test/integration/users_signup_test.rb:25:in `block in <class:UsersSignupTest>'
FAIL["test_invalid_signup_information", UsersSignupTest, 0.37334744699910516]
test_invalid_signup_information#UsersSignupTest (0.37s)
expecting <"users/new"> but rendering with <[]>
test/integration/users_signup_test.rb:16:in `block in <class:UsersSignupTest>'
FAIL["test_should_get_new", UsersControllerTest, 0.475691736999579]
test_should_get_new#UsersControllerTest (0.48s)
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>'
OK
###10.2.2 正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにする必要がある。
ここではセキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。
したがって、Usersコントローラのテストを補完するように、テストを追加していく。
まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加する。
ユーザー用のfixtureファイルに2人目のユーザーを追加してみる。
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 "should redirect edit when logged in as wrong user" do # @other_userで編集できるか確認
log_in_as(@other_user) # @other_userでログインする
get edit_user_path(@user) # @userの編集ページを取得
assert flash.empty? # flashが空ならtrue
assert_redirected_to root_url # root_urlへ移動できればtrue
end
test "should redirect update when logged in as wrong user" do
log_in_as(@other_user) # @other_userでログインする
patch user_path(@user), params: { user: { name: @user.name, # @userのユーザーページへ、フォームに入力したname値・email値を送信(更新)
email: @user.email } }
assert flash.empty? # flashが空ならtrue
assert_redirected_to root_url # root_urlへ移動できればtrue
end
テストは失敗する。
ログイン済みの@other_userで、@userのページを取得・送信した際にルートURLへリダイレクトさせたいので、
correct_user
というメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。
beforeフィルターのcorrectuserで@user変数を定義し、edit
とupdate
の各アクションから、@user
の代入文を削除している点にも注意。
before_action :correct_user, only: [:edit, :update] # editとupdateアクションにcorret_userメソッドを適用
def correct_user # 正しいユーザーかどうか確認
@user = User.find(params[:id]) # URLのidの値と同じユーザーを@userに代入
redirect_to(root_url) unless @user == current_user # @userと記憶トークンcookieに対応するユーザー(current_user)を比較して、失敗したらroot_urlへリダイレクト
end
ちょっと難しいが、@userと記憶トークンcookieに対応するユーザーとを比較し、値が異なっていればroot_urlへリダイレクトしている。
要は、ログインユーザーとURLに入力されたユーザーが異なっていれば、root_urlに飛ばしますよ〜という処理。
root_urlに飛ばすことで、flash(エラーメッセージ)を無くしている。
これでテストはパスする。
最後にリファクタリングとして、current_user?という論理値を返すメソッドを実装する。
correct_user
の中で使えるようにしたいのでSessionsヘルパーの中にこのメソッドを追加する。
unless @user == current_user
これを
unless current_user?(@user)
とする。
# 渡されたユーザーがログイン済みユーザーであればtrueを返す
def current_user?(user)
user == current_user
end
先ほどのメソッドを使って比較演算していた行を置き換えると
def correct_user # 正しいユーザーかどうか確認
@user = User.find(params[:id]) # URLのidの値と同じユーザーを@userに代入
redirect_to(root_url) unless current_user?(@user) # @userと記憶トークンcookieに対応するユーザー(current_user)を比較して、失敗したらroot_urlへリダイレクト
end
これでテストはパスする。
####演習
1:何故editアクションとupdateアクションを両方とも保護する必要があるのか?
updateアクションにpatchリクエストを送って不正に更新しようとする行為を防止する為
2:上記アクションのうち、どちらがブラウザで簡単にテストできるか?
editやろ
###10.2.3 フレンドリーフォワーディング
Webサイトの認可機能は完成したかのようん見えるが、後1つ小さなキズがあります。
保護されたページにアクセスしようとすると、問題無用で自分のプロフィールページに移動させられてしまう。
例えば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後には、その編集ページにリダイレクトさせてあげるのが望ましい。
リダイレクト先は、ユーザーが開こうとしていたページにしてあげる。
フレンドリーフォワーディングのテストコードは、シンプルに書くことができて、例えばログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけ。
具体的には、
①編集ページにアクセス
②ログイン
③編集ページにリダイレクトされているかどうかチェックする
これを、users_edit_text.rb
で実際に行う
なお、リダイレクトによってedit用のテンプレが描画されなくなったので、edit_testにあるassert_template 'users/edit'
を削除する。
test "successful edit with friendly forwarding" do
get edit_user_path(@user) # @userのユーザー編集ページを取得
log_in_as(@user) # @userでログイン
assert_redirected_to edit_user_path(@user) # @userのユーザー編集ページへ移動する
log_in_as(@user) # ログインする
get edit_user_path(@user) # userIDを取得(michael)
name = "Foo Bar" # フォーム欄に値を入力する
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name, # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty? # エラー文が空じゃなければtrue
assert_redirected_to @user # michaelのユーザーidページへ移動できたらtrue
@user.reload
assert_equal name, @user.name # DB内の名前と@userの名前が一致していていたらtrue
assert_equal email, @user.email # DB内のEmailと@userの名前が一致
end
これでテストは失敗。
これでフレンドリーフォワーディングを実装する準備が整った。
ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
この動作をstore_location
とredirect_back_or
の2つのメソッドを使って実現してみる。
これらのメソッドはSessionsヘルパーで定義している。
# 記憶した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? # :forwarding_urlキーにリクエスト先のURLを、GETリクエストが送られた時だけ代入
end
:forwarding_urlキーへの値の格納で、GETリクエストが送られたときだけ格納するようにする。
こうすることで、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにする。
これは稀なケースだが起こりえる。
例えば、ユーザがセッション用のcookieを手動で削除して、フォームから送信するケース。
こういったケースに対処しておかないと、POSTやPATCH、DELETEリクエストを期待しているURLに対して、GETリクエストが送られてしまい、場合によってはエラーが発生する。
これらの問題を
if request.get?
という条件文を使って対応している。
先ほど定義したstore_location
メソッドを使って、早速beforeフィルターlogged_in_user
を修正してみる。
# ログイン済みユーザーかどうか確認
def logged_in_user # ログイン済みユーザーかどうか確認
unless logged_in? # ユーザーがログインしていなければ(false)処理を行う
store_location # アクセスしようとしたURLを覚えておく
flash[:danger] = "ログインしないとダメですよ" # エラーメッセージを書く
redirect_to login_url # ログインユーザーのidを引数に取ったURLのページへ飛ぶ
end
end
これで、ログインできなかった場合、アクセスしようとしたURLを記憶する。
フォワーディング自体を実装するには、redirect_back_or
メソッドを使う。
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトする。
デフォルトのURLは、Sessionコントローラの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文の後にあるコードでも、そのコードは実行される。
def create
@user = User.find_by(email: params[:session][:email].downcase) # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
if @user && @user.authenticate(params[:session][:password]) # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
log_in @user # sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
params[:session][:remember_me] == '1' ? remember(@user) : forget(@user) # ログイン時、sessionのremember_me属性が1(チェックボックスがオン)ならセッションを永続的に、それ以外なら永続的セッションを破棄する
redirect_back_or @user # userの前のページもしくはdefaultにリダイレクト
else
flash.now[:danger] = 'Invalid email/password combination' # flashメッセージを表示し、新しいリクエストが発生した時に消す
render 'new' # newビューの出力
end
end
*Tutorialでは@userではなくuserが使われているが、使うとusers_login_testでエラーが発生するので@userを使う。
これで、フレンドリーフォワーディング用統合テストもパスする。
$ rails t
5 tests, 20 assertions, 0 failures, 0 errors, 0 skips
####演習
1:フロンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを確認するテストを書く
test "successful edit with friendly forwarding" do
get edit_user_path(@user) # @userのユーザー編集ページを取得
assert_equal session[:forwarding_url], edit_user_url(@user) # 渡されたURLに転送されているか確認
log_in_as(@user) # @userでログイン
assert_nil session[:forwarding_url] # forwarding_urlの値がnilならtrue(deleteが効いてる)
name = "Foo Bar" # フォーム欄に値を入力する
email = "foo@bar.com"
patch user_path(@user), params: { user: { name: name, # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する
email: email,
password: "",
password_confirmation: "" } }
assert_not flash.empty? # エラー文が空じゃなければtrue
assert_redirected_to @user # michaelのユーザーidページへ移動できたらtrue
@user.reload
assert_equal name, @user.name # DB内の名前と@userの名前が一致していていたらtrue
assert_equal email, @user.email # DB内のEmailと@userの名前が一致
end
2:debuggerメソッドを使ってSessionsコントローラのnewアクションに置いてみる。
その後、ログアウトして/users/1/editにアクセス。すると、デバッガーが途中で処理をやめる。
ここでコンソールでsession[:forwarding_url]の値が正しいかどうか、newアクションにアクセスした時のrezuest.get?の値も確認してみる。
(byebug) "https://eac437457e484fe491559aaa135f7f93.vfs.cloud9.us-east-2.amazonaws.com/users/1/edit"
(byebug) true
OK。
##10.3 すべてのユーザーを表示する
全てのユーザーを表示するためのindexアクション(/users users_path)を追加する。
その際、DBにサンプルデータを追加する方法や、将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネーション(ページ分割)の方法を学ぶ。
ユーザーの一覧、ページネーション用リンク、移動用の[Users]リンクのモックアップはこちら
###10.3.1 ユーザーの一覧ページ
実装前にセキュリティモデルについて考えてみる。
ユーザーのshowページはサイトを訪れた全てのユーザーから見えるようにしておくが、
ユーザーのindexページはログインしたユーザーにしか見えないようにし、未登録のユーザーがデフォルトで表示できるページを制限する。
indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするかを検証するテストを書いてみる。
test "should redirect index when not logged in" do
get users_path # index(/users)を取得
assert_redirected_to login_url # ログインページへリダイレクトできたらtrue
end
次に、users_controllerのbeforeフィルターのlogged_in_user
にindexアクションを追加して、ログインしていなければログインページを飛ばす
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update] # editとupdateアクションにlogged_in_userメソッドを適用
before_action :correct_user, only: [:edit, :update] # editとupdateアクションにcorret_userメソッドを適用
def index
end
今度は、usersページで全てのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装する。
Toyアプリケーションにも同じindexアクションがあったことを思い出して、User.all
を使って全DB上のユーザーを取得し、ビューで使えるインスタンス変数@users`に代入させる。
def index
@users = User.all
end
実際のindexページを作成するには、ユーザーを列挙してユーザーごとにliタグで囲むビューを作成する必要がある。
ここではeach
メソッドを使って作成する。
それぞれの行をulで囲いながら、各ユーザーのGravatarと名前を表示する。
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>
<ul class="users">
<% @users.each do |user| %>
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
<% end %>
</ul>
ここでgravatar_for で画像を表示し、デフォルト以外のサイズを指定するオプションを渡している点に注目。
7章の演習でgravatar_forメソッドの引数にオプションでsizeを渡していたので、それを今回は変更している。
Gravatar側の準備が整ったら、SCSSにも手を加える。
/* ユーザー一覧 */
.users {
list-style: none;
margin: 0;
li {
overflow: auto;
padding: 10px 0;
border-bottom: 1px solid $gray-lighter;
}
}
最後に、ヘッダーにユーザー一覧用のリンクを追加。
リンクにはusers_path
(名前付きルート)を割り当てる。
<li><%= link_to "Users", users_path %></li> <!-- ユーザー一覧リンク-->
これでテストは成功。
$ rails t
ユーザー一覧も表示できた。
####演習
1:レイアウトにあるすべてのリンクに対して統合テストを書いてみる。
ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてみる。
require 'test_helper'
class SiteLayoutTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
test "layout links" do
get root_path
assert_template 'static_pages/home'
assert_select "a[href=?]", root_path, count: 2
assert_select "a[href=?]", help_path
assert_select "a[href=?]", about_path
assert_select "a[href=?]", contact_path
assert_select "a[href=?]", login_path
get contact_path
assert_select "title", full_title("Contact")
get signup_path
assert_select "title", full_title("Sign up")
end
def setup
@user = users(:michael)
end
test "layout links when logged in" do # ホームページ用の統合テスト
log_in_as(@user)
get root_path
assert_template 'static_pages/home'
assert_select "a[href=?]", users_path
assert_select "a[href=?]", user_path(@user)
assert_select "a[href=?]", edit_user_path(@user)
assert_select "a[href=?]", logout_path
end
end
全部描画されているのでtrue
$ rails t
2 tests, 13 assertions, 0 failures, 0 errors, 0 skips
###10.3.2 サンプルユーザー
indexページに複数のユーザーを表示させるために、Rubyを使ってユーザーを一気に作成してみる。
その為にGemfileにFaker gem
を追加する。
これは実際にいそうなユーザー名を作成するgem。faker gemは開発環境以外では普通使わないが、今回は例外的に本番環境でも適用させる予定なので、次のようにすべての環境で使えるようにする。
gem 'bootstrap-sass', '3.3.7'
gem 'bcrypt', '3.1.12'
gem 'faker', '1.7.3'
$ bundle update
# bundle install
次に、サンプルユーザーを生成するRubyスクリプト(Railsタスク)を追加してみる。
Railsではdb/seed.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を返すのではなく、例外を発生させる点が異なる。
こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になる。
ここえDBをリセットして、Railsタスクを実行(db:seed)してみる。
$ rails db:migrate:reset
$ rails db:seed
データベース上にデータを追加するのは遅くなりがちで、システムによっては数分掛かることもあり得る。
また、Railsサーバーを動かしている状態だとrails db:migrate:reset
コマンドがうまく動かない時もあるので注意
ここで、ユーザー一覧を確認してみる。
db:seedでRailsタスクを実行し終わると、サンプルアプリケーションのユーザーが100人になっている。
最初のいくつかのメールアドレスはデフォルトのGravatar画像以外の写真を関連付けている。
####演習
1:試しに他人の編集ページにアクセスしてみて、実装したようにリダイレクトされるか確認。
確認済み。
###10.3.3 ページネーション
大量のユーザーを1つのページに表示させているが、これを分割する為にページネーションを行う。
今回は、1ページに30人だけユーザーを表示してみる。
Railsには豊富なページネーションメソッドがあり、今回はwill paginate
メソッドを使って見る。
これを使うために、Gemfile
にwill_paginate
gemとbootstrap-will_paginate
gemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginate
を構成する必要がある。
まずは各gemをGemfileに追加してみる。
gem 'faker', '1.7.3'
gem 'will_paginate','3.1.6'
gem 'bootstrap-will_paginate','1.0.0'
$ bundle install
実行後、新しいgemが正しく読み込まれるように、Webサーバーを再起動する。
ページネーションが動作するには、ユーザーのページネーションを行うようにRailsに指示するコードをindexビューに追加する必要がある。
また、indexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もある。
まずはビューにwill_paginate
メソッドを追加する。
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</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 c
>> User.paginate(page: 1)
User Load (0.9ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 11], ["OFFSET", 0]]
(0.1ms) SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest
paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る。
User.paginate
では、:pageパラメーターに基づいて、DBからひとかたまりのデータ(デフォルトでは30)を取り出す。
したがって、1ページ目は1から30のユーザー、2ページ目は31~60のユーザーと行った具合にデータが取り出される。
ちなみに、pageがnilの場合、paginateは単に最初のページを返す。
paginateを使うことで、サンプルアプリケーションのユーザーのページネーションを行えるようになる。
具体的には、indexアクション内のallをpaginateメソッドに置き換える。
ここで、:page
パラメーターにはparams[:page]
が使われるが、これはwill_paginate
によって自動的に生成される。
def index
@users = User.paginate(page: params[:page]) # Userを取り出して分割した値を@usersに代入
end
以上で、ユーザー一覧ページを確認してみる。
次ページへの遷移も可能。
####演習
1:Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認する。
>> User.paginate(page: nil)
User Load (0.2ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 11], ["OFFSET", 0]]
(0.1ms) SELECT COUNT(*) FROM "users"
=> #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest:
OK
2:paginationオブジェクトは何クラスか確認。また、User.all
のクラスとどこが違うか?
>> user = User.all
>> page = User.paginate(page: 1)
>> user.class
=> User::ActiveRecord_Relation
>> page.class
=> User::ActiveRecord_Relation
ActiveRecord_RelationクラスでUser.allと同じ。
###10.3.4 ユーザー一覧のテスト
これでユーザー一覧ページが動くようになったので、ページネーションに対する簡単なテストを書いておく。
今回のテストでは、
①ログイン
②indexページにアクセス
③最初のユーザーのページにユーザーがいることを確認
④ページネーションのリンクがあることを確認
という順にテストしていく。
最後の2つのステップでは、テスト用のDBに31人以上のユーザーがいる必要がある。
そこで、今回はfixtureに埋め込みRubyを使って31人以上のテストユーザーを作成する。
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 %>
テスト用ユーザーを追加したので、indexページに対するテストを書いていく。
まずは統合テストから作成
$ rails g integration_test users_index
今回のテストでは、paginationクラスを持ったdivタグをチェックして、最初のページにユーザーがいることを確認する。
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 t
36 tests, 121 assertions, 0 failures, 0 errors, 0 skips
####演習
1:ページネーション(will_paginate)をコメントアウトしてテストが失敗するかどうか確認。
確認済み
2:1つだけコメントアウトした場合にテストがパスすることを確認。
また、will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いか?
test "index including pagination" do
log_in_as(@user)
get users_path
assert_template 'users/index'
assert_select 'div.pagination', count: 2
User.paginate(page: 1).each do |user|
assert_select 'a[href=?]', user_path(user), text: user.name
end
end
###10.3.5 パーシャルのリファクタリング
ユーザー一覧ページにページネーションを実装できたが、ここではパーシャルを使ってリファクタリングしていく。
今回は一覧ページのリファクタリング(動作を変えずにコードを整理)を行うことにする。
サンプルアプリケーションのテストは既に完了しているので、Webサイトの機能を損なうことなく安心してリファクタリングに取りかかれる。
リファクタリングの第一歩は、ユーザーのliをrender呼び出しに置き換えること。
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>
<%= will_paginate %>
<ul class="users">
<% @users.each do |user| %>
<%= render user %>
<% end %>
</ul>
<%= will_paginate %>
パーシャル用のファイルを作成し、そこにliタグを置く。
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
さらに、今度はrenderを@users変数に対して直接実行する。
<% provide(:title, 'All users') %>
<h1>ユーザー一覧</h1>
<%= will_paginate %>
<ul class="users">
<%= render @users %>
</ul>
<%= will_paginate %>
@usersをrenderで読み出すと、Railsは自動的に@usersをUserオブジェクトのリストであると推測。
さらに、ユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力する。
つまり、each doメソッドを使わずに、@usersのユーザーを順に取り出して表示することが可能。
$ rails t
15 tests, 79 assertions, 0 failures, 0 errors, 0 skips
テストがパスしたので、ユーザーを取り出せてることを確認した。
#####演習
1: renderの行をコメントアウトし、テストが失敗することを確認。
確認済み。
##10.4 ユーザーを削除する
ユーザー一覧ページが完了したので、残るはdestroyのみ。
これを実装することで、RESTに準拠した正統なアプリケーションとなる。
まずはユーザーを削除するためのリンクを追加する。
次に、削除を行うのに必要なdestroyアクションも実装する。
モックアップはこちら
出典:図 10.13: 削除リンクを追加したユーザー一覧のモックアップ
###10.4.1 管理ユーザー
特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加する。
追加したら、自動的にadmin?
という論理値を返すメソッドも使えるようになる。
これを使って、管理ユーザーの状態をテストできる。
変更後のデータモデル
出典:図 10.14: 論理値をとるadmin属性が追加されたUserモデル
いつものように、マイグレーションを実行してadmin属性を追加する。
属性の型はboolean
とする。
$ rails g migration add_admin_to_users admin:boolean
マイグレーション実行後、adminカラムがusersテーブルに追加される。
default: false
という引数をadd_column
に追加している。
これは、デフォルトでは管理者になれないと明示している。
なお、デフォルトではadminの値はnilなのでfalseを指定せずともいいのだが、あえてfalseを渡すことでコードの意図をRailsと開発者に明確に示せる。
class AddAdminToUsers < ActiveRecord::Migration[5.1]
def change
add_column :users, :admin, :boolean, default: false
end
end
あとは、マイグレーションを実行して変更を反映
$ rails db:migrate
Railsコンソールで動作を確認すると、期待どおりadmin属性が追加されて論理値をとり、さらに疑問符の付いたadmin?メソッドも利用できるようになっている。
>> user = User.first
User Load (0.7ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2019-01-14 19:08:30", updated_at: "2019-01-14 19:08:30", password_digest: "$2a$10$dljD1bDoK.bH4l8WOY0CIueP7hTv.84hOJW76oNEf2w...", remember_digest: nil, admin: false>
>> user.admin?
=> false
>> user.toggle!(:admin)
(0.1ms) SAVEPOINT active_record_1
SQL (3.6ms) UPDATE "users" SET "updated_at" = ?, "admin" = ? WHERE "users"."id" = ? [["updated_at", "2019-01-15 00:50:25.017787"], ["admin", "t"], ["id", 1]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true
これでuserがデフォルトでは管理者ではないことがわかった。
また、toggle!
メソッドでadmin属性の状態をfalseからtrueに反転している。
仕上げに、最初のユーザーだけをデフォルトで管理者にするようサンプルデータを更新する。
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true)
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
次に、DBをリセットして、サンプルデータを再度生成する。
rails db:migrate:reset
rails db:seed
####Strong Parameters 再び
seeds.rbでは初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしている。
ここでは、荒れ狂うWeb世界にオブジェクトを晒すことの危険性を改めて強調している。
もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくるかもしれない。
patch /users/17?admin=1
このリクエストは、17番目のユーザーを管理者に変えてしまう。
ユーザーのこの行為は少なくとも重大なセキュリティ違反となる可能性があるし、それだけでは済まされない。
このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になる。
これを、7章で使ったStrong Parametersを使って対策をする。
次のように、params
ハッシュに対してrequire
とpermit
を呼び出す。
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
上のコードでは、許可された属性リストにadminが含まれていないことに注目。
これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
この問題は重大であるため、編集可能になってはならない属性に対するテストを作成してみる。
####演習
1:Web経由でadmin属性を変更できないことを確認してみる。
具体的には、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみる。
テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_params
メソッド内の許可されたパラメータ一覧に追加するところから始めてみる。
test "should not allow the admin attribute to be edited via the web" do
log_in_as(@other_user) # @other_userでログインする
assert_not @other_user.admin? # @toher_userが管理権限あれば(adminがtrueなら)falseを返す
patch user_path(@other_user), params: { # /users/@other_user へparamsハッシュの中身を送る
user: { password: 'password',
password_confirmation: 'password',
admin: true } }
assert_not @other_user.reload.admin? # @other_userを再読み込みし、admin論理値が変更されてないか検証(falseやnilならtrue)
end
###10.4.2 destroyアクション
Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加する。
まず、ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。
これによって、現在のユーザーが管理者のときに限り[delete]
リンクが表示されるようになる。
<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
アクションを追加する必要がある。
該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーのindexに移動する。
ユーザーを削除するためにはログインしていなければならないので、destroyアクションもlogged_in_user
フィルターに追加する。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy] # logged_in_userメソッドを適用
before_action :correct_user, only: [:edit, :update] # editとupdateアクションにcorret_userメソッドを適用
before_action :admin_user, only: :destroy
def destroy
User.find(params[:id]).destroy
flash[:success] = "削除完了"
redirect_to users_url
end
destroyアクションでは、findメソッドとdestroyメソッドを1行で書くために2つのメソッドを連結している。
結果として、管理者だけがユーザーを削除できるようになる。
(つまり、削除リンクが見えているユーザーのみ削除できる)
しかし、ここでコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができる。
サイトを正しく防衛するためには、destroyアクションにもアクセス制御を行う必要がある。
これを実装してようやく、管理者だけがユーザーを削除できるようにする。
またbeforeアクションを使う。今回は:admin_user
をdestoryアクションのみに適用する。
before_action :admin_user, only: :destroy
def admin_user # 管理者のみに適用
redirect_to(root_url) unless current_user.admin? # 現在のユーザーが管理者でなければroot_urlへリダイレクト
end
####演習
1:管理者ユーザーとしてログインし、試しにサンプルユーザを2~3人削除してみる。
ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるか?
(0.1ms) begin transaction
SQL (2.6ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 3]]
(8.5ms) commit transaction
transaction処理でid=3のユーザーを削除していることが分かる。
###10.4.3 ユーザー削除のテスト
ユーザーを削除するといった重要な操作については、期待された通りに動作するか確かめるテストを書くべき。
まずは、fixtureファイルのmichaelに管理者権限を与える。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
次に、Usersコントローラをテストするために、アクション単位でアクセス制御をテストする。
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。
この時、2つのケースをチェック
①ログインしていないユーザーであれば、ログイン画面にリダイレクトさせる
②ログイン済みであっても管理者でなければ、ホーム画面にリダイレクトさせる。
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
assert_no_difference
メソッドを使って、ユーザー数が変化しないことを確認している点に注目。
管理者ではないユーザーの振る舞いについて検証するが、管理者ユーザーの振る舞いと一緒に確認できるといい。
そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、今回のテストを追加していくことにする。
これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けそう。
今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、
ユーザーが削除されたことを確認する部分。
その部分のテストがこれ。
assert_difference 'User.count', -1 do
delete user_path(@other_user)
end
7章ではassert_difference
メソッドを使ってユーザーが作成されたことを確認していたが、
今回は同じメソッドを使ってユーザーが削除されたことを確認。
具体的には、DELETEリクエストを適切なURLに向けて発行し、User.count
を使ってユーザー数が1減ったかどうかを確認している。
したがって、管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストを全てまとめると、以下のようになる。
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
各ユーザーの削除リンクをテストする時に、ユーザーが管理者であればスキップしている点にも注目。
これは、_user.html.erb
で、ログイン時、現在のユーザーがログインしていて、なおかつ特定のユーザーがログイン成功したユーザーでない場合のみ削除リンクを表示しているから。
結果として、管理者権限のないユーザーに削除リンクが表示されてるため、このテストが通る。
$ rails t
####演習
1:users_controller
の管理者ユーザーのbeforeフィルターをコメントアウトしてテストが失敗するか確認
確認済み。
##10.5 最後に
ログイン/ログアウト、ユーザー一覧画面まで作り、管理者権限を持ったユーザーはユーザーを削除することができる仕組みも実装した。
この時点で、サンプルアプリケーションはWebサイトとしての十分な基盤が整ったと言える。
今後はアカウント有効化機能、パスワード再設定、投稿機能などを実装する。
いつも通りブランチ切ってマージ、Herokuにデプロイ
$ git add -A
$ git commit -m "Finish user edit, update, index, and destroy actions"
$ git checkout master
$ git merge updating-users
$ git push
$ rails test
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
$ heroku restart
seedを走らせることで、ユーザー一覧をローカル環境の標準順序と合わせている。
#単語集
- target="_blank"
新しい窓(ウィンドウ)、又はタブでリンク先を開く
- type="hidden"
hidden属性の中で指定し、name属性で名前をつけ、value属性で送信される値を指定する。
そうすることで、非表示データを送信することができる。
- allow_nil: true
validatesのオプションで、validatesに追加すると、trueならばnilの検証はスキップする。
なお、初期値はfalseである。
使い方
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
上記のように明示的にtrueを指定することで、passwordのバリデーションに引っ掛かったならば、nil(空かどうか)
の検証を行い、trueを返す(バリデーションを通す)
- セキュリティモデル
セキュリティ上の制御機構のこと。
エンドユーザと管理者との間の権限を管理する。
- before_action
何らかの処理を実行させる前に、特定のメソッドを実行させる。
- フレンドリーフォワーディング
ログインしていないユーザーが何らかのページへアクセスした際に、ログイン画面に遷移させて、ユーザーがログイン後にそのページへリダイレクトさせてあげる施策のこと。
- redirect_back_or(default)
引数に渡したデフォルト値を使い、一個前(直前)のページにリダイレクトするメソッド
- default
デフォルト値を設定
- forwarding_url
一個前のURLを記憶するsessionのキー。
使い方
session[:forwarding_url]
- request.original_url
HTTPリクエストでクライアントがサーバーに渡した値を、_urlでURLを取得
- get?
getしたかどうか聞いている。
- permit
引数に取った属性のみ受け取りを許可し、オブジェクトに渡すメソッド。