If you haven't already, first read the previous post about Cookies.
Authorization refers to what a user can do once they're authenticated. It also can include what a user sees (and also what a non-user doesn't see). Let's start with that. Let's look at that navbar again and hide the login/signup links when a user is logged-in. And similarly, we'll hide the current user's name if there is no current user. Open the /app/views/layouts/application.html.erb
file and add some conditional logic based on the condition if @current_user
.
<p>
<% if @current_user %>
<%= @current_user["first_name"] %>
<% else %>
<a href="/sessions/new">Login</a> |
<a href="/users/new">Signup</a>
<% end %>
</p>
Refresh the browser and you should no longer see the login and signup links.
To test the else
condition, we need to logout the current user. To do that, let's add a destroy
action to the sessions controller that will "delete" the current session. Typically a destroy
action will delete a row from a table, but in this case, we just want to "delete" the cookie so that the browser no longer "remembers" that a user is logged-in. We can do this by adding the destroy
action and then re-assigning the value of the cookie as nil
which effectively will mean there is no current user.
def destroy
flash["notice"] = "Goodbye."
session["user_id"] = nil
redirect_to "/sessions/new"
end
The url we would normally use to get to this path would look something like a DELETE
request to /tacos/123
where 123
is the id
of the row we want to delete. But with a session, there isn't an id, so this RESTful path doesn't really make sense. Occasionally, when the REST pattern doesn't fit, we add a custom path and tell our app where to direct the request to. Open up the routes file /config/routes.rb
and add the following code at the bottom below the resources.
get("/logout", {:controller => "sessions", :action => "destroy"})
This code is registering the /logout
path as a GET
request. When our application server receives that request, it will direct it to the destroy
action within the sessions
controller. While we're at it, let's add a custom path for login as well, just to keep some nice naming consistency.
get("/login", {:controller => "sessions", :action => "new"})
The entire routes file now looks like this:
Rails.application.routes.draw do
resources "companies"
resources "contacts"
resources "activities"
resources "tasks"
resources "users"
resources "sessions"
get("/login", {:controller => "sessions", :action => "new"})
get("/logout", {:controller => "sessions", :action => "destroy"})
# Landing page (aka root route)
# get("/", {:controller => "", :action => ""})
end
Now that the application can receive this "/logout" request, let's add a logout link to the navbar. We can also modify the login link there as well.
<p>
<% if @current_user %>
<%= @current_user["first_name"] %>
<a href="/logout">Logout</a>
<% else %>
<a href="/login">Login</a> |
<a href="/users/new">Signup</a>
<% end %>
</p>
Refresh your browser and you should see the Logout link alongside the current user's name. Click it and the sessions destroy
action will reset the cookie so that the current user is "logged-out" and redirected to login.
Login again, and you'll be back at the companies index and see the current user's name and logout link. We are now "authorizing" what a logged-in user can see vs a logged-out user (aka "visitor").
Activities
In our CRM domain model, there are companies
and contacts
and salespeople
(i.e. users
). There are also activities
which are related to both contacts
and users
. This app came seeded with a couple rows in the activities
table. Navigate to the "Apple" show page and then the "Tim Cook" show page. There you'll see a couple activities.
Firstly, if a user is not currently logged-in, they should not be able to see or add activities. One could argue, they shouldn't be able to see contacts or companies either for that matter, but let's just focus on activities for now since activities are very explicitly user-related.
So we should hide anything in this view that only logged-in users should be able to access. Open up this file (/app/views/contacts/show.html.erb
) and let's wrap the relevant code in an if @current_user
conditional block.
<% if @current_user %>
<p><a href="/contacts/<%= @contact["id"] %>/edit">Edit Contact</a></p>
<form action="/contacts/<%= @contact["id"] %>" method="post">
<input type="hidden" name="_method" value="delete">
<button>Delete Contact</button>
</form>
<h3>Sales Activity</h3>
<ul>
<% for activity in @activities %>
<li>
<%= activity["activity_type"] %>
<br>
<%= activity["note"] %>
</li>
<% end %>
</ul>
<h4>Log Activity:</h4>
<form action="/activities" method="post">
<p>
<select name="activity_type" id="activity_type_select">
<option value="call">call</option>
<option value="email">email</option>
<option value="meeting">meeting</option>
</select>
with <%= @contact["first_name"] %> <%= @contact["last_name"] %>
</p>
<p>
<label for="note_input">Note</label>
<textarea name="note" id="note_input"></textarea>
</p>
<input type="hidden" name="contact_id" value="<%= @contact["id"] %>" id="contact_id_input">
<button>Submit</button>
</form>
<% end %>
Now the activity data and the new activity form will only appear for a logged-in user (and for good measure, we'll also hide the contact edit and delete links since non-users shouldn't be able to access those either). If you haven't done so, logout and refresh the page to confirm this is working, and then login again.
This is an improvement. But the activities we see here aren't our activities - as in, they're not the activities for this current user.
If you look at this data in the database, you'll notice that these activities are related to a contact (i.e. their contact_id
column is the id
for "Tim Cook"), but they are not related to a user (i.e. their user_id
column is nil). Add a new activity using the form and the same will be true - the activity does not know that it's related to the current user.
Now that we have awareness of a current user, when we create a new activity, it should be assigned to this user. To make that change, we need to go to the code where the activity gets created. We could add a hidden form input and add the current user's id there, but html is easily manipulated by anyone who knows how to view source. So that wouldn't be very secure. Instead, we'll do this in the backend code that only us developers can access. The new activity gets created in the activities controller's create
action. Let's look at that code:
class ActivitiesController < ApplicationController
def create
@activity = Activity.new
@activity["contact_id"] = params["contact_id"]
@activity["activity_type"] = params["activity_type"]
@activity["note"] = params["note"]
@activity.save
redirect_to "/contacts/#{@activity["contact_id"]}"
end
end
All of the form data is being assigned properly, but there's another column in the activities table that's not being assigned: user_id
. So let's just add a line of code to assign that as the current user's id
:
def create
@activity = Activity.new
@activity["contact_id"] = params["contact_id"]
@activity["activity_type"] = params["activity_type"]
@activity["note"] = params["note"]
@activity["user_id"] = @current_user["id"]
@activity.save
redirect_to "/contacts/#{@activity["contact_id"]}"
end
That's it. As you're seeing, with access to the @current_user
record, we're able to build logic and add context throughout the application - it's really becoming a complete piece of software!
Go back to the activities form and submit another activity. You'll see it displayed in the list of activities just as the other records are, but the data in the table has changed in a very meaningful way. The previous rows have an empty value (i.e. nil
) in the user_id
column. But this new row has the current user's id in it. If you were to logout and login as another user, and then create another activity, that row would have a different value in its user_id
column. Activities are now related to both contacts and users.
There's one last improvement we can make. Instead of displaying all of the activities here, we should only display the current user's activities. The activities are displayed in a loop in the contact's show view (the same file we modified above with the if @current_user
logic).
<ul>
<% for activity in @activities %>
<li>
<%= activity["activity_type"] %>
<br>
<%= activity["note"] %>
</li>
<% end %>
</ul>
Since we're in the contact's show view, the @activities
variable must be defined in the contacts controller's show
action:
@activities = Activity.where({ "contact_id" => @contact["id"] })
This is querying the activities table for all rows where the contact_id
column value is this contact's id
(in the example above, that's Tim Cook's id
). We can refine the query to also filter by the current user's id so that only this user's activities are returned:
@activities = Activity.where({ "contact_id" => @contact["id"], "user_id" => @current_user["id"] })
Now the SQL query will filter by contact_id
AND user_id
. Refresh the browser and you should only see the activity you created as this user. You can also look at the server log to see the SQL that gets executed.
There's one possible problem. If you logout and return to Tim Cook's page, previously we were hiding the sales activity and form, but you could still see Tim Cook's details. However, now you'll see an error:
The bug is due to the fact that @current_user
is nil when logged-out. And you can't try to read the id
column of nothing. It's a possible problem because maybe a logged-out user shouldn't ever be able to get to this page in the first place. We could conditionally check if @current_user
in this action and simply redirect a logged-out user. But, to maintain the functionality that we had before, let's just account for the possibility of a logged-out user. The simplest way to do this is to instead use the value from the session
hash which won't produce an error, but will simply be nil
if there is no logged-in user. Here's the modified code:
@activities = Activity.where({ "contact_id" => @contact["id"], "user_id" => session["user_id"] })
And now you can refresh the page and no more error. Login again, and you'll only see the activities for this current user. Success!
Besides the concept of @current_user
and how it gets assigned in a before_action
method, you may have noticed that the rest of this authorization code is really no different from anything we've done before - authorization at its core is just the use of conditionally logic, sql queries, and column assignment. Look at the code we used to add user context to the application:
if @current_user
@activity["user_id"] = @current_user["id"]
Activity.where({ "user_id" => session["user_id"] })
With those tools, you have almost infinite control over the authorization logic in your applications. With that said, let's practice with a new resource: todos.
Todos
If the canonical first app in every language is "Hello, world"
, then the canonical second app is almost always a list of todos. So let's check that box (pun intended) for you.
This repository already has some MVC (model-view-controller) architecture for a tasks
resource - tasks/todos, tomato/tomahto. There is a Task
model and table - the tasks
table has columns for id
, description
, and user_id
. The routes file is exposing the tasks resource. There is a tasks_controller.rb
file with index
, create
, and destroy
actions - the destroy
is used when a task is "completed". And there is a tasks index.html.erb
file which includes the new task form and a loop of @tasks
.
Take a look through the code and then visit the /tasks
path (i.e. the tasks index view) in the browser.
And try submitting a new task.
This is a weird todos app as-is. New tasks don't have any context of a user, so if you were to ship this app, it would be a global/public todo app. Maybe an interesting social experiment, but not really useful for personal productivity. To fix this, implement the following user stories. Since we're still in the same repository with all of the existing authentication code, you only need to focus on authorization logic.
Lab - User Stories
- As an anonymous user, I cannot create new tasks.
- As a signed-in user, I want to create new tasks.
- As a signed-in user, I want to see my tasks.
- As a signed-in user, I want to be able to complete my tasks.
The steps to complete these are below, but try to work through it yourself before looking.
Lab - Solution
First user story:
As an anonymous user, I cannot create new tasks.
Currently, anyone can submit the new task form even if logged-out. We can change that by hiding the form in the index view file (/app/views/tasks/index.html.erb
):
<% if @current_user %>
<form action="/tasks" method="post">
<input type="text" name="description">
<button>Add new task</button>
</form>
<% end %>
If we want to be helpful to the user, we can add an else
condition that says something like "You must login to add todos". That's up to you. Reload the page and the form should now be hidden. User story ✅ complete.
Second user story:
As a signed-in user, I want to create new tasks.
Login as a user and navigate back to the tasks index page. The task form is there. At first glance, this user story is already done. However, this is where these user stories are a bit too vague. A better user story would say something like:
"As a signed-in user, I want to create new tasks, so that I can keep track of things I need to do".
Thinking like a developer now, we can interpret a bit more clearly that these tasks will need to be related in the data to the current user. To implement this, we need to go to the code where the task gets created, the tasks controller create
action.
def create
@task = Task.new
@task["description"] = params["description"]
@task.save
redirect_to "/tasks"
end
Recall that a task has a description
and a user_id
column. The former is assigned based on the submitted form data. So we just need to assign the latter.
@task["user_id"] = @current_user["id"]
Now when the a new task gets created, it will be related to the current user so that the user can "keep track of the things [they] need to do". User story ✅ complete.
Third user story:
As a signed-in user, I want to see my tasks.
Currently, all tasks are displayed in the view regardless of who is logged-in. Good thing we implemented that second user story correctly. Now that tasks are related to users, we can query for only the current user's tasks. Since the view is the tasks index view, the code we want is in the tasks controller index
action:
def index
@tasks = Task.all
@task = Task.new
end
Instead of Task.all
, we'll modify it to query by the current user's id.
@tasks = Task.where({ "user_id" => @current_user["id"] })
Now if you refresh the page, you should only see tasks you created as this user. If you don't see any tasks, you probably haven't created any yet as this user. Try submitting a new task. To test this out thoroughly, you can logout and login as another user, create a new task, and confirm that you only see that user's tasks as well.
However, if you logout and try to view this todo app as a logged-out user, you're going to run into that same bug from before because @current_user["id"]
will raise an error when there is no current user. There are plenty of fixes for this bug, but the simplest is to use the session
cookie instead:
@tasks = Task.where({ "user_id" => session["user_id"] })
And now the error should go away. User story ✅ complete.
Fourth user story:
As a signed-in user, I want to be able to complete my tasks.
Looking at the tasks index.html.erb
code, the complete button is part of the @tasks
loop:
<%= button_to "Complete", task, :method => "delete" %>
Since the @tasks
loop is only showing the current user's tasks, then the "complete" button is only being displayed for this user's tasks. So we can probably consider this user story done.
However, putting on our security hats for a moment, what if we were to view the source code in the browser to see what's going on?
Here's the final html that our app is sending to the browser.
<form method="post" action="/tasks/3">
<input type="hidden" name="_method" value="delete" autocomplete="off" />
<button type="submit">Complete</button>
<input type="hidden" name="authenticity_token" value="lfNjSdpeuV27vux8x9ChX1O-MIpwMZ1O-9pdz1aSRV2frkv__2zhAlt5ZzxjrXX6wI2Q0U81fuDbMLgCeXEHiQ" autocomplete="off" />
</form>
There's a lot of noise there and some helpful code that rails is building for us. But the critical code is where this form submits to which is the action
attribute of the <form>
element: action="/tasks/3"
. The path is to /tasks/3
where 3
is the id
of the task row that we want to "complete". As a clever internet user, you may know that you can actually edit that id by opening up the Chrome developer tools and inspecting the html. If you change that action to /tasks/1
and click the "complete" button, the request will go to the tasks controller destroy
action, query for Task.find_by({ "id" => params["id"] })
and find the row where the id
is 1
(or any other value you change it to). All of a sudden, a clever user can "complete" any task they want, even if it's not their user's task. Bad.
We can protect against this type of malicious behavior fairly simply in our backend code. Here's the destroy
action as-is:
def destroy
@task = Task.find_by({ "id" => params["id"] })
@task.destroy
redirect_to "/tasks"
end
Let's modify it slightly by wrapping the @task.destroy
behavior in conditional logic that checks if this task is related to the current user.
def destroy
@task = Task.find_by({ "id" => params["id"] })
if @task["user_id"] == @current_user["id"]
@task.destroy
end
redirect_to "/tasks"
end
The code @task["user_id"] == @current_user["id"]
will only be true if this task's user_id
column matches the logged-in user's id
. So even if a clever user tries to modify the html, nothing will happen unless the path finds a task that they created. User story ✅ complete.
Test out the todos app now - it should behave as you'd expect as a user.