Phoenix Authentication
Mix.install([
{:kino, github: "livebook-dev/kino", override: true},
{:kino_lab, "~> 0.1.0-dev", github: "jonatanklosko/kino_lab"},
{:vega_lite, "~> 0.1.4"},
{:kino_vega_lite, "~> 0.1.1"},
{:benchee, "~> 0.1"},
{:ecto, "~> 3.7"},
{:math, "~> 0.7.0"},
{:faker, "~> 0.17.0"},
{:utils, path: "#{__DIR__}/../utils"},
{:httpoison, "~> 1.8"},
{:poison, "~> 5.0"}
])
Navigation
Setup
Ensure you type the ea
keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.
Overview
We implement authorization in our applications to ensure only authorized clients can perform protected actions and access protected resources.
Failure to implement authorization properly can lead to severe repercussions. We can expose confidential information and leave our users vulnerable to harmful attacks.
To learn about authorization, we’re going to create a diary application. A diary is a private journal. Each user will have private diary entries that other users should not be able to access.
classDiagram
class User {
email: string
password: string
}
class Entry {
user_id: :id
title:
}
User --> Entry : Has many
Entry --> User : Belongs to
Create the Diary Application
Create a new phoenix diary project.
$ mix phx.new diary
Change directory into the project.
$ cd diary
Install dependencies.
$ mix deps.get
Create the Database and run migrations.
$ mix ecto.setup
Add Authorization
Fortunately, Phoenix provides a command to generate an authorization system for our applications.
Run the following command in the diary project folder.
$ mix phx.gen.auth Accounts User users
By running this command, we’ve created new files and modified existing files to implement an authorization system.
Compiling 14 files (.ex)
Generated diary app
* creating priv/repo/migrations/20220711044627_create_users_auth_tables.exs
* creating lib/diary/accounts/user_notifier.ex
* creating lib/diary/accounts/user.ex
* creating lib/diary/accounts/user_token.ex
* creating lib/diary_web/controllers/user_auth.ex
* creating test/diary_web/controllers/user_auth_test.exs
* creating lib/diary_web/views/user_confirmation_view.ex
* creating lib/diary_web/templates/user_confirmation/new.html.heex
* creating lib/diary_web/templates/user_confirmation/edit.html.heex
* creating lib/diary_web/controllers/user_confirmation_controller.ex
* creating test/diary_web/controllers/user_confirmation_controller_test.exs
* creating lib/diary_web/templates/layout/_user_menu.html.heex
* creating lib/diary_web/templates/user_registration/new.html.heex
* creating lib/diary_web/controllers/user_registration_controller.ex
* creating test/diary_web/controllers/user_registration_controller_test.exs
* creating lib/diary_web/views/user_registration_view.ex
* creating lib/diary_web/views/user_reset_password_view.ex
* creating lib/diary_web/controllers/user_reset_password_controller.ex
* creating test/diary_web/controllers/user_reset_password_controller_test.exs
* creating lib/diary_web/templates/user_reset_password/edit.html.heex
* creating lib/diary_web/templates/user_reset_password/new.html.heex
* creating lib/diary_web/views/user_session_view.ex
* creating lib/diary_web/controllers/user_session_controller.ex
* creating test/diary_web/controllers/user_session_controller_test.exs
* creating lib/diary_web/templates/user_session/new.html.heex
* creating lib/diary_web/views/user_settings_view.ex
* creating lib/diary_web/templates/user_settings/edit.html.heex
* creating lib/diary_web/controllers/user_settings_controller.ex
* creating test/diary_web/controllers/user_settings_controller_test.exs
* creating lib/diary/accounts.ex
* injecting lib/diary/accounts.ex
* creating test/diary/accounts_test.exs
* injecting test/diary/accounts_test.exs
* creating test/support/fixtures/accounts_fixtures.ex
* injecting test/support/fixtures/accounts_fixtures.ex
* injecting test/support/conn_case.ex
* injecting config/test.exs
* injecting mix.exs
* injecting lib/diary_web/router.ex
* injecting lib/diary_web/router.ex - imports
* injecting lib/diary_web/router.ex - plug
* injecting lib/diary_web/templates/layout/root.html.heex
The command above added new dependencies, so we need to install them.
$ mix deps.get
Migration
The mix phx.gen.auth
command generates a new migration file priv/repo/migrations/create_users_auth_tables.exs
with the current timestamp in the file name.
defmodule Diary.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end
The migration above creates :users
and :users_tokens
tables.
We’ll gain insight into what these tables do as we continue diving into the authorization system.
Run migrations.
$ mix ecto.migrate
User Registration
Now we can start the server and visit http://localhost:4000.
Notice there are now register and log-in links which we can find in the lib/diary_web/templates/layout/_user_menu.html.heex
template.
# lib/diary_web/templates/layout/_user_menu.html.heex
<ul>
<%= if @current_user do %>
<li><%= @current_user.email %></li>
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
<li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
<% else %>
<li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
<li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
<% end %>
</ul>
When we click the register link, the application directs us to http://localhost:4000/users/register.
This triggers the "users/register"
route.
# lib/diary_web/router.ex
get "/users/register", UserRegistrationController, :new
We’ve already walked through route -> controller -> view -> template flow in the previous Phoenix lesson. Re-read this lesson if it is not already clear how the "users/register"
route renders the lib/diary_web/templates/user_registration/new.html.heex
template.
flowchart LR
R[users/register]
C[UserRegistrationController.new/2]
V[UserRegistrationView]
T[user_registration/new.html.heex]
R --> C --> V --> T
Submit the form with any email (real or fake such as test@example.com
) and any valid password such as supersecretpassword
. We should never re-use personal passwords, particularly during development where they may not be private.
This form triggers an HTTP POST request to "/users/registration"
to create a new user.
# lib/diary_web/router.ex
post "/users/register", UserRegistrationController, :create
The UserRegistrationController.create/2
function creates a new user in the Database. If successful, it delivers a confirmation email. It then flashes a success message and logs the user in.
# lib/diary_web/controllers/user_registration_controller.ex
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :edit, &1)
)
conn
|> put_flash(:info, "User created successfully.")
|> UserAuth.log_in_user(user)
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
To accomplish this, the UserRegistrationController.create/2
function calls the following four functions.
-
Accounts.register_user/1
: Create a User in the Database. -
Accounts.deliver_user_confirmation_instructions/1
: Send a confirmation email. -
put_flash/2
: Attach a blue info message to the assigns to be displayed on the client. -
UserAuth.log_in_user/3
: Create a user token and store it on the client. The user will use this token to authenticate future requests.
Creating Users
The Accounts.register_user(user_params)
function call above creates a user with the provided email and password.
# lib/diary/accounts.ex
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
end
The User.registration_changeset(attrs)
function call uses Ecto changesets to validate the email and password.
# lib/diary/accounts/user.ex
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> validate_email()
|> validate_password(opts)
end
The validate_password(opts)
function call validates the password. There are several commented-out validation functions we may wish to uncomment to improve password strength.
# lib/diary/accounts/user.ex
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
Hashed Passwords
The maybe_hash_password(opts)
call generates the :hashed_password
using Bcrypt.
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
Hashing is a way of encrypting data using a mathematical algorithm. Hashing algorithms run on some input string and return an encrypted output.
We store the hash of the password rather than the actual password to improve security. For an overview of hashing, here’s a video by Seytonic.
Kino.YouTube.new("https://www.youtube.com/watch?v=--tnZMuoK3E")
The user schema includes a :password
and a :hashed_password
field.
# lib/diary/accounts/user.ex
defmodule Diary.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime
timestamps()
end
...
The redact: true
option redacts the :hashed_password
and the :password
if we attempt to IO.inspect/2
their values. See Redacting Fields for a full explanation.
The virtual: true
means the Database doesn’t store the :password
.
For security reasons, the users table only stores the :hashed_password
.
# priv/repo/migrations/_create_users_auth_tables.exs
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
Confirmation Emails
The deliver_user_confirmation_instructions/2
function sends a confirmation email to the user.
# lib/diary/accounts.ex
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do
{:error, :already_confirmed}
else
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
end
end
The UserNotifier
module in lib/diary/accounts/user_notifier.ex
uses Swoosh to send emails. Swoosh provides a deliver/2
function which takes an email address and a string with the desired email content to send.
# lib/diary/accounts/user_notifier.ex
def deliver_confirmation_instructions(user, url) do
deliver(user.email, "Confirmation instructions", """
==============================
Hi #{user.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
In development, emails are stored locally rather than sent because we don’t want to send development emails accidentally. We can view sent emails by visiting http://localhost:4000/dev/mailbox.
Sessions
Phoenix uses cookies to store a session key on each client to identify them. Cookies are a local storage mechanism used by browsers.
We can view this cookie. First, ensure the diary server is running, then visit http://localhost:4000. Next, open the inspector and go to Applications. Find Cookies under Storage and view the cookies for http://localhost:4000. Notice the _diary_key
cookie.
For each client connection, Phoenix stores a session in memory. The session stores client-specific information. We can set and retrieve values on the session using the get_session/2
and put_session/2
functions on the Plug.Conn struct .
Earlier, when we triggered the UserRegistrationController.create/2
function in lib/diary_web/controllers/user_registration_controller.ex
we called the UserAuth.log_in_user/3
function.
The UserAuth.log_in_user/3
function generates a user token and puts it on the session.
# lib/diary_web/controllers/user_auth.ex
def log_in_user(conn, user, params \\ %{}) do
token = Accounts.generate_user_session_token(user)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
Every client request includes their session key. Our Phoenix application uses this session key to retrieve the session which stores the user token.
The user token is associated with a user in the Database. Our application uses this token to retrieve the user for authorization purposes.
flowchart LR
subgraph Client
C[Session Key Cookie]
end
subgraph Server
subgraph Conn
subgraph Session
UT[User Token]
U[User]
UT --retrieve--> U
end
end
end
C --> Server
We’ll see this in action in the Authenticated Routes section.
Authenticated Routes
Earlier, when we ran mix phx.gen.auth
we generated many new authentication routes and added some new plugs.
The :browser
pipeline now uses the :fetch_current_user
plug to fetch the current user for authentication purposes.
# lib/journal_web/router.ex
pipeline :browser do
...
plug :fetch_current_user
end
The :redirect_if_user_is_authenticated
plug prevents an already signed-in user from accessing any routes in the scope, such as registering or logging in.
# lib/journal_web/router.ex
scope "/", DiaryWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update
end
The :require_authenticated_user
plug requires an authenticated user. We can use this plug to hide resources that should only be accessible to authenticated users.
# lib/journal_web/router.ex
scope "/", DiaryWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
Publicly available routes simply omit both plugs.
# lib/journal_web/router.ex
scope "/", DiaryWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :edit
post "/users/confirm/:token", UserConfirmationController, :update
end
These plugs come from the DiaryWeb.UserAuth
module.
The :redirect_if_user_is_authenticated
plug checks if there is a current user, and redirects the client to the signed_in_path/1
("/"
) if there is.
# lib/diary_web/controllers/user_auth.ex
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn))
|> halt()
else
conn
end
end
The :require_authenticated_user
plug checks if there is a current user and redirects the client to the Routes.user_session_path/2
("users/log_in"
) if there is not.
# lib/diary_web/controllers/user_auth.ex
@doc """
Used for routes that require the user to be authenticated.
If you want to enforce the user email is confirmed before
they use the application at all, here would be a good place.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new))
|> halt()
end
end
Both plugs use a :current_user
value on the conn.assigns
, which is defined in the :fetch_current_user
plug also in DiaryWeb.UserAuth
.
As previously mentioned, Phoenix uses the user token stored on the session to retrieve the user from the Database.
# lib/diary_web/controllers/user_auth.ex
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
end
Your Turn
We’ve walked through the flow of the "/users/register"
routes to understand the generated authentication system better.
Now, it’s your turn to explore the rest of the system. Read through the remaining routes and their associated controllers to get a good overview of the system.
## Authentication Routes
scope "/", DiaryWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/log_in", UserSessionController, :new
post "/users/log_in", UserSessionController, :create
get "/users/reset_password", UserResetPasswordController, :new
post "/users/reset_password", UserResetPasswordController, :create
get "/users/reset_password/:token", UserResetPasswordController, :edit
put "/users/reset_password/:token", UserResetPasswordController, :update
end
scope "/", DiaryWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
end
scope "/", DiaryWeb do
pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
get "/users/confirm", UserConfirmationController, :new
post "/users/confirm", UserConfirmationController, :create
get "/users/confirm/:token", UserConfirmationController, :edit
post "/users/confirm/:token", UserConfirmationController, :update
end
Diary
With our authorization system in place, we’re ready to add the diary functionality.
Run the following command to generate the boilerplate code for the Diary Notes resource.
$ mix phx.gen.html Entries Entry entries title:string content:text user_id:references:users
Make sure to run migrations.
$ mix ecto.migrate
Everything other than user_id:references:users
should be familiar from when we created a journal application in the previous Phoenix section.
This time, we need to associate each diary entry with a user and ensure users can only access their own entries.
First, we can add all of the routes for the "/entries"
and require an authenticated user to access them.
Add the following to lib/diary_web/router.ex
.
# lib/diary_web/router.ex
scope "/entries", DiaryWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/", EntryController
end
Clients can only perform entry-related actions after they’ve logged in.
By default, mix phx.gen.html
generates tests for the context and the controller.
We’ve broken the default tests by requiring an authenticated user to access the "/entries"
route.
Run the following command in the diary project folder, and the output should include eight failing tests.
$ mix test
To fix this issue, add the :register_and_login_user
setup function to the EntryControllerTest
module.
# test/diary_web/controllers/entry_controller_test.ex
defmodule DiaryWeb.EntryControllerTest do
use DiaryWeb.ConnCase
import Diary.EntriesFixtures
@create_attrs %{content: "some content", title: "some title"}
@update_attrs %{content: "some updated content", title: "some updated title"}
@invalid_attrs %{content: nil, title: nil}
setup :register_and_log_in_user
...
Now run mix test
again, and all tests should pass.
Create Diary Entries
While logged in, we can visit http://localhost:4000/entries/new to create a diary entry.
Currently, created entries are not associated with a user. We have an authorization issue where users can view and modify other users’ diary entries.
To resolve this issue, we need to associate entries with a user upon creation.
Modify the EntryController.create/2
function to include the user id in the entry_params
.
# lib/diary_web/controllers/entry_controller.ex
def create(conn, %{"entry" => entry_params}) do
entry_params = Map.put(entry_params, "user_id", conn.assigns[:current_user].id)
case Entries.create_entry(entry_params) do
{:ok, entry} ->
conn
|> put_flash(:info, "Entry created successfully.")
|> redirect(to: Routes.entry_path(conn, :show, entry))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
Modify the Entry.changeset/2
to include the :user_id
.
# lib/diary/entries/entry.ex
def changeset(entry, attrs) do
entry
|> cast(attrs, [:title, :content, :user_id])
|> validate_required([:title, :content])
end
Modify the EntryController.index/2
function to use the current user in the Entries.list_entries
function. We’ll use the user id to filter entries.
# lib/diary_web/controllers/entry_controller.ex
def index(conn, _params) do
entries = Entries.list_entries(conn.assigns[:current_user].id)
render(conn, "index.html", entries: entries)
end
Finally, create a new Entries.list_entries/1
function to accept the user_id
as an argument and use it to filter the entries query.
# lib/diary/entries.ex
def list_entries(user_id) do
Entry
|> where([e], e.user_id == ^user_id)
|> Repo.all()
end
Now when we visit http://localhost:4000/entries, we’ll only see entries created by the current user. Try creating an entry, sign out, create a new account with a different email, and notice there are no visible entries, because none belong to the current user.
Custom Authorization Plug
We’ve fixed the authorization issue where users can access a list of diaries that don’t belong to them, and now we associate created diary entries with a user.
However, we still have authorization issues. For example, users can show, edit, update, and delete entries that don’t belong to them.
We could extend the pattern above and write custom authorization for every EntryController
action. However, to avoid duplication, let’s create a custom plug that ensures users own the resource they are manipulating.
Remember that plugs are simply functions that take a conn struct as the first argument and parameters as the second argument.
We can define and execute a custom plug to run on the :show
, :edit
, :update
, and :delete
controller actions.
# lib/diary_web/controllers/entry_controller.ex
plug :require_user_owns_entry when action in [:show, :edit, :update, :delete]
Now create a :require_user_owns_entry
plug. This plug checks if the current entry belongs to the current user and redirects the client if it does not.
To accomplish this, it reads the entry id from conn.path_params
and converts it into an integer. It then retrieves the entry by the entry id and compares the entry.user_id
to the current user’s id.
If the entry belongs to the current user, it simply returns the current connection allowing the client to run the desired action. If the entry does not belong to the current user, it redirects the client to the entries :index
path and prevents the client from running the desired action.
# lib/diary_web/controllers/entry_controller.ex
def require_user_owns_entry(conn, _opts) do
entry_id = String.to_integer(conn.path_params["id"])
entry = Entries.get_entry!(entry_id)
if conn.assigns[:current_user].id == entry.user_id do
conn
else
conn
|> put_flash(:error, "You do not own this resource.")
|> redirect(to: Routes.entry_path(conn, :index))
|> halt()
end
end
Now users can only show, edit, update, and delete entries that belong to them.
Adding User Authentication to Tests
By adding the :require_user_owns_entry
plug we’ve broken several tests in the EntryControllerTest
module.
Run mix test
and we should see the following failed tests in the output.
1) test delete entry deletes chosen entry (DiaryWeb.EntryControllerTest)
test/diary_web/controllers/entry_controller_test.exs:72
expected error to be sent as 404 status, but response sent 302 without error
code: assert_error_sent 404, fn ->
stacktrace:
(phoenix 1.6.10) lib/phoenix/test/conn_test.ex:651: Phoenix.ConnTest.assert_error_sent/2
test/diary_web/controllers/entry_controller_test.exs:76: (test)
2) test update entry renders errors when data is invalid (DiaryWeb.EntryControllerTest)
test/diary_web/controllers/entry_controller_test.exs:63
** (RuntimeError) expected response with status 200, got: 302, with body:
"You are being redirected."
code: assert html_response(conn, 200) =~ "Edit Entry"
stacktrace:
(phoenix 1.6.10) lib/phoenix/test/conn_test.ex:369: Phoenix.ConnTest.response/2
(phoenix 1.6.10) lib/phoenix/test/conn_test.ex:383: Phoenix.ConnTest.html_response/2
test/diary_web/controllers/entry_controller_test.exs:65: (test)
3) test edit entry renders form for editing chosen entry (DiaryWeb.EntryControllerTest)
test/diary_web/controllers/entry_controller_test.exs:46
** (RuntimeError) expected response with status 200, got: 302, with body:
"You are being redirected."
code: assert html_response(conn, 200) =~ "Edit Entry"
stacktrace:
(phoenix 1.6.10) lib/phoenix/test/conn_test.ex:369: Phoenix.ConnTest.response/2
(phoenix 1.6.10) lib/phoenix/test/conn_test.ex:383: Phoenix.ConnTest.html_response/2
test/diary_web/controllers/entry_controller_test.exs:48: (test)
4) test update entry redirects when data is valid (DiaryWeb.EntryControllerTest)
test/diary_web/controllers/entry_controller_test.exs:55
Assertion with == failed
code: assert redirected_to(conn) == Routes.entry_path(conn, :show, entry)
left: "/entries"
right: "/entries/154"
stacktrace:
test/diary_web/controllers/entry_controller_test.exs:57: (test)
The current tests do not log in a user, so they fail on the :show
, :edit
, :update
, and :delete
actions.
# tests/diary_web/controllers/entry_controller_test.exs
describe "edit entry" do
setup [:create_entry]
test "renders form for editing chosen entry", %{conn: conn, entry: entry} do
conn = get(conn, Routes.entry_path(conn, :edit, entry))
assert html_response(conn, 200) =~ "Edit Entry"
end
end
describe "update entry" do
setup [:create_entry]
test "redirects when data is valid", %{conn: conn, entry: entry} do
conn = put(conn, Routes.entry_path(conn, :update, entry), entry: @update_attrs)
assert redirected_to(conn) == Routes.entry_path(conn, :show, entry)
conn = get(conn, Routes.entry_path(conn, :show, entry))
assert html_response(conn, 200) =~ "some updated content"
end
test "renders errors when data is invalid", %{conn: conn, entry: entry} do
conn = put(conn, Routes.entry_path(conn, :update, entry), entry: @invalid_attrs)
assert html_response(conn, 200) =~ "Edit Entry"
end
end
describe "delete entry" do
setup [:create_entry]
test "deletes chosen entry", %{conn: conn, entry: entry} do
conn = delete(conn, Routes.entry_path(conn, :delete, entry))
assert redirected_to(conn) == Routes.entry_path(conn, :index)
assert_error_sent 404, fn ->
get(conn, Routes.entry_path(conn, :show, entry))
end
end
end
To fix these tests, we need to ensure the created entry belongs to a user, and that the user is logged in.
The Diary.AccountsFixture
module provides a user_fixture/1
function for creating users. Import the module to get access to this function in our tests.
It’s conventional for imports to be grouped together, so add the new import statement below the existing import Diary.EntriesFixture
statement.
# test/diary_web/controllers/entry_controller_test.exs
import Diary.EntriesFixtures
import Diary.AccountsFixtures
Now remove all instances of setup [:create_entry]
from the EntryControllerTest
module. Also remove entry: entry
from the map passed to each test that was making use of setup [:create_entry]
. We can also remove the private create_entry/1
function since it’s no longer used.
Then put the following in each failing test to create a user, associate it with the created entry, and log the user in.
# test/diary_web/controllers/entry_controller_test.exs
user = user_fixture()
entry = entry_fixture(user_id: user.id)
conn = log_in_user(conn, user)
The final test/diary_web/controllers/entry_controller_test.exs
test file should have the following content.
defmodule DiaryWeb.EntryControllerTest do
use DiaryWeb.ConnCase
import Diary.EntriesFixtures
import Diary.AccountsFixtures
@create_attrs %{content: "some content", title: "some title"}
@update_attrs %{content: "some updated content", title: "some updated title"}
@invalid_attrs %{content: nil, title: nil}
setup :register_and_log_in_user
describe "index" do
test "lists all entries", %{conn: conn} do
conn = get(conn, Routes.entry_path(conn, :index))
assert html_response(conn, 200) =~ "Listing Entries"
end
end
describe "new entry" do
test "renders form", %{conn: conn} do
conn = get(conn, Routes.entry_path(conn, :new))
assert html_response(conn, 200) =~ "New Entry"
end
end
describe "create entry" do
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, Routes.entry_path(conn, :create), entry: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.entry_path(conn, :show, id)
conn = get(conn, Routes.entry_path(conn, :show, id))
assert html_response(conn, 200) =~ "Show Entry"
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.entry_path(conn, :create), entry: @invalid_attrs)
assert html_response(conn, 200) =~ "New Entry"
end
end
describe "edit entry" do
test "renders form for editing chosen entry", %{conn: conn} do
user = user_fixture()
entry = entry_fixture(user_id: user.id)
conn = log_in_user(conn, user)
conn = get(conn, Routes.entry_path(conn, :edit, entry))
assert html_response(conn, 200) =~ "Edit Entry"
end
end
describe "update entry" do
test "redirects when data is valid", %{conn: conn} do
user = user_fixture()
entry = entry_fixture(user_id: user.id)
conn = log_in_user(conn, user)
conn = put(conn, Routes.entry_path(conn, :update, entry), entry: @update_attrs)
assert redirected_to(conn) == Routes.entry_path(conn, :show, entry)
conn = get(conn, Routes.entry_path(conn, :show, entry))
assert html_response(conn, 200) =~ "some updated content"
end
test "renders errors when data is invalid", %{conn: conn} do
user = user_fixture()
entry = entry_fixture(user_id: user.id)
conn = log_in_user(conn, user)
conn = put(conn, Routes.entry_path(conn, :update, entry), entry: @invalid_attrs)
assert html_response(conn, 200) =~ "Edit Entry"
end
end
describe "delete entry" do
test "deletes chosen entry", %{conn: conn} do
user = user_fixture()
entry = entry_fixture(user_id: user.id)
conn = log_in_user(conn, user)
conn = delete(conn, Routes.entry_path(conn, :delete, entry))
assert redirected_to(conn) == Routes.entry_path(conn, :index)
assert_error_sent 404, fn ->
get(conn, Routes.entry_path(conn, :show, entry))
end
end
end
end
Run mix test
and all tests should pass.
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.
- Authorization in Phoenix web applications using Role Based Access Control (RBAC)
- HexDocs: mix phx.gen.auth
Commit Your Progress
Run the following in your command line from the project folder to track and save your progress in a Git commit.
$ git add .
$ git commit -m "finish phoenix authentication section"