基本用語
認証(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
ユーザーを更新する
編集フォーム
class UsersController < ApplicationController
.
.
.
def edit
@user = User.find(params[:id])
end
.
.
.
end
<% 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?論理値メソッドを使って区別している。
<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を使ってマスアサインメントの脆弱性を防止している。
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リクエストを送信するためのメソッド。
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を使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している。(こういった正しい振る舞いというのは一般に忘れがちだが、受け入れテスト(もしくは一般的なテスト駆動開発)では先にテストを書くので、効果的なユーザー体験について考えるようになる。)
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
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
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に追加する。
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アクションだけにこのフィルタが適用されるように制限をかけていく。
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ヘルパーを使うこと。
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にメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認する。
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アクションへと適切に繋いでくれる。
正しいユーザーを要求する
当然のことだが、ログインを要求するだけでは十分ではない。
ユーザーが自分の情報だけを編集できるようにする必要がある。
ここでは、セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。
まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加する。
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にリダイレクトしている。
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への代入文を削除している。
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)
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
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用のテンプレートが描画されなくなったので、該当するテストを削除しています。)
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つのメソッドを使って実現してみる。
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)を修正してみる。
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文の後にあるコードでも、そのコードは実行される。
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アクションが正しくリダイレクトするか検証するテストを書く。
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
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
<% 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ヘルパーにデフォルト以外のサイズを指定するオプションを渡す。
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
.
.
.
/* Users index */
.users {
list-style: none;
margin: 0;
li {
overflow: auto;
padding: 10px 0;
border-bottom: 1px solid $gray-lighter;
}
}
サイト内移動用のヘッダーにユーザー一覧表示用のリンクを追加する。
<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というファイルを標準として使う。
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を、ページネーションを理解できるオブジェクトに置き換える必要もある。
<% 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によって自動的に生成される。
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人の名前付きユーザーも一緒に追加している。
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
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呼び出しに置き換えること。
<% 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という名前のパーシャルを探しにいくので、このパーシャルを作成する必要がある。
<li>
<%= gravatar_for user, size: 50 %>
<%= link_to user.name, user %>
</li>
ここで終わらせず、さらに改良してみる。
今度はrenderを@users変数に対して直接実行する。
<% 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と開発者に明確に示すことができる)。
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に反転している。
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) に送信するテストを作成してみる。
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]リンクが表示されるようになる。
<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フィルターに追加している。
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アクションへのアクセスを制御する。
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ファイルを修正し、今いるサンプルユーザーの一人を管理者にしてみる。
michael:
name: Michael Example
email: michael@example.com
password_digest: <%= User.digest('password') %>
admin: true
.
.
.
Usersコントローラをテストするために、アクション単位でアクセス制御をテストする。
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。
このとき2つのケースをチェックする。
1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされること。
もう1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされること。
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メソッドを使って、ユーザー数が変化しないことを確認している。
管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、ユーザー一覧のテストに今回のテストを追加していくことにする。
管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストをすべてまとめると、以下のようになる。
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を再起動しても直らなかった。)