この章では、Usersリソース用のRESTアクションのうち、これまで未実装だった
edit
、update
、index
、destroy
アクションを加え、RESTアクションを完成させる。
##ユーザーの更新・表示・削除
まず、ユーザーが自分のプロフィールを自分で更新できるようにする。
8章で実装した認証用のコードを使うので、
認可モデル(Authorization Model)について学習。
次に、すべてのユーザーを一覧できるようにし、認証を要求しつつ
ページネーション(ページ分割機能)を導入する。
最後に、ユーザーを削除し、DBから完全に消去する機能を追加する。
ユーザーの削除はどのユーザーにも許可させる訳ではない。
管理ユーザーを作成し、このユーザだけ削除を許可するようにする。
##ユーザーを更新する
git checkout -b updating-users
ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている。
- 新規ユーザー用のビューを出力するnewアクションと同じように、ユーザーを編集するためのeditアクションを作成。
- POSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成。
最大の違いは、ユーザー登録は誰でも実行できるが、ユーザー情報を更新できるのは
そのユーザー自身に限られるということ。
第8章で実装した認証機構を使えば、before filterを使ってこのアクセス制御を実現できる。
###編集フォーム
下記モックアップを動かすには、Usersコントローラにeditアクションを追加して、
それに対応するeditビューを実装する必要がある。
まずはeditアクションの実装から始めるが、ここではDBから適切なユーザーデータを
読み込む必要がある。
画像: 10章 図 10.1: ユーザー編集ページのモックアップ
ここで注意なのはユーザー編集ページの正しいURLが/users/1/edit
となっている点。
ユーザーのidはparams[:id]変数で取り出すことができる。
def edit
@user = User.find(params[:id])
end
次に、ユーザー編集ページに対応するビューを作成
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_with(model: @user, local: true) 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="https://gravatar.com/emails" target="_blank">change</a>
</div>
</div>
</div>
上のコードでは、newビューで使ったerror_messagesパーシャルを再利用している。
Gravatarへのリンクでtarget="_blank"が使われているが、これを使うとリンク先を新しいタブ(またはウィンドウ)で開くようになるので、別のWebサイトへリンクするときなどに便利。
ただしtarget="_blank"にはセキュリティ上の小さな問題もある。(後で解説)
@userインスタンス変数を使うと、編集ページがうまく描画されるようになる。
(Railsによって名前やメールアドレスのフィールドに値が自動的に入力されるようになる)
これらの値は、editアクションでDBから@user変数の属性情報から引き出されているため。
<form accept-charset="UTF-8" action="/users/1" class="edit_user"
id="edit_user_1" method="post">
<input name="_method" type="hidden" value="patch" /> #入力フィールドに隠し属性があることに注目。
ブラウザはそのままだとPATCHリクエストを送信できないので、
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを「偽造」している。
patchの値がhidden属性によって、_methodという名前と共に、隠して送信される。
<微妙な点>
form_with(@user)のコードはnewビュー(new.html.erb)と完全に同じ。
Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのか?
-> Railsは、ユーザーが新規なのか、DBに存在する既存のユーザーであるかを、
Active Recordのnew_record?論理値メソッドを使って区別している。
$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false
Railsは、form_with(@user)を使ってフォームを構成すると、
@user.new_record?
がtrueのときにはPOSTを、falseのときにはPATCHを使う。
仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新。
edit_user_path
という名前付きルートと、
current_user
というヘルパーメソッドを使うと、実装が簡単。
<%= link_to "Settings", edit_user_path(current_user) %>
これをheaderパーシャルに差し込み、レイアウトの “Settings” リンクを更新する。
<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>
###編集の失敗
ユーザー登録に失敗したときと似た方法で、編集に失敗した場合について扱う。
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 update
@user = User.find(params[:id])
if @user.update(user_params)
# 更新に成功した場合を扱う。
else
render 'edit'
end
end
updateアクションの作成
updateを使って送信されたparamsハッシュに基いてユーザーを更新する。
無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。
この構造はcreateアクションの最初のバージョンと似ている。
update
への呼び出しでuser_params
を使っていることに注目
Strong Parametersを使ってマスアサインメントの脆弱性を防止している。
Userモデルのバリデーションとエラーメッセージのパーシャルが既にあるので、
無効な情報を送信すると役立つエラーメッセージが表示されるようになる。
###編集失敗時のテスト
編集失敗時の統合テストを生成する。
$ rails generate integration_test users_edit
最初は編集失敗時の簡単なテストを追加
①編集ページにアクセスし、editビューが描画されるかどうかをチェック。
②無効な情報を送信してみて、editビューが再描画されるかどうかをチェック。
ここで、PATCHリクエストを送るためにpatch
メソッドを使っていることに注目
-> getやpost、deleteメソッドと同じように、HTTPリクエストを送信するためのメソッド。
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で編集を成功させる
編集フォームが動作するようにする。
プロフィール画像の編集は、画像のアップロードをGravatarに任せてあるので、既に動作する。
changeボタンをクリックすれば、Gravatarを編集できる。
ここで、より快適にテストするためには、アプリ用のコードを実装する前に統合テストを書いた方が便利なので、
テスト駆動開発を使ってユーザーの編集機能を実装していく。
まずは、ユーザー情報を更新する正しい振る舞いをテストで定義。
次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェック。
また、DB内のユーザー情報が正しく変更されたかどうかも検証。
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
パスワードとパスワード確認が空であることに注目。
ユーザー名やメールアドレスを編集時に毎回パスワードを入力するのは不便。パスワードを入力せずに更新できると便利。
テスト駆動開発では先にテストを書くので、効果的なユーザー体験について考えるようになる。
テストが成功する必要のあるupdateアクションは、createアクションの最終的なフォームとほぼ同じ。
def update
@user = User.find(params[:id])
if @user.update(user_params)
flash[:success] = "Profile updated"
redirect_to @user
else
render 'edit'
end
end
パスワードやパスワード確認の欄を空にしておくと、
パスワードの長さに対するバリデーションに引っかかってしまう恐れがあるため
このテストはまだ失敗する。
パスワードのバリデーションに対して、空だったときの例外処理を加える必要があり、
Userモデルのバリデーション(validates
)に、allow_nil :true
というオプションを使う。
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: true
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
新規ユーザー登録時、空のパスワード(nil)は有効にはならない。
has_secure_password
ではオブジェクト生成時に存在性を検証するようになっているため、
空のパスワード(nil)が新規ユーザー登録時に有効になることはない。
(空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordによるバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグがあったが、これで解決する。)
テスト成功。
###認可
認証(authentication)はユーザーを識別することであり、
認可(authorization)はユーザーが実行可能な操作を管理すること。
第8章で認証システムを構築したことで、認可のためのシステムを実装する準備ができている。
editアクションとupdateアクションは、1点セキュリティ上の問題がある。
どのユーザーでもあらゆるアクションにアクセスできるため、誰でもユーザー情報を編集できてしまう。
今回はユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。
また、ログインしていないユーザーが保護されたページにアクセスしようとした際のケースについて対処していく。
こういったケースはアプリケーションを使っていると普通に起こることなので、
ログインページに転送時に分かりやすいメッセージも表示させる。
許可されていないページに対してアクセスするログイン済みのユーザーがいたら、ルートURLにリダイレクトさせるようにする。
画像: [10章 図 10.6: 保護されたページにアクセスしたときのページのモックアップ]
###ユーザーにログインを要求する
転送させる仕組みを実装したいときは、Usersコントローラの中でbeforeフィルターを使う。
beforeフィルターは、before_action
メソッドを使い、何らかの処理が実行される直前に特定のメソッドを実行する仕組み。
ユーザーにログインを要求するために、logged_in_user
メソッドを定義して、
before_action:logged_in_user
という形式で使う。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:edit, :update]
# beforeアクション
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
flash[:danger] = "Please log in."
redirect_to login_url
end
end
end
デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用される。
ここでは適切な:onlyオプション(ハッシュ)を渡すことで、:editと:updateアクションだけに
このフィルタが適用されるように制限をかけています。
beforeフィルターを使って実装した結果は、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスして確認できる。
テストはこの時点で失敗する。
原因は、editアクションやupdateアクションでログインを要求するようになったので、
ログインしていないユーザーだと失敗するようになったため。
このため、editアクションやupdateアクションをテストする前にログインしておく必要がある。
解決策はlog_in_as
ヘルパーを使うこと。
test "unsuccessful edit" do
log_in_as(@user)
get edit_user_path(@user)
test "successful edit" do
log_in_as(@user)
get edit_user_path(@user)
これでテストは成功する。
次に、セキュリティモデルに関する実装を取り外してもテストが失敗する想定だが、
実際にコメントアウトして確かめると、テストが成功してしまう。
beforeフィルターをコメントアウトして巨大なセキュリティーホールが作られたら、テストスイートでそれを検出できるべきです。つまり、リスト 10.19のコードは red にならなければいけないのです。テストを書いて、この問題に対処しましょう。
beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書く。
正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行
flashにメッセージが代入されたか
ログイン画面にリダイレクトされたかどうかを確認。
適切なリクエストは
edit -> GET
update -> PATCH
を割り当てる。
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
beforeフィルターのコメントアウトを元に戻して、今度はテストが成功する。
###正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにする必要がある。
ユーザーの情報が互いに編集できないことを確認するため、サンプルユーザーをもう一人追加する。
セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発を採用。
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にリダイレクトしている点に注意。
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
別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので
correct_user
というメソッドを作成し、beforeフィルターからこのメソッドを呼び出す。
beforeフィルターのcorrectuserで@user変数を定義し、editとupdateの各アクションから、
@userの代入文を削除している点にも注意。
before_action :correct_user, only: [:edit, :update]
# 正しいユーザーかどうか確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url) unless @user == current_user
end
これでテストは成功する。
最後にリファクタリングとして、current_user?という論理値を返すメソッドを実装する。
correct_user
というbefore filterの中で使えるようにしたいので、
Sessionsヘルパーの中にこのメソッドを追加する。
unless @user == current_user
を
unless current_user?(@user)
にする。
# 渡されたユーザーがカレントユーザーであればtrueを返す
def current_user?(user)
user && user == current_user
end
先ほどのメソッドを使って比較演算していた行を置き換える
# 正しいユーザーかどうか確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url) unless current_user?(@user)
end
###フレンドリーフォワーディング
後1つ小さなキズがある。保護されたページにアクセスしようとすると、問答無用で
自分のプロフィールページに移動させられてしまう。
理想は、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、
ログイン後にはその編集ページにリダイレクトされるようにするのが望ましい動作。
リダイレクト先は、ユーザーが開こうとしていたページにしてあげる。
フレンドリーフォワーディングのテストはシンプルに書くことができる。
ログインした後に編集ページへアクセスする、という順序を逆にする。
<実際のテスト>
- 編集ページにアクセス
- ログイン
- 編集ページ(デフォルトPFページではない)にリダイレクトされているかどうかをチェック
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
テストは失敗させた。
これでようやくフレンドリーフォワーディングを実装する準備ができた。
ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、
その場所にリダイレクトさせる必要がある。
この動作を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?
end
転送先のURLを保存する仕組みは、session変数を使う。
store_location
メソッドでは、 リクエストが送られたURLを
session変数の:forwarding_urlキーに格納している(GETリクエストが送られたときだけ)。
これで、ログインしていないユーザーがフォームを使って送信した場合に
転送先のURLを保存させないようにできる。
例えば、ユーザがセッション用のcookieを手動で削除してフォームから送信するケースが考えられる。
稀なケースだが起こり得る。
こういったケースに対処しておかないと、POST、PATCH、DELETEリクエストを期待しているURLに対して、
(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。
先ほど定義したstore_locationメソッドを使い、beforeフィルター(logged_in_user)を修正する。
# beforeアクション
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
store_location
flash[:danger] = "Please log in."
redirect_to login_url
end
end
フォワーディング自体を実装するには、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文の後にあるコードでも、そのコードは実行される。
#フレンドリーフォワーディングを備えたcreateアクション
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 # userの前のページもしくはdefaultにリダイレクト
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
これで、フレンドリーフォワーディング用統合テストは成功する。