Demo 14: Authentication with Devise (Part 2)
In this demonstration, I will build upon the user-login functionality from the last demo by making it so that users have ownership of the quizzes they create (as well as the associated questions). Although one user may view the quizzes owned by another user, only the owner of a quiz will be allowed to edit or delete that quiz (or its associated questions).
To accomplish to implement such ownership, we will perform three high-level tasks:
- Create an ownership association between
User
andQuiz
, as depicted in Figure 1. - Update the existing pages and actions to use this new association.
- Restrict permissions to the
new
/create
/edit
/update
/delete
controller actions for quizzes and for questions such that only the user who owns a quiz/question is permitted to execute those actions on the quiz/question.
1. Creating an Ownership Association between User
and Quiz
-
Create a new database migration to add the
User
FK column toQuiz
. This involves several sub-steps.Generate a new (empty) database migration by running the following command:
rails g migration AddUserFkColToQuizzes
Set up the migration to add a
user_id
FK column to thequizzes
table by inserting the following line in thechange
method:add_reference :quizzes, :user, foreign_key: true
-
Update the database schema by running the migration with the following now-familiar command:
rails db:migrate
-
Set up the one-to-many association by adding
has_many
andbelongs_to
declarations toUser
andQuiz
, respectively.The
User
declaration should look like this:has_many :quizzes, class_name: 'Quiz', foreign_key: 'user_id', inverse_of: :creator
The
Quiz
declaration should look like this:belongs_to :creator, class_name: 'User', foreign_key: 'user_id', inverse_of: :quizzes
In the above declarations, note that a
Quiz
object will refer to its owner ascreator
, which is different from the Rails default name (user
). For example, to retrieve theUser
that owns aQuiz
referenced by a variablequiz
, the method invocation would bequiz.creator
. Also note that in Figure 1, the association end is labeledcreator
to capture this custom name. -
Add some new
User
seed data, setting each user’s password to “password
” (which would be too insecure to deploy, but is easy to remember for purposes of testing/development), like this:u1 = User.create!(email: 'alice@gmail.com', password: 'password') u2 = User.create!(email: 'bob@gmail.com', password: 'password')
Note that although the Devise
User
constructor has apassword
parameter that takes a clear-text password string, the password will actually be encrypted and saved in anencrypted_password
attribute. It would be insecure forUser
objects to store clear-text passwords as attributes. -
Update each
Quiz
in the seed data to be owned by a particularUser
, like this:q1 = Quiz.create!(creator: u1, title: 'MVC Concepts', description: 'This quiz contains questions related to the Model-View-Controller web application architecture.') q2 = Quiz.create!(creator: u2, title: 'Rails Concepts', description: 'This quiz contains questions related to web application development using the Ruby on Rails platform.')
-
Remove all existing data from the database and re-seed it by entering this command:
rails db:seed:replant
To confirm that the data was seeded correctly, use pgAdmin to inspect the database, or use the Rails console, for example, to run Quiz.all
and inspect the output.
2. Updating Existing Pages and Actions to Use the Association
2.1. Updating the index
and show
Pages for Quiz
-
Display the email address of each quiz’s creator on the
index
andshow
pages forQuiz
, like this:<p> Created by: <%= quiz.creator.email %> </p>
-
Fix the N + 1 Queries Problem (discussed in this previous demo) that we now have by modifying the query in the
index
controller action, like this:quizzes = Quiz.includes(:creator).all
2.2. Adding a new index
Page That Shows Only the Current User’s Quizzes
The current index
page for Quiz
records displays all quizzes for all users; however, we would also like to have an index
-style page that displays only the Quiz
records owned by the currently signed-in user. To add this new index
page, perform the following steps.
-
Generate a new
AccountQuizzesController
class by entering this now-familiar command:rails g controller AccountQuizzesController
This controller will service requests for actions on
Quiz
records that areUser
specific. The rationale for having this separate controller is twofold. Firstly, it has more knowledge about theUser
model class than the plain oldQuizzesController
class; thus, the new controller helps to separate concerns. Secondly, adding this controller enables us to use only standard RESTful routes/actions (i.e.,index
,show
,new
/create
,edit
/update
, anddestroy
). For example, if we were to instead add the newindex
-style action to the existingQuizzesController
class, we would have to use a non-standard route, because the standardindex
route is already in use. -
Add a route for the new user-specific
index
page, like this:resources :quizzes get 'account/quizzes', to: 'account_quizzes#index', as: 'account_quizzes' # my quizzes page
Note that the above code assumes that the
QuizzesController
routes were previously declared using only theresources
method. If all those routes were declared individually, then they should now be refactored to use theresources
method. -
Add to the
AccountQuizzesController
class the newindex
action that will handle requests that match the new route, like this:def index # get all quiz objects belonging to current user quizzes = current_user.quizzes # display index view respond_to do |format| format.html { render :index, locals: { quizzes: quizzes } } end end
-
Add the
account_quizzes/index.html.erb
view that will be rendered by the newindex
action, like this:<h1>My Quizzes</h1> <%= link_to 'New Quiz', new_account_quiz_path %> <% quizzes.each do |quiz| %> <div id="<%= dom_id(quiz) %>"> <br> <p> <%= quiz.title %> <%= link_to '🔎', quiz_path(quiz) %> <%= link_to '🖋', edit_quiz_path(quiz) %> <%= link_to '🗑', quiz_path(quiz), method: :delete %> </p> <p> <%= truncate quiz.description, length: 75, separator: ' ' %> </p> </div> <% end %>
-
Make it easier for users to navigate to the
index
pages forQuiz
records (i.e.,QuizzesController#index
andAccountQuizzesController#index
) by adding a hyperlink to each of thoseindex
pages to theul
list of links inapplication.html.erb
that display at the top of each page, like this:<li> <%= link_to "Hi, #{current_user.email}", edit_user_registration_path %> </li> <li> <%= link_to 'Quizzes', quizzes_path %> </li> <li> <%= link_to 'My Quizzes', account_quizzes_path %> </li> <li> <%= link_to 'Sign Out', destroy_user_session_path, method: :delete %> </li>
2.3. Updating the new
/create
Page/Actions for Quiz
At this point, all the pages and actions for Quiz
should still be functional, except the new
and create
actions. The reason that these actions are broken is because Quiz
must now have a user_id
in order for save
to execute without a validation error. To solve this problem, we will update the new
and create
actions, and in the process, we will move those actions out of the QuizzesController
class and into the AccountQuizzesController
class.
-
Update the routes for the
new
andcreate
actions, like this:# includes quizzes#index, quizzes#show, quizzes#edit, quizzes#update, quizzes#destroy resources :quizzes, except: [:new, :create] get 'account/quizzes', to: 'account_quizzes#index', as: 'account_quizzes' # my quizzes page post 'account/quizzes', to: 'account_quizzes#create' get 'account/quizzes/new', to: 'account_quizzes#new', as: 'new_account_quiz'
Note that the above code removes the old
QuizzesController
routes fornew
andcreate
by using theexcept
parameter of theresources
method. It then inserts the newcreate
andnew
routes forAccountQuizzesController
after theindex
route we created earlier in this demo. -
Move the existing
quizzes/new.html.erb
file to theaccount_quizzes
directory, and change the form url to the new route, like this:<%= form_with model: quiz, url: account_quizzes_path, method: :post, local: true, scope: :quiz do |form| %>
-
Comment out the existing
new
andcreate
actions in theQuizzesController
class, and add new ones to theAccountQuizzesController
class, like this:def new # make empty quiz object quiz = Quiz.new # display new view respond_to do |format| format.html { render :new, locals: { quiz: quiz } } end end def create # new object from params quiz = current_user.quizzes.build(params.require(:quiz).permit(:title, :description)) # respond_to block respond_to do |format| # html format block format.html { if quiz.save # success message flash[:success] = "Quiz saved successfully" # redirect to index redirect_to account_quizzes_url else # error message flash.now[:error] = "Error: Quiz could not be saved" # render new render :new, locals: { quiz: quiz } end } end end
-
Remove the “
New Quiz
” link from thequizzes_controller/index.html.erb
view. The rationale for this change is that creating a new quiz from a page where the user is viewing all the quizzes in the system (both theirs and everyone else’s) might not make sense, and it’s better to have the create-quiz link on the page that lists only the user’s quizzes.
3. Restricting Permissions on Controller Actions to Owners
Currently, it is possible for a signed-in user to edit and delete quizzes/questions that they don’t own (e.g., by directly entering an edit
-page URL into their browser’s location/address bar with the ID of an arbitrary quiz/question). To fix this, we are going to (1) Add a new before_action
to the QuizzesController
, the McQuestionsController
, and the QuizMcQuestionsController
, and (2) we will display the edit and delete links on the index
pages only when the current user owns the corresponding quiz/question.
-
Create a new function
require_permission
in theQuizzesController
that will check that the current user owns a specified quiz, like this:def require_permission if Quiz.find(params[:id]).creator != current_user redirect_to quizzes_path, flash: { error: "You do not have permission to do that." } end end
Note that if the current user is not the owner, then browser is redirected to to the
index
page for allQuiz
records. -
Add a
before_action
declaration at the top of theQuizzesController
class definition that callsrequire_permission
whenever any of theedit
/update
/destroy
actions are invoked, like this:before_action :require_permission, only: [:edit, :update, :destroy]
Run the app to verify that the
🖋
and🗑
links on http://localhost:3000/quizzes only work for quizzes the current user created. -
Add the same
require_permission
method to theQuizMcQuestionsController
. Also add the samebefore_action
, except set it for only thenew
andcreate
actions. Run the app to verify that a user cannot add a new question on theshow
page for a quiz they did not create. -
Similar to above, create a new method
require_permission
in theMcQuestionsController
that will check that the current user owns a specified question, like this::def require_permission quiz = McQuestion.find(params[:id]).quiz if quiz.creator != current_user redirect_to quiz_path(quiz), flash: { error: "You do not have permission to do that." } end end
-
Similar to above, add a
before_action
to theMcQuestionsController
, setting it for only theedit
,update
anddestroy
actions, like this:before_action :require_permission, only: [:edit, :update, :destroy]
Run the app to verify that the
🖋
and🗑
links for the questions on theshow
page forQuiz
objects only work for quizzes that are owned by the current user. -
In the
quizzes/show.html.erb
and thequizzes/index.html.erb
view, display the “New Question
”,🖋
, and🗑
hyperlinks only for the quiz owner by wrapping the links inif
statements, like this:<% if quiz.creator == current_user %> <%= link_to 'New Question', new_quiz_mc_question_path(quiz) %> <% end %>
<% if quiz.creator == current_user %> <%= link_to '🖋', edit_mc_question_path(question) %> <%= link_to '🗑', mc_question_path(question), method: :delete %> <% end %>
-
Similarly, in the
mc_questions/show.html.erb
view, display the🖋
and🗑
hyperlinks only for the owner of the parentQuiz
, like this:<% if question.quiz.creator == current_user %> <%= link_to '🖋', edit_mc_question_path(question) %> <%= link_to '🗑', mc_question_path(question), method: :delete %> <% end %>
With those steps completed, the QuizMe app should now be fully secured such that users are required to be authenticated in order to view most pages and the data that users create are protected against manipulation by other users.