目的
ユーザーが自分のプロフィールを自分で更新できるようにする
10.1 .1 編集フォーム
編集フォームを作成する
・editアクションの実装
users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
reset_session
log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new', status: :unprocessable_entity
end
end
def edit
@user = User.find(params[:id])
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
・ユーザーのeditビュー
<% 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) 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>
↓
出力されるeditフォームのHTML
<form action="/users/1" accept-charset="UTF-8" method="post">
<input type="hidden" name="_method" value="patch" />
.
.
.
</form>
・入力フィールドの隠し属性
<input name="_method" type="hidden" value="patch" />
Webブラウザは、RESTの慣習として要求されているPATCHリクエストをそのままでは送信できないので
POSTリクエストと隠しinputフィールドを利用して、PATCHリクエストを偽造している
・新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエスト
form_with(@user)は新規でも編集でも同じものを使用している
新規と編集は
Active Recordのnew_record?論理値メソッドで区別している
→form_with(@user)を使ってフォームを構成すると、
@user.new_record?がtrueの場合はPOSTを使い、falseの場合はPATCHを使う
$ rails console
>> User.new.new_record?
=> true
>> User.first.new_record?
=> false
・ナビゲーションバーにあるユーザー設定へのリンクを更新
<%= link_to "Settings", edit_user_path(current_user) %>
_header.html.erb
<header class="navbar navbar-fixed-top navbar-inverse">
<div class="container">
<%= link_to "sample app", root_path, id: "logo" %>
<nav>
<div class="navbar-header">
<button id="hamburger" type="button" class="navbar-toggle collapsed">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<ul id="navbar-menu"
class="nav navbar-nav navbar-right collapse navbar-collapse">
<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="#" id="account" class="dropdown-toggle">
Account <b class="caret"></b>
</a>
<ul id="dropdown-menu" 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,
data: { "turbo-method": :delete } %>
</li>
</ul>
</li>
<% else %>
<li><%= link_to "Log in", login_path %></li>
<% end %>
</ul>
</nav>
</div>
</header>
10.1.2 編集の失敗
編集に失敗した場合について実装する
updateを使って送信されたparamsハッシュに基いてユーザーを更新
↓
無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリング
users_controller.rb
class UsersController < ApplicationController
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
reset_session
log_in @user
flash[:success] = "Welcome to the Sample App!"
redirect_to @user
else
render 'new', status: :unprocessable_entity
end
end
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
if @user.update(user_params)
# 更新に成功した場合を扱う
else
render 'edit', status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
end
10.1.3 編集失敗時のテスト
editビューがレンダリングされるかどうかをチェック
↓
その後、無効な情報を送信してみて、editビューが再描画されるかどうかをチェック
PATCHリクエストを送るためにpatchメソッドを使っている
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
10.1.4 TDDで編集を成功させる
編集フォームが動作するようにする
ユーザー情報を更新する正しい振る舞いをテストで定義する
フラッシュメッセージが空でないかどうか確認
↓
プロフィールページにリダイレクトされるかどうかをチェック
↓
データベース内のユーザー情報が正しく変更されたかどうかも検証
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
updateアクションに更新成功時の処理を追記
users_controller.rb
class UsersController < ApplicationController
.
.
.
def update
@user = User.find(params[:id])
if @user.update(user_params)
flash[:success] = "Profile updated"
redirect_to @user
else
render 'edit', status: :unprocessable_entity
end
end
.
.
.
end
・パスワードが空のままでも更新できるようにする
has_secure_passwordでは追加したバリデーションとは別に、
オブジェクト生成時に存在性を検証するようになっているため、
空のパスワード(nil)が誤って新規ユーザー登録時に有効になることはない
allow_nil: true
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: true
has_secure_password
validates :password, presence: true, length: { minimum: 6 }, allow_nil: true
.
.
.
end
10.2 認可
認可のためのシステムを実装する
・ログインしていないユーザーが保護されたページにアクセスしようとする場合について対処する
ユーザーをログインページに転送し、わかりやすいメッセージも表示する
10.2.1 ユーザーにログインを要求する
・beforeフィルター
before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組み
※デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるため
:onlyオプション(ハッシュ)を渡すことで、このフィルタが:editと:updateアクションだけに
適用されるように制限をかけている
今後どこかの時点で :destroyアクションを保護するのに使われる可能性があるので、
ステータスコード:see_otherを含めることで将来のコードを保証している
・セキュリティモデルに関する実装を取り外してもテストが green になる
テストスイートでそれを検出できるようにする
正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行し、flashにメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認する
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
10.2.2 正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにする
・log_in_asメソッドを使って、editアクションとupdateアクションをテストする
間違ったユーザーが編集しようとしたときのテスト
テストコード
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
プロダクトコード
リダイレクトの後にstatus :see_otherを追加することで、フィルタがdestroyアクションの保護に使われても正常に動くようにし、将来のコードを保証している
beforeフィルターを使って編集/更新ページを保護する
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(user_params)
flash[:success] = "Profile updated"
redirect_to @user
else
render 'edit', status: :unprocessable_entity
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, status: :see_other
end
end
# 正しいユーザーかどうか確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url, status: :see_other) unless @user == current_user
end
end
・リファクタリング
current_user?という論理値を返すメソッドを実装
sessions_helper.rb
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
# セッションリプレイ攻撃から保護する
# 詳しくは https://bit.ly/33UvK0w を参照
session[:session_token] = user.session_token
end
# ユーザーを永続セッションに保存する
def remember(user)
user.remember
cookies.permanent.encrypted[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
# 記憶トークンのcookieに対応するユーザーを返す
def current_user
if (user_id = session[:user_id])
user = User.find_by(id: user_id)
if user && session[:session_token] == user.session_token
@current_user = user
end
elsif (user_id = cookies.encrypted[:user_id])
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
# 渡されたユーザーがカレントユーザーであればtrueを返す
def current_user?(user)
user && user == current_user
end
.
.
.
end
・ユーザーにセッショントークンのメソッドを追加
user.rb
class User < ApplicationRecord
.
.
.
# 永続化セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
remember_digest
end
# セッションハイジャック防止のためにセッショントークンを返す
# この記憶ダイジェストを再利用しているのは単に利便性のため
def session_token
remember_digest || remember
end
.
.
.
end
・プロダクトコード書き換え
users_controller.rb
# 正しいユーザーかどうか確認
def correct_user
@user = User.find(params[:id])
redirect_to(root_url, status: :see_other) unless current_user?(@user)
end
10.2.3 フレンドリーフォワーディング
保護されたページにユーザーがアクセスしようとすると
問答無用で自分のプロフィールページに移動させられるため、
直前にユーザーが開こうとしていたページにリダイレクトするよう実装する
・フレンドリーフォワーディングのテスト
編集ページにアクセスした後、
ログイン後に、デフォルトのプロフィールページではなく
編集ページにリダイレクトされているかどうかをチェックする
test/integration/
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
・リクエストされたページを保管する
リクエストが送られたURLをsession変数の:forwarding_urlキーに保存
(GETリクエストが送られたときだけ)
if request.get?という条件文で考慮している
ヘルパー
sessions_helper.rb
module SessionsHelper
.
.
.
# アクセスしようとしたURLを保存する
def store_location
session[:forwarding_url] = request.original_url if request.get?
end
end
コントローラー
users_controller.rb
# ログイン済みユーザーかどうか確認
def logged_in_user
unless logged_in?
store_location
flash[:danger] = "Please log in."
redirect_to login_url, status: :see_other
end
end
フォワーディング自体を実装するには、リクエストされたURLが存在する場合はそこにリダイレクトし、存在しない場合は何らかのデフォルトURLにリダイレクトするようにする
・転送先URLが存在する場合は次のように最初にセッションを取得しておく
セッション固定攻撃の対処のために
forwarding_url = session[:forwarding_url]
reset_session
log_in user
・転送先URLが存在する場合はそこにリダイレクトし、転送先URLがnilの場合はユーザーのプロフィールにリダイレクトできるようになる
redirect_to forwarding_url || user
sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
forwarding_url = session[:forwarding_url]
reset_session
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
log_in user
redirect_to forwarding_url || user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new', status: :unprocessable_entity
end
end
.
.
.
end
10.3 全てのユーザーを表示する
すべてのユーザーを一覧表示するindexアクションを追加する
10.3.1 ユーザーの一覧ページ
ユーザーのshowページについては、ユーザーがログインしているかどうかに関わらず、サイトにアクセスするすべてのユーザーから見えるようにして、
ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限する
・indexアクションのリダイレクトをテスト
users_controller_test.rb
require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other_user = users(:archer)
end
test "should get new" do
get signup_path
assert_response :success
end
test "should redirect index when not logged in" do
get users_path
assert_redirected_to login_url
end
.
.
.
end
・beforeフィルターのlogged_in_userにindexアクションを追加して、アクションを保護する
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update]
before_action :correct_user, only: [:edit, :update]
def index
end
def show
@user = User.find(params[:id])
end
.
.
.
end
・すべてのユーザーを表示するために、
全ユーザーを保存する変数を作成して、ユーザー一覧を表示するindexビューを実装する
users_controller.rb
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update]
.
.
.
def index
@users = User.all
end
.
.
.
end
ユーザーのindexビュー
eachメソッドを使い実装
各ユーザーの行をリストタグulで囲み、その中にユーザーのGravatar画像と名前を表示する
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_forヘルパーにオプション引数を追加
デフォルト以外のサイズを指定するオプションを渡す
users_helper.rb
module UsersHelper
# 引数で与えられたユーザーのGravatar画像を返す
def gravatar_for(user, options = { size: 80 })
size = options[:size]
gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
image_tag(gravatar_url, alt: user.name, class: "gravatar")
end
end
10.3.2 サンプルのユーザー
indexページにrubyを使用して複数のユーザーを表示する
・サンプルユーザーを生成するRubyスクリプト(Railsタスク)を追加
Example Userという名前とメールアドレスを持つユーザー1人の他に、
それらしい名前とメールアドレスを持つユーザーを99人作成している
seeds.rb
# メインのサンプルユーザーを1人作成する
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
・データベースをリセットして、Railsタスクを実行
$ rails db:migrate:reset
$ rails db:seed
10.3.3 ページネーション
1つのページで一度に表示できるユーザーを設定する
・will_paginateメソッド
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 %>
・paginateメソッド
will_paginateではpaginateメソッドで得た結果が必要のため
indexアクションでUsersをページネートする
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
10.3.4 ユーザー一覧のテスト
ユーザーの一覧ページのテストを作成
1ログイン
2indexページにアクセス
3最初のページにユーザーがいることを確認
4ページネーションのリンクがあることを確認
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
10.3.5 パーシャルのリファクタリング
ユーザー一覧ページのリファクタリングを行う
・ユーザーのliをrender呼び出しに置き換える
index.html.erb
<% provide(:title, 'All users') %>
<h1>All users</h1>
<%= will_paginate %>
<ul class="users">
<% @users.each do |user| %>
<%= render user %>
<% end %>
</ul>
<%= will_paginate %>
・renderを@users変数に対して直接実行する
<% provide(:title, 'All users') %>
<h1>All users</h1>
<%= will_paginate %>
<ul class="users">
<%= render @users %>
</ul>
<%= will_paginate %>
10.4 ユーザーを削除する
ユーザーを削除するためのリンクを追加する
10.4.1 管理ユーザー
特権を持つ管理ユーザーを識別するために、論理値型のadmin属性をUserモデルに追加
rails generate migration add_admin_to_users admin:boolean
seeds.rb
# メインのサンプルユーザーを1人作成する
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true)
・Strong Parameters
paramsハッシュに対してrequireとpermitを呼び出す
def user_params
params.require(:user).permit(:name, :email, :password,
:password_confirmation)
end
10.4.2 destroyアクション
destroyアクションへのリンクを追加
ユーザー削除用リンクの実装(管理者にのみ表示される)
_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, data: { "turbo-method": :delete,
turbo_confirm: "You sure?" } %>
<% end %>
</li>
"turbo-method": :delete
リンクに必要なDELETEリクエストを発行する準備をする
turbo_confirm: "You sure?"
JavaScriptのconfirmボックスを表示する
10.4.3 ユーザー削除のテスト
ユーザー削除のテストを作成する
2ケース確認する
ユーザーがログインしていない場合は、ログイン画面にリダイレクトされること
ログイン済みであっても管理者でない場合は、ホーム画面にリダイレクトされること
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_response :see_other
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_response :see_other
assert_redirected_to root_url
end
end
assert_no_differenceメソッドを使って、
ユーザー数が変化しないことを確認している
・管理者が削除リンクをクリックしたときに、ユーザーが削除されたことを確認する
assert_differenceメソッドでユーザーが削除されたことを確認
→DELETEリクエストを適切なURLに向けて発行し、ユーザー数が1減ったかどうかをUser.countで確認している
リダイレクトURLとHTTPステータスコードが正しいことも検証
→ーユーザーを1人削除したら、redirect users_urlで同じページにリダイレクトする
ここではdeleteリクエストを使っているので、ステータスコードは:see_otherになる
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)
assert_response :see_other
assert_redirected_to users_url
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
感想
ユーザー情報の更新について学びました!
普段使っているユーザー情報ページでのバリデートやリダイレクトなどの仕組みを知ることができてとても勉強になりました!