Phoenix Authentication
Mix.install([
{:jason, "~> 1.4"},
{:kino, "~> 0.9", override: true},
{:youtube, github: "brooklinjazz/youtube"},
{:hidden_cell, github: "brooklinjazz/hidden_cell"}
])
Navigation
Home Report An Issue Blog: CommentsBlog: AuthenticationReview Questions
Upon completing this lesson, a student should be able to answer the following questions.
- How do we scaffold an authorization system in Phoenix?
- How do we protect a route from unauthorized access?
Overview
This is a companion reading for the Blog: Authentication exercise. This lesson is an overview of how to add authentication and authorization into a Phoenix application.
Authentication
Authentication is the process of verifying the identity of a user or system. It typically involves providing credentials, such as a username and password or a digital certificate, and validating them against a trusted source.
Authorization
Authorization is the process of determining whether a user or system is allowed to perform a specific action or access a particular resource. It involves verifying the identity and permissions of the requester and comparing them against the access control policies of the system.
Ownership-based Authorization
Some resources should only be managed by the user or groups that own them. For example, in a blog application, a user should probably only be able to edit and delete their own blog posts.
Typically we prove ownership by associating the resource with the user. For example, a user would have many blog posts in a one to many relationship.
Role-based Authorization
Some operations can be allowed based on user roles. For example, an admin user can typically perform all actions that require authorization.
If you’re familiar with the app Discord, then you’re likely very familiar with the role-based authorization system they use to manage permissions in specific voice and text channels.
Password Hashing
Passwords are encrypted in databases to ensure the even if there were a database leak, passwords would be encrypted.
By default, the Phoenix authentication system uses bcrypt_elixir to hash passwords.
Computerphile has a great overview video on hashing algorithms and security.
YouTube.new("https://www.youtube.com/watch?v=b4b8ktEV4Bg&ab_channel=Computerphile")
User Tokens
The Phoenix authentication system uses user tokens to identify users. This avoids storing confidential user information on the client, as that is not secure.
The client sends the token whenever it makes a request, and the server can use this token to identify and retrieve the user from the database. Tokens typically expire after a certain amount of time for security purposes.
Generators
Phoenix provides the following mix phx.gen.auth command to generate all of the scaffolding we need to auth authentication in our system.
mix phx.gen.auth Accounts User users
The auth system can be generated using controllers, or LiveView. For now, we’ll select n when using the generator to use controllers.
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Yn] n
The generator creates a large number of files, here are a few you should familiarize yourself with:
-
app
-
accounts.ex
: The context for managing user accounts. -
user.ex
: The user schema. -
user_token.ex
: The user token schema. -
user_notifier.ex
: The mailer module for user emails such as account confirmation and resetting passwords. -
user_auth.ex
: Useful functions for managing users on the web side of the application. For example,require_authenticated_user/2
ensures a user is signed in before accessing some resource.
-
-
app web
-
user_confirmation_controller.ex
: controller actions for confirming a user account -
user_registration_controller.ex
: controller actions for user sign up. -
user_reset_password_controller.ex
: controller actions for resetting a user’s password. -
user_session_controller.ex
: controller actions for user log in and log out. -
user_settings_controller.ex
: controller actions for managing user settings.
-
-
tests
-
accounts_fixtures.ex
: fixture for creating users in tests. -
the
register_and_log_in_user/1
andlog_in_user/2
function in the existingconn_case.ex
file.
-
Modifying Users
The Phoenix generator simply generates the scaffolding for an authentication system. We still own this part of the application and are responsible for maintaining security in our application.
We can also make changes to users, such as adding fields to the users table. For example, we could write a migration that adds a username
field or other user information to the users
table.
defmodule Blog.Repo.Migrations.AddUsernameToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :username, :string, null: false
end
end
end
Associating Resources With A User
In order to associate a resource with a user, it needs to reference a user in the database through a foreign key.
erDiagram
Post {
string title
text content
date published_on
boolean visibility
id user_id
}
User {
string username
string email
string password
string hashed_password
naive_datetime confirmed_at
}
User ||--O{ Post : "owns"
Here’s an example migration that would add a user_id
to an existing posts
table.
defmodule Blog.Repo.Migrations.AddUserIdToPosts do
use Ecto.Migration
def change do
alter table(:posts) do
add :user_id, references(:users, on_delete: :delete_all), null: false
end
end
end
Schemas should reflect any changes made in the database.
defmodule Blog.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :content, :string
field :title, :string
field :visible, :boolean, default: true
field :published_on, :utc_datetime
has_many :comments, Blog.Comments.Comment
belongs_to :user, Blog.Accounts.User
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content, :visible, :published_on, :user_id])
|> validate_required([:title, :content, :visible, :user_id])
|> unique_constraint(:title)
|> foreign_key_constraint(:user_id)
end
end
Fixing Broken Tests
Typically making a required field or association in a database table breaks any tests that create those records without the field or association.
To fix tests, it’s necessary to provide the required field or association whenever creating the resource.
Here’s an example of a test that creates a post with an associated user.
test "delete_post/1 deletes the post" do
user = user_fixture()
post = post_fixture(user_id: user.id)
assert {:ok, %Post{}} = Posts.delete_post(post)
assert_raise Ecto.NoResultsError, fn -> Posts.get_post!(post.id) end
end
Whenever making impactful changes to schema’s/migrations, it’s recommended to make the change when there are no other changes in git. It’s also often best to fix all tests before moving on to other features to avoid potential bugs and unnecessary complexity.
Protecting Controller Actions
The generated UserAuth
module contains two useful plugs for protecting routes. The require_authenticated_user/2
plug ensures that a user must be signed in to access the route, and the redirect_if_user_is_authenticated/2
plug does the opposite.
We can use these plugs when we want to protect certain routes.
Requiring Authenticated User
Here’s an example of protecting post controller actions that only an authenticated user should be able to do.
scope "/", BlogWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/posts", PostController, only: [:new, :edit, :update, :create, :delete]
end
Requiring User Owns Resource
Sometimes we want to protect a route based on whether or not a user owns the particular resource. For example, we probably only want to let users edit their own posts.
There are multiple ways to require that a user owns a resource. Here are a few options:
- Individually protect the specific controller actions (handle auth for one action.)
- Holistically protect several controller actions (handle auth for an entire controller.)
- Create a custom plug and use it in the router (handle auth for multiple controllers.)
Here’s an example of creating a plug function in a controller that can be selectively applied to multiple routes.
# Typically Goes Above Controller Actions And Below Imports/aliases
plug :require_user_owns_post when action in [:edit, :update, :delete]
# Typically Goes At The Bottom Of The File
defp require_user_owns_post(conn, _params) do
post_id = String.to_integer(conn.path_params["id"])
post = Posts.get_post!(post_id)
if conn.assigns[:current_user].id == post.user_id do
conn
else
conn
|> put_flash(:error, "You can only edit or delete your own posts.")
|> redirect(to: ~p"/posts/#{post_id}")
|> halt()
end
end
Alternatively, if you wanted to have different error messages for each action, you could include this authorization code in the action itself. This results in more code as each controller action handles it’s own authorization, but it allows for a more specific response.
def delete(conn, %{"id" => id}) do
if conn.assigns[:current_user].id == post.user_id do
post = Posts.get_post!(id)
{:ok, _post} = Posts.delete_post(post)
conn
|> put_flash(:info, "Post deleted successfully.")
|> redirect(to: ~p"/posts")
else
conn
|> put_flash(:error, "You can only delete your own posts.")
|> redirect(to: ~p"/posts/#{id}")
|> halt()
end
end
Testing Flash Messages
Phoenix 1.7 deprecated the get_flash/2 in favor of Phoenix.Flash.get/2. We can use this function in tests if we want to write assertions on flash messages.
Here’s an example of testing that a user cannot edit a resource that doesn’t belong to them.
test "a user cannot edit another user's post", %{conn: conn} do
post_user = user_fixture()
other_user = user_fixture()
post = post_fixture(user_id: post_user.id)
conn = conn |> log_in_user(other_user) |> get(~p"/posts/#{post}/edit")
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can only edit or delete your own posts."
assert redirected_to(conn) == ~p"/posts/#{post}"
end
Route Specificity
When we start splitting up routes into different scopes, it’s possible for routes to conflict.
For example, the following two routes conflict because the wildcard :id
would also handle the value "new"
, so the second route would never trigger.
get "posts/:id", PostController, :show
get "posts/new", PostController, :new
It’s just like having a case statement where the first clause always matches.
url = "/posts/new"
case url do
"/posts" <> id -> "show route"
# this case clause will never match.
"/posts/new" -> "new route"
end
You might encounter weird looking errors like the following if you run into this issue.
Accessing The Current User In The Assigns
By default, if the user is signed in, then the :current_user
is available in the conn.assigns
. This is set up by the :fetch_current_user
plug generated in the router.
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, {BlogWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
end
We can use the @current_user
value in any heex template, or provide the @current_user
to other function components if necessary.
For example, we can see the @current_user
‘s email is displayed in root.html.heex
by default.
<li>
<%= @current_user.email %>
</li>
Including The User Association In Forms
The @current_user
stored in the conn.assigns
is important if you need to include the current user in a form. Here’s an example of passing the user into a form component.
<.header>
New Post
<:subtitle>Use this form to manage post records in your database.
<.post_form changeset={@changeset} action={~p"/posts"} current_user={@current_user} />
<.back navigate={~p"/posts"}>Back to posts
Then the form component can send the user’s id as part of an HTTP request in body params, URL params, or query params. Here’s an example using body params.
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
<.input field={f[:user_id]} type="hidden" value={@current_user.id} />
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:content]} type="text" label="Content" />
<.input field={f[:published_on]} type="datetime-local" label="Publish On" value={DateTime.utc_now()} />
<.input field={f[:visible]} type="checkbox" label="Visible" />
<:actions>
<.button>Save Post
However, be mindful that accessing @current_user
directly requires that a user must be signed in. If you are on a page where a user doesn’t need to be signed in, then you can access the user safely using the access syntax.
assigns[:current_user]
Signing In A User In Tests
Some tests may require that you sign in a user. Fortunately, the ConnCase
module contains a generated log_in_user/2
function we can use to resolve these issues.
Here’s an example of logging in a user for a test.
test "renders form for editing chosen post", %{conn: conn} do
user = user_fixture()
post = post_fixture(user_id: user.id)
# log in the user
conn = conn |> log_in_user(user) |> get(~p"/posts/#{post}/edit")
assert html_response(conn, 200) =~ "Edit Post"
end
Hiding Elements
Some elements require authorization or authentication to display on the page.
Here’s an example of hiding an element based on if there is a current user.
<%= if assigns[:current_user] do %>
Only Authenticated Users Can See This
<% end %>
Here’s an example of hiding an element based on if the user owns a particular resource, in this case a comment
.
# It's Important To Verify A Map Exists Before Accessing Fields Such As `id`.
# Otherwise If The Assigns[:current_user] Map Is `nil`, Accessing `id` Of Nil
# Would Cause The Page To Crash.
<%= if assigns[:current_user] && comment.user_id == assigns[:current_user].id do %>
<.link href={~p"/comments/#{comment.id}"} method="delete" data-confirm="Are you sure?">
Delete
<% end %>
Preloading Nested Associations
Ecto.Query.preload/3 and Ecto.Repo.preload/3 can preload associated data if the schema includes a has_many/3 or belongs_to/3 association.
It’s also possible to preload nested associations by providing a keyword list to the preload function.
query = from p in Post, preload: [:user, comments: [:user]]
Instead of providing an atom, we can even provide another query modify how the associated results are preloaded.
Here’s an example that preloads post comments and sorts them by newest -> oldest. The comments themselves also preload their associated user.
comments_query = from c in Comment,
order_by: [desc: c.inserted_at, desc: c.id],
preload: :user
post_query = from p in Post, preload: [:user, comments: ^comments_query]
Repo.get!(post_query, id)
Further Reading
We’ve provided an overview of basic route-based and ownership-based authentication. However, authorization is a massive topic far beyond the scope of this course.
There is no single authorization solution, and an effective authorization implementation depends on the application’s needs. Therefore, always consider who should be able to access application resources and how to protect them effectively.
Consider the following resources as you continue to learn more about authorization and authentication:
- Authorization in Phoenix web applications using Role Based Access Control (RBAC)
- HexDocs: mix phx.gen.auth
Commit Your Progress
DockYard Academy now recommends you use the latest Release rather than forking or cloning our repository.
Run git status
to ensure there are no undesirable changes.
Then run the following in your command line from the curriculum
folder to commit your progress.
$ git add .
$ git commit -m "finish Phoenix Authentication reading"
$ git push
We’re proud to offer our open-source curriculum free of charge for anyone to learn from at their own pace.
We also offer a paid course where you can learn from an instructor alongside a cohort of your peers. We will accept applications for the June-August 2023 cohort soon.