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
UserandQuiz, as depicted in Figure 1. - Update the existing pages and actions to use this new association.
- Restrict permissions to the
new/create/edit/update/deletecontroller 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.
User and Quiz.1. Creating an Ownership Association between User and Quiz
-
Create a new database migration to add the
UserFK column toQuiz. This involves several sub-steps.Generate a new (empty) database migration by running the following command:
rails g migration AddUserFkColToQuizzesSet up the migration to add a
user_idFK column to thequizzestable by inserting the following line in thechangemethod: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_manyandbelongs_todeclarations toUserandQuiz, respectively.The
Userdeclaration should look like this:has_many :quizzes, class_name: 'Quiz', foreign_key: 'user_id', inverse_of: :creatorThe
Quizdeclaration should look like this:belongs_to :creator, class_name: 'User', foreign_key: 'user_id', inverse_of: :quizzesIn the above declarations, note that a
Quizobject will refer to its owner ascreator, which is different from the Rails default name (user). For example, to retrieve theUserthat owns aQuizreferenced by a variablequiz, the method invocation would bequiz.creator. Also note that in Figure 1, the association end is labeledcreatorto capture this custom name. -
Add some new
Userseed 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
Userconstructor has apasswordparameter that takes a clear-text password string, the password will actually be encrypted and saved in anencrypted_passwordattribute. It would be insecure forUserobjects to store clear-text passwords as attributes. -
Update each
Quizin 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
indexandshowpages 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
indexcontroller 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
AccountQuizzesControllerclass by entering this now-familiar command:rails g controller AccountQuizzesControllerThis controller will service requests for actions on
Quizrecords that areUserspecific. The rationale for having this separate controller is twofold. Firstly, it has more knowledge about theUsermodel class than the plain oldQuizzesControllerclass; 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 existingQuizzesControllerclass, we would have to use a non-standard route, because the standardindexroute is already in use. -
Add a route for the new user-specific
indexpage, like this:resources :quizzes get 'account/quizzes', to: 'account_quizzes#index', as: 'account_quizzes' # my quizzes pageNote that the above code assumes that the
QuizzesControllerroutes were previously declared using only theresourcesmethod. If all those routes were declared individually, then they should now be refactored to use theresourcesmethod. -
Add to the
AccountQuizzesControllerclass the newindexaction 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.erbview that will be rendered by the newindexaction, 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
indexpages forQuizrecords (i.e.,QuizzesController#indexandAccountQuizzesController#index) by adding a hyperlink to each of thoseindexpages to theullist of links inapplication.html.erbthat 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
newandcreateactions, 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
QuizzesControllerroutes fornewandcreateby using theexceptparameter of theresourcesmethod. It then inserts the newcreateandnewroutes forAccountQuizzesControllerafter theindexroute we created earlier in this demo. -
Move the existing
quizzes/new.html.erbfile to theaccount_quizzesdirectory, 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
newandcreateactions in theQuizzesControllerclass, and add new ones to theAccountQuizzesControllerclass, 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.erbview. 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_permissionin theQuizzesControllerthat 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 endNote that if the current user is not the owner, then browser is redirected to to the
indexpage for allQuizrecords. -
Add a
before_actiondeclaration at the top of theQuizzesControllerclass definition that callsrequire_permissionwhenever any of theedit/update/destroyactions 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_permissionmethod to theQuizMcQuestionsController. Also add the samebefore_action, except set it for only thenewandcreateactions. Run the app to verify that a user cannot add a new question on theshowpage for a quiz they did not create. -
Similar to above, create a new method
require_permissionin theMcQuestionsControllerthat 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_actionto theMcQuestionsController, setting it for only theedit,updateanddestroyactions, like this:before_action :require_permission, only: [:edit, :update, :destroy]Run the app to verify that the
🖋and🗑links for the questions on theshowpage forQuizobjects only work for quizzes that are owned by the current user. -
In the
quizzes/show.html.erband thequizzes/index.html.erbview, display the “New Question”,🖋, and🗑hyperlinks only for the quiz owner by wrapping the links inifstatements, 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.erbview, 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.