devise の複数モデル管理を試みる

今回は、devise gem での複数モデル管理を試みてみます。

目次

参考

今回作成するもの

user と admin という 2 つのユーザーロールを持ったサイトを作る。
food というリストを参照可能なのが user、すべての操作を可能なのが admin とする。

準備

bundle exec rails sで、いつもの「Yay! You’re on Rails!」が確認できるところまで進めます。

root 用のコントローラとビューを作成

以下のコマンドで、root に割り当てるコントローラとビューを作成。

1
bundle exec rails g controller home index --skip-test-framework --skip-assets

root を変更

作成したhome/indexを root に変更。

config/routes.rb(変更前)
1
2
3
4
Rails.application.routes.draw do
get 'home/index'
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end
config/routes.rb(変更後)
1
2
3
Rails.application.routes.draw do
root to: 'home#index'
end

localhost:3000にアクセスして以下の表示を確認。

gem インストール

以下の様に Gemfile に追記する。

Gemfile
1
2
gem 'devise'
gem 'devise-i18n'

devise をインストール。

1
2
3
bundle install
bundle exec rails g devise:install
#忘れがち注意

devise の設定変更

devise の設定を変更する。

config/initializers/devise.rb(変更前)
1
2
3
#config.scoped_views = false
# 中略
#config.sign_out_all_scopes = true
config/initializers/devise.rb(変更後)
1
2
3
4
config.scoped_views = true
# 複数のmodelで個別のログイン画面を使いたいので変更する
config.sign_out_all_scopes = false
# 複数のモデルを扱う際に、一方をログアウトした時に、もう片方もログアウトすることを防ぐ

devise のモデルの作成

以下のコマンドを実行し devise の model を作成。

1
2
bundle exec rails g devise user
bundle exec rails g devise admin

作成できたら以下のコマンドでマイグレーション。

1
bundle exec rails db:migrate

管理者アカウントになる admin に対して勝手に sign_up されたくないので Admin モデルを修正。
不要部分をコメントアウトする。

app/models/admin.rb(修正前)
1
2
3
4
5
6
class Admin < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
end
app/models/admin.rb(修正後)
1
2
3
4
5
6
class Admin < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable,# :registerable,
:recoverable, :rememberable, :validatable
end

ルーティング修正

ルーティングを修正する。

現在のルーティング

config/routes.rb(修正前)
1
2
3
4
5
Rails.application.routes.draw do
devise_for :admins
devise_for :users
root to: 'home#index'
end

これをbundle exec rails routesで確認する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
                  Prefix Verb   URI Pattern                       Controller#Action
new_admin_session GET /admins/sign_in(.:format) devise/sessions#new
admin_session POST /admins/sign_in(.:format) devise/sessions#create
destroy_admin_session DELETE /admins/sign_out(.:format) devise/sessions#destroy
new_admin_password GET /admins/password/new(.:format) devise/passwords#new
edit_admin_password GET /admins/password/edit(.:format) devise/passwords#edit
admin_password PATCH /admins/password(.:format) devise/passwords#update
PUT /admins/password(.:format) devise/passwords#update
POST /admins/password(.:format) devise/passwords#create
new_user_session GET /users/sign_in(.:format) devise/sessions#new
user_session POST /users/sign_in(.:format) devise/sessions#create
destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy
new_user_password GET /users/password/new(.:format) devise/passwords#new
edit_user_password GET /users/password/edit(.:format) devise/passwords#edit
user_password PATCH /users/password(.:format) devise/passwords#update
PUT /users/password(.:format) devise/passwords#update
POST /users/password(.:format) devise/passwords#create
cancel_user_registration GET /users/cancel(.:format) devise/registrations#cancel
new_user_registration GET /users/sign_up(.:format) devise/registrations#new
edit_user_registration GET /users/edit(.:format) devise/registrations#edit
user_registration PATCH /users(.:format) devise/registrations#update
PUT /users(.:format) devise/registrations#update
DELETE /users(.:format) devise/registrations#destroy
POST /users(.:format) devise/registrations#create

admin と user の使うコントローラーがぶつかっているので変更する。

修正後のルーティング

次のようにルーティングを修正する。

config/routes.rb(修正後)
1
2
3
4
5
6
7
8
9
10
11
12
13
Rails.application.routes.draw do
devise_for :admins, controllers: {
sessions: 'admins/sessions',
passwords: 'admins/passwords',
registrations: 'admins/registrations'
}
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations'
}
root to: 'home#index'
end

変更できたらbundle exec rails routesで確認する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
                  Prefix Verb   URI Pattern                          Controller#Action
new_admin_session GET /admins/sign_in(.:format) admins/sessions#new
admin_session POST /admins/sign_in(.:format) admins/sessions#create
destroy_admin_session DELETE /admins/sign_out(.:format) admins/sessions#destroy
new_admin_password GET /admins/password/new(.:format) admins/passwords#new
edit_admin_password GET /admins/password/edit(.:format) admins/passwords#edit
admin_password PATCH /admins/password(.:format) admins/passwords#update
PUT /admins/password(.:format) admins/passwords#update
POST /admins/password(.:format) admins/passwords#create
new_user_session GET /users/sign_in(.:format) users/sessions#new
user_session POST /users/sign_in(.:format) users/sessions#create
destroy_user_session DELETE /users/sign_out(.:format) users/sessions#destroy
new_user_password GET /users/password/new(.:format) users/passwords#new
edit_user_password GET /users/password/edit(.:format) users/passwords#edit
user_password PATCH /users/password(.:format) users/passwords#update
PUT /users/password(.:format) users/passwords#update
POST /users/password(.:format) users/passwords#create
cancel_user_registration GET /users/cancel(.:format) users/registrations#cancel
new_user_registration GET /users/sign_up(.:format) users/registrations#new
edit_user_registration GET /users/edit(.:format) users/registrations#edit
user_registration PATCH /users(.:format) users/registrations#update
PUT /users(.:format) users/registrations#update
DELETE /users(.:format) users/registrations#destroy
POST /users(.:format) users/registrations#create

admin と user のすべてのアクションが、admins か users の配下になった。

devise のビューを作成

次のコマンドでビューを作成。

1
2
bundle exec rails g devise:views users
bundle exec rails g devise:views admins

コントローラーを作成

次のコマンドでコントローラーを作成。

1
2
bundle exec rails generate devise:controllers users
bundle exec rails generate devise:controllers admins

とりあえず確認

bundle exec rails sで起動します。
localhost:3000/users/sign_inlocalhost:3000/admins/sign_inのそれぞれページが表示されることを確認。

localhost:3000/users/sign_in
localhost:3000/admins/sign_in

管理者のログイン画面localhost:3000/admins/sign_inには、sign_up がないことも確認できます。

参照するリストの Food モデルを作成する

1
2
bundle exec rails g model food name:string --skip-test-framework
bundle exec rails db:migrate

admin 用の Foods コントローラーとビューを作る

コントローラーとビューを作成(admin)

1
bundle exec rails g scaffold_controller admins/food --skip-test-framework --skip-assets

ルーティングの修正(admin)

config/routes.rb(修正前)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Rails.application.routes.draw do
devise_for :admins, controllers: {
sessions: 'admins/sessions',
passwords: 'admins/passwords',
registrations: 'admins/registrations'
}
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations'
}
root to: 'home#index'
end

config/routes.rb(修正後)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Rails.application.routes.draw do
namespace :admins do
resources :foods
end
devise_for :admins, controllers: {
sessions: 'admins/sessions',
passwords: 'admins/passwords',
registrations: 'admins/registrations'
}
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations'
}
root to: 'home#index'
end

layout の追加(admin)

admin のユーザー認証を通ったものだけが使用するレイアウトを追加する必要がある。
‘app/views/layouts/application.html.slim’を参考に、’app/views/layouts/admins/application.html.slim’を作成する。

app/views/layouts/admins/application.html.slim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
doctype html
html
head
title
| Test
= csrf_meta_tags
= csp_meta_tag
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'
body
h1 Admins
- if admin_signed_in?
= "LogOnUser: #{current_admin.email}"
= link_to 'SignOut', destroy_admin_session_path, method: :delete
- else
= link_to 'SingUp', new_admin_registration_path
= link_to 'SignIn', new_admin_session_path
= yield

継承元コントローラーを追加(admin)

app/admins以下に作成したコントローラは、すべて’app/views/layouts/admins/application.html.slim’をレイアウトに使ってほしい。
そして、admin で認証されるようにしたいので、app/adminsApplicationControllerを継承したAdmins::ApplicationControllerを作成します。
app/adminsに作成するクラスはすべてAdmins::ApplicationControllerを継承させることにする。

app/controllers/admins/application_controller.rb
1
2
3
4
class Admins::ApplicationController < ApplicationController
layout 'admins/application'
before_action :authenticate_admin!
end

コントローラーを修正(admin)

app/controllers/admins/foods_controller.rbを修正する。
現在は、Admins::FoodsControllerApplicationControllerを参照している。
Admins::ApplicationControllerを参照元に変更する。

app/controllers/admins/foods_controller.rb(変更前)
1
class Admins::FoodsController < ApplicationController
app/controllers/admins/foods_controller.rb(変更後)
1
class Admins::FoodsController < Admins::ApplicationController

また、Admins::FoodsControllerは、操作対象のモデルが、Users::FoodになっているのでFoodに修正する。
index アクションの場合以下のようになる。

app/controllers/admins/foods_controller.rb(変更前)
1
2
3
def index
@admins_foods = Admins::Food.all
end
app/controllers/admins/foods_controller.rb(変更後)
1
2
3
def index
@admins_foods = Food.all
end

また、namespace によって admins の配下であるために、リンクも修正する必要がある。
create アクションの場合以下の様になる。

app/controllers/admins/foods_controller.rb(変更前)
1
2
3
4
5
6
7
8
9
10
11
12
13
def create
@admins_food = Food.new(admins_food_params)

respond_to do |format|
if @admins_food.save
format.html { redirect_to @admins_food, notice: 'Food was successfully created.' }
format.json { render :show, status: :created, location: @admins_food }
else
format.html { render :new }
format.json { render json: @admins_food.errors, status: :unprocessable_entity }
end
end
end
app/controllers/admins/foods_controller.rb(変更後)
1
2
3
4
5
6
7
8
9
10
11
12
13
def create
@admins_food = Food.new(admins_food_params)

respond_to do |format|
if @admins_food.save
format.html { redirect_to [:admins, @admins_food], notice: 'Food was successfully created.' }
format.json { render :show, status: :created, location: @admins_food }
else
format.html { render :new }
format.json { render json: @admins_food.errors, status: :unprocessable_entity }
end
end
end

redirect_to @admins_foodが、redirect_to [:admins, @admins_food]に変わる。

ビューを修正(admin)

app/views/admins/foods以下に作成されたビューは、index.html.slim 等一通りそろっているがカラムの情報が埋まっていないので、それぞれ修正する。
app/views/admins/foods/index.html.slimの場合は、以下のように変更。

app/views/admins/foods/index.html.slim(変更前)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
h1 Listing admins_foods

table
thead
tr
th
th
th

tbody
- @admins_foods.each do |admins_food|
tr
td = link_to 'Show', admins_food
td = link_to 'Edit', edit_admins_food_path(admins_food)
td = link_to 'Destroy', admins_food, data: { confirm: 'Are you sure?' }, method: :delete

br

= link_to 'New Food', new_admins_food_path

app/views/admins/foods/index.html.slim(変更後)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
h1 Listing admins_foods

table
thead
tr
th Name
th
th
th

tbody
- @admins_foods.each do |admins_food|
tr
td = admins_food.name
td = link_to 'Show', [:admins, admins_food]
td = link_to 'Edit', edit_admins_food_path(admins_food)
td = link_to 'Destroy', [:admins, admins_food], data: { confirm: 'Are you sure?' }, method: :delete

br

= link_to 'New Food', new_admins_food_path

Foodのカラムnameを参照できるようにする。
show.html.slim 他も同様の修正しておきます。

今回 foods は namespace によって admins の下に位置しています。form_for の取り扱いを修正します。

app/views/admins/foods/_form.html.slim(変更前)
1
2
3
4
5
6
7
8
9
= form_for @admins_food do |f|
- if @admins_food.errors.any?
#error_explanation
h2 = "#{pluralize(@admins_food.errors.count, "error")} prohibited this admins_food from being saved:"
ul
- @admins_food.errors.full_messages.each do |message|
li = message

.actions = f.submit
app/views/admins/foods/_form.html.slim(変更後)
1
2
3
4
5
6
7
8
9
10
11
12
= form_for [:admins,@admins_food] do |f|
- if @admins_food.errors.any?
#error_explanation
h2 = "#{pluralize(@admins_food.errors.count, "error")} prohibited this admins_food from being saved:"
ul
- @admins_food.errors.full_messages.each do |message|
li = message
.field
= f.label :name
= f.text_field :name

.actions = f.submit

form_for @admins_foodform_for [:admins,@admins_food]に変わるというのが総じてポイントになる。

ここまでの動作確認

localhost:3000/admins/foodsにアクセスし、localhost:3000/admins/sign_inにリダイレクトされることを確認する。
確認できれば、ここまで OK。

user 用の Foods コントローラーとビューを作る

コントローラーとビューを作成(user)

次のコマンドで、コントローラーとビューを作成。

1
bundle exec rails g scaffold_controller users/food --skip-test-framework --skip-assets

ルーティングの修正(user)

user は参照だけなので、index と show へのルーティングを追加する。

config/routes.rb(修正前)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Rails.application.routes.draw do
namespace :admins do
resources :foods
end
devise_for :admins, controllers: {
sessions: 'admins/sessions',
passwords: 'admins/passwords',
registrations: 'admins/registrations'
}
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations'
}
root to: 'home#index'
end

config/routes.rb(修正後)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Rails.application.routes.draw do
namespace :users do
resources :foods, only: [:index,:show]
end
namespace :admins do
resources :foods
end
devise_for :admins, controllers: {
sessions: 'admins/sessions',
passwords: 'admins/passwords',
registrations: 'admins/registrations'
}
devise_for :users, controllers: {
sessions: 'users/sessions',
passwords: 'users/passwords',
registrations: 'users/registrations'
}
root to: 'home#index'
end

layout の追加(user)

admin 同様に user の認証を通ったものだけが使用するレイアウトを追加する必要がある。
‘app/views/layouts/application.html.slim’を参考に、’app/views/layouts/users/application.html.slim’を作成する。

app/views/layouts/users/application.html.slim
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
doctype html
html
head
title
| Test
= csrf_meta_tags
= csp_meta_tag
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'
body
h1 Users
- if user_signed_in?
= "LogOnUser: #{current_user.email}"
= link_to 'SignOut', destroy_user_session_path, method: :delete
- else
= link_to 'SingUp', new_user_registration_path
= link_to 'SignIn', new_user_session_path
= yield

継承元コントローラーを追加(user)

admins 同様に、app/users 以下に作成したコントローラは、すべてapp/views/layouts/users/application.html.slimをレイアウトに使ってほしい。
そして、user として認証されるように、app/usersApplicationController を継承した Users::ApplicationController を作成する。
app/users に作成するクラスはすべて Users::ApplicationController を継承させることにする。

app/controllers/users/application_controller.rb
1
2
3
4
class Users::ApplicationController < ActionController::Base
layout 'users/application'
before_action :authenticate_user!
end

コントローラーを修正(user)

app/controllers/users/foods_controller.rb(変更前)
1
class Users::FoodsController < ApplicationController
app/controllers/users/foods_controller.rb(変更前)
1
class Users::FoodsController < Users::ApplicationController

また、Admins::FoodsController は、操作対象のモデルが Admins::Food になっているので Food に修正する。
user は参照だけなので、必要なアクションはindexshowだけなので、app/controllers/admins/foods_controller.rbは以下のようにする。

app/controllers/admins/foods_controller.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Users::FoodsController < Users::ApplicationController
before_action :set_users_food, only: [:show]

def index
@users_foods = Food.all
end

def show
end

private
def set_users_food
@users_food = Food.find(params[:id])
end
end

ビューを修正(user)

app/views/users/foods 以下に作成されたビューは、index.html.slim 等一通りそろっているがカラムの情報が埋まっていないので、それぞれ修正する。
app/views/users/foods/index.html.slim の場合は、以下のように変更。

app/views/users/foods/index.html.slim(変更前)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
h1 Listing users_foods

table
thead
tr
th
th
th

tbody
- @users_foods.each do |users_food|
tr
td = link_to 'Show', users_food
td = link_to 'Edit', edit_users_food_path(users_food)
td = link_to 'Destroy', users_food, data: { confirm: 'Are you sure?' }, method: :delete

br

= link_to 'New Food', new_users_food_path
app/views/users/foods/index.html.slim(変更後)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
h1 Listing users_foods

table
thead
tr
th Name
th
th
th

tbody
- @users_foods.each do |users_food|
tr
td = users_food.name
td = link_to 'Show', users_food

edit ほかのルーティングを持たないので、不要部分は削除します。

また、scaffold_controller コマンドによって不要なものが作成されているので、以下のものは削除する。

  • app/views/users/foods/edit.html.slim
  • app/views/users/foods/new.html.slim
  • app/views/users/foods/_form.html.slim

再度ここまでの動作確認

localhost:3000/users/foodsにアクセスし、localhost:3000/users/sign_inにリダイレクトされることを確認する。
確認できれば、ここまで OK。

root のページにリンクを追加

root のページとして表示されるapp/views/home/index.html.slim/users/foods``/admins/foodsのリンクを追加します。
以下のようになります。

1
2
3
4
5
h1 Home#index
p Find me in app/views/home/index.html.slim

p = link_to '一般',users_foods_path
p = link_to '管理者',admins_foods_path

書き換えてlocalhost:3000にアクセスすると次の様な画面になります。

管理者の ID 設定

admin の sign_up を禁止しています。
新しいユーザーをブラウザから作ることはできないので、rails コンソールからアクセスしてユーザーを作ります。

1
2
3
4
5
6
7
8
9
10
>bundle exec rails c
Loading development environment (Rails 5.2.4.2)
irb(main):001:0> Admin.new({email:"example@example.com",password:"*******"}).save
(0.3ms) SET NAMES utf8, @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'), @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
(0.3ms) BEGIN
Admin Exists (0.4ms) SELECT 1 AS one FROM `admins` WHERE `admins`.`email` = BINARY 'example@example.com' LIMIT 1
Admin Create (0.7ms) INSERT INTO `admins` (`email`, `encrypted_password`, `created_at`, `updated_at`) VALUES ('example@example.com', '$2a$11$jc6uSzaxri4VTs9s9GejfOHM6myE8O0isSlnk2u8cf.m542.lgDX2', '2020-03-20 18:05:50', '2020-03-20 18:05:50')
(12.3ms) COMMIT
=> true
irb(main):002:0> exit

動作確認

admin 側確認

localhost:3000/admins/foodsにアクセスし、ログインを試みます。
ログインできると、次のような画面になるはず。

適当にデータを登録すると次のようになるはず。

useer 側確認

localhost:3000/users/foodsにアクセスし、サインアップを試みます。
ログインできると、次のような画面になるはず。

サインアウト

サインアウトすると、app/views/home/index.html.slimを表示します。
片一方のアカウントでログアウトしても、もう一方のログインは維持されています。

ログイン・ログアウトを試していたら、ActionController::InvalidAuthenticityToken が出る

複数のアカウントで、ログイン・ログアウトをためしていたらたまに、ActionController::InvalidAuthenticityToken が発生するようになった。
調べてみると CSRF トークンの不整合が発生すると起きるということだった。
app/controllers/application_controller.rbに、protect_from_forgery with: :null_sessionを記述しする対応を見かけた。
しかし、CSDF 対策として弱くなるという意見もあり、今回は見送った。

調査を進めると github 上の issue に、CSRF protection prevents some webkit users from submitting formsを見つけた。
こちらを参考に、以下のように対応してみた。

app/controllers/application_controller.rb
1
2
3
class ApplicationController < ActionController::Base
protect_from_forgery prepend: true, with: :exception
end
app/controllers/admins/sessions_controller.rb
1
2
3
4
5
class Admins::SessionsController < Devise::SessionsController
rescue_from ActionController::InvalidAuthenticityToken do
redirect_to request.referrer, alert: "Your request has expired, please try again"
end
end
app/controllers/users/sessions_controller.rb
1
2
3
4
5
class Users::SessionsController < Devise::SessionsController
rescue_from ActionController::InvalidAuthenticityToken do
redirect_to request.referrer, alert: "Your request has expired, please try again"
end
end

以上の対応で、エラーを防ぐことができるようになりました。


今回は、devise gem を利用して異なるロールのアカウントの認証を同居できるように、複数モデルのモデルの管理を試みてみました。
どうにもうまくいかず、この記事を書くまでに 3 回ほど作り直したり調べたりで、丸 1 日程度かけてしまいました。
しかし、管理者アカウントと一般アカウントを分離してあげれば作れるものの幅が、大きく広がります。

これからもできることを拡大したいところです。

ではでは。