Sep 7, 2020

Phoenix Authentication with Phx.Gen.Auth - Part 1

In the past I’ve primarily used Pow as an authentication solution for Phoenix. It’s well documented and fairly easy to customize.

However, it’s always fun to kick the tires on something new. Considering Phx.Gen.Auth comes from José Valim, the author of Elixir, it seems likely it will find a fair bit of adoption within the community.

Phx.Gen.Auth is a little different than other authentication solutions in that; as hinted by it’s name; it generates and injects the authentication code directly into your application. This blog post by José Valim provides some motivation around the thinking behind this.

In anycase, we’ll look at how to get Phx.Gen.Auth set-up and then how to add some custom workflows and functionality.

Specifically we’ll be:

  • Adding a password confirmation field to the registration page.
  • Altering the registration workflow to prevent authentication prior to account confirmation.
  • Adding the ability to block a user.
  • Swapping the default console logger notifications with Bamboo.

I’d encourage you to follow along, but if you want to skip directly to the code, it’s available on GitHub at: https://github.com/riebeekn/phx-gen-auth-example.

Let’s get at it!

Creating the app and adding Phx.Gen.Auth

We’ll be working with a traditional Phoenix application but if you’re working with LiveView things will be pretty much the same.

Note: A good walk through and example of how to make use of Phx.Gen.Auth within LiveView is contained in this video. Around the 34 minute mark is where consuming Phx.Gen.Auth tokens within a LiveView app is discussed.

Let’s fire up a terminal session and create the application.

Terminal
mix phx.new auth

Select yes when asked to install dependencies.

Once that’s completed we’ll head over to the GitHub page for Phx.Gen.Auth and follow the installation instructions. The installation steps are well laid out and we’ll be following them pretty much verbatim.

First we change into the directory where our application has been created.

Terminal
cd auth

And now add the Phx.Gen.Auth dependency to the mix.exs file.

/mix.exs … line 34
defp deps do
  [
    {:phoenix, "~> 1.5.4"},
    {:phoenix_ecto, "~> 4.1"},
    {:ecto_sql, "~> 3.4"},
    {:postgrex, ">= 0.0.0"},
    {:phoenix_html, "~> 2.11"},
    {:phoenix_live_reload, "~> 1.2", only: :dev},
    {:phoenix_live_dashboard, "~> 0.2"},
    {:telemetry_metrics, "~> 0.4"},
    {:telemetry_poller, "~> 0.4"},
    {:gettext, "~> 0.11"},
    {:jason, "~> 1.0"},
    {:plug_cowboy, "~> 2.0"},
    {:phx_gen_auth, "~> 0.4.0", only: [:dev], runtime: false}
  ]
end

After updating the mix.exs file, we need to grab the new dependency:

Terminal
mix deps.get

With that accomplished, we can generate the authentication code.

Terminal
mix phx.gen.auth Accounts User users

After generation we need to grab dependencies once again, and create the database for our application.

Terminal
mix do deps.get, ecto.create, ecto.migrate

And we now have a default installation of Phx.Gen.Auth up and running… was that easy or what!

We can see our authentication code in action by firing up the Phoenix server.

Terminal
mix phx.server

When navigating to http://localhost:4000/ we’ll see we have some authentication specific links available:

Clicking the Register link brings us to a registation page where we can set-up a new user.

After registering the user, we’ll see that we are logged in by default.

And a confirmation notification has been pushed to the iex console.

The generator creates screens for updating the user’s settings; handling forgotten passwords; and pretty much everything we need for a basic authentication system. I’d encourage you to run thru the various authentication screens to get a feel for the workflow.

One great feature of Phx.Gen.Auth is it generates tests as well as application code. We can kick off the tests via the terminal.

Terminal
mix test

These tests will come in handy when we start to modify the authentication behaviour in the next section.

Adding a secured page

Before customzing the Phx.Gen.Auth code, let’s see how we can go about restricting access to parts of the application. We’ll start by creating a new template for the existing page controller.

Terminal
touch lib/auth_web/templates/page/secure.html.eex
/lib/auth_web/templates/page/secure.html.eex
<section class="phx-hero">
  <h1>This page will only appear if you are authenticated!</h1>
</section>

Nothing fancy, just a simply template with a bit of header text.

Let’s hook this up in the controller.

/lib/auth_web/controllers/page_controller.ex
defmodule AuthWeb.PageController do
  use AuthWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end

  def secure(conn, _params) do
    render(conn, "secure.html")
  end
end

All we’ve done is add a function to render the new template.

The "magic", such as it exists happens in the router.

/lib/auth_web/router.ex …line 61
scope "/", AuthWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/users/settings", UserSettingsController, :edit
  put "/users/settings/update_password", UserSettingsController, :update_password
  put "/users/settings/update_email", UserSettingsController, :update_email
  get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email

  get "/secured_page", PageController, :secure
end

Some new scopes and routes have been added to router.ex during the Phx.Gen.Auth set-up. To add a protected page / route we just need to add it to the scope which pipes thru :require_authenticated_user.

If you want to keep the generated routes seperate from your custom routes, just create a new block. For instance instead of the above we could have created a new block:

/lib/auth_web/router.ex
scope "/", AuthWeb do
  pipe_through [:browser, :require_authenticated_user]

  get "/secured_page", PageController, :secure
end

Now when attempting to access http://localhost:4000/secured_page without first authenticating, we’ll be redirected to the Log In page and presented with an appropriate message.

As expected, after logging in we can view the page.

Customizing Phx.Gen.Auth

Next let’s look at how we can customize Phx.Gen.Auth.

We’ll start off with a very simple change; adding a password confirmation field to the Registration page.

Adding a password confirmation field on registration

We need to update the new.html.eex registration template.

/lib/auth_web/templates/user_registration/new.html.eex
<h1>Register</h1>
...
...

  <%= label f, :password %>
  <%= password_input f, :password, required: true %>
  <%= error_tag f, :password %>

  <%= label f, :password_confirmation, "Confirm password" %>
  <%= password_input f, :password_confirmation, required: true %>
  <%= error_tag f, :password_confirmation %>

  <div>
    <%= submit "Register" %>
  ...
  ...

All we’ve done is add a password_confirmation label, input and error tag under the existing password items.

In user.ex we need to make a small change to the registation_changeset.

/lib/auth/accounts/user.ex …line 23
def registration_changeset(user, attrs) do
  user
  |> cast(attrs, [:email, :password])
  |> validate_confirmation(:password, message: "does not match password")
  |> validate_email()
  |> validate_password()
end

We’ve added a validate_confirmation check to ensure the passwords match.

Finally, we need to update the tests affected by our changes.

Let’s start with user_registration_controller_test.exs, we’ll make 2 changes.

/test/auth_web/controllers/user_registration_controller_test.exs …line 23
test "creates account and logs the user in", %{conn: conn} do
  email = unique_user_email()

  conn =
    post(conn, Routes.user_registration_path(conn, :create), %{
      "user" => %{
        "email" => email,
        "password" => valid_user_password(),
        "password_confirmation" => valid_user_password()
      }
    })
    ...
    ...

The password_confirmation field has beed added to the map of values.

Now let’s update the test that validates the submitted data.

/test/auth_web/controllers/user_registration_controller_test.exs
    test "render errors for invalid data", %{conn: conn} do
      conn =
        post(conn, Routes.user_registration_path(conn, :create), %{
          "user" => %{
            "email" => "with spaces",
            "password" => "too short",
            "password_confirmation" => "does not match"
          }
        })

      response = html_response(conn, 200)
      assert response =~ "<h1>Register</h1>"
      assert response =~ "must have the @ sign and no spaces"
      assert response =~ "should be at least 12 character"
      assert response =~ "does not match password"
    end

We’ve again added the password_confirmation field and added an additional assert to check for the "does not match password" error message.

We should also update accounts_test.exs.

/test/auth/accounts_test.exs …line 60
test "validates email and password when given" do
  {:error, changeset} =
    Accounts.register_user(%{
      email: "not valid",
      password: "not valid",
      password_confirmation: "not matching"
    })

  assert %{
           email: ["must have the @ sign and no spaces"],
           password: ["should be at least 12 character(s)"],
           password_confirmation: ["does not match password"]
         } = errors_on(changeset)
end

Similar to the invalid data test in the controller we’re checking for the password_confirmation error message.

We also want to add the password_confirmation value to the map we pass to Accounts.register_user:

/test/auth/accounts_test.exs …line 92
test "registers users with a hashed password" do
  email = unique_user_email()

  {:ok, user} =
    Accounts.register_user(%{
      email: email,
      password: valid_user_password(),
      password_confirmation: valid_user_password()
    })

  assert user.email == email
  assert is_binary(user.hashed_password)
  assert is_nil(user.confirmed_at)
  assert is_nil(user.password)
end

Running our tests, all should be good:

Terminal
mix test

Sweet! Now when registering a user, a password confirmation field is present.

Next let’s look at updating the registration workflow. This will be a more involved change.

Updating the registration workflow

Currently user’s can access the application without confirming their account. They are authenticated immediately after signing up, and are able to log in and log out of the application. Let’s see what would be required to restrict our application to confirmed users.

Determining how authentication works on the back-end

The first thing we want to do is figure out how authentication actually works! A quick look at the user_session_controller offers a hint.

/lib/auth_web/controllers/user_session_controller.ex
def create(conn, %{"user" => user_params}) do
  %{"email" => email, "password" => password} = user_params

  if user = Accounts.get_user_by_email_and_password(email, password) do
    UserAuth.log_in_user(conn, user, user_params)

So it looks like Accounts.get_user_by_email_and_password is the function we’re after, let’s check it out!

/lib/auth/accounts.ex
def get_user_by_email_and_password(email, password)
    when is_binary(email) and is_binary(password) do
  user = Repo.get_by(User, email: email)
  if User.valid_password?(user, password), do: user
end

It appears the method returns a user on success, nil on failure. This matches what we’re seeing in the controller code and also matches what the tests indicate.

/test/auth/accounts_test.exs
describe "get_user_by_email_and_password/1" do
  test "does not return the user if the email does not exist" do
    refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
  end

  test "does not return the user if the password is not valid" do
    user = user_fixture()
    refute Accounts.get_user_by_email_and_password(user.email, "invalid")
  end

  test "returns the user if the email and password are valid" do
    %{id: id} = user = user_fixture()

    assert %User{id: ^id} =
             Accounts.get_user_by_email_and_password(user.email, valid_user_password())
  end
end

Another great bonus of tests is they can help with understanding and confirming the expected functionality of an application… if you write tests, your future self and others will thank you!

With the above knowledge in hand, we’re ready to make our change.

Updating the code

We will need an additional check in get_user_by_email_and_password to ensure the user is confirmed, so let’s add that.

/lib/auth/accounts.ex
def get_user_by_email_and_password(email, password)
    when is_binary(email) and is_binary(password) do
  user = Repo.get_by(User, email: email)
  if User.valid_password?(user, password) && User.is_confirmed?(user), do: user
end

Simple! We have a new is_confirmed? condition that we’ll create in user.ex.

/lib/auth/accounts/user.ex
...
...
@doc """
Returns true if the user has confirmed their account, false otherwise
"""
def is_confirmed?(user), do: user.confirmed_at != nil
...
...

Creating a new function here is perhaps going overboard. We could just perform the check explicitly in accounts, for example:

if User.valid_password?(user, password) && user.confirmed_at != nil, do: user

But adding a function, I feel sticks closer stylistically to the existing valid_password? check, and more explicitly states what it is we are checking for.

With the above minor changes we already have a decent first swipe at an implementation… before trying things out let’s see what’s happened with our tests.

Terminal
mix test

Whoops, as Jay-Z would say, we’ve got 7 problems and they’re all failing unit tests… actually I’m pretty sure Jay-Z never says that… and I promise no more attempts at lame jokes.

Fixing the tests

Looking at the test results, the most basic of scenarios where we expect a user to be authenticated with valid credentials fails, i.e.

This makes sense as we’ve now added the is_confirmed? check to the validation logic. In our tests we call Auth.AccountsFixtures.user_fixture() to get the user under test, so we need to update the fixture to set the confirmed_at value for the user. Let’s see how that might look.

/test/support/fixtures/accounts_fixtures.ex
defmodule Auth.AccountsFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `Auth.Accounts` context.
  """

  # NEW - added some aliases
  alias Auth.Repo
  alias Auth.Accounts.{User, UserToken}

  def unique_user_email, do: "user#{System.unique_integer()}@example.com"
  def valid_user_password, do: "hello world!"

  def user_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> Enum.into(%{
        email: unique_user_email(),
        password: valid_user_password()
      })
      |> Auth.Accounts.register_user()

    # NEW - confirm the user prior to returning
    Repo.transaction(confirm_user_multi(user))

    user
  end

  def extract_user_token(fun) do
    {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]")
    [_, token, _] = String.split(captured.body, "[TOKEN]")
    token
  end

  # NEW - added a private function for confirming the user
  defp confirm_user_multi(user) do
    Ecto.Multi.new()
    |> Ecto.Multi.update(:user, User.confirm_changeset(user))
    |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
  end
end

Essentially what we’ve done here is replicate some of the functionality from accounts.ex that relates to confirming a user. Now when we get a user from the fixure, their account will be confirmed.

If we re-run the basic autentication test it now passes.

Terminal
mix test test/auth/accounts_test.exs:29

How about if we run all our tests?

Terminal
mix test

Not so good! We have 4 failing tests, if we look at the details of those tests we’ll see they are related to user confirmation. So for some of our tests we need a confirmed user, for others we need an unconfirmed user. Therefore we need to add this flexibility in the fixture.

/test/support/fixtures/accounts_fixtures.ex
defmodule Auth.AccountsFixtures do
  @moduledoc """
  This module defines test helpers for creating
  entities via the `Auth.Accounts` context.
  """

  alias Auth.Repo
  alias Auth.Accounts.{User, UserToken}

  def unique_user_email, do: "user#{System.unique_integer()}@example.com"
  def valid_user_password, do: "hello world!"

  def user_fixture(attrs \\ %{}, opts \\ []) do
    {:ok, user} =
      attrs
      |> Enum.into(%{
        email: unique_user_email(),
        password: valid_user_password()
      })
      |> Auth.Accounts.register_user()

    if Keyword.get(opts, :confirmed, true), do: Repo.transaction(confirm_user_multi(user))

    user
  end

  def extract_user_token(fun) do
    {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]")
    [_, token, _] = String.split(captured.body, "[TOKEN]")
    token
  end

  defp confirm_user_multi(user) do
    Ecto.Multi.new()
    |> Ecto.Multi.update(:user, User.confirm_changeset(user))
    |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
  end
end

There is now an optional keyword list available as a second parameter on user_fixture. In the function itself, we check for the confirmed keyword, and default confirmed to true when it is not present. This means by default we’ll set our user to confirmed, but have the option of creating an unconfirmed user. In the case of the registration tests, this is exactly what we need, so we can now update them as below.

/test/auth/accounts_test.exs
...
...
describe "confirm_user/2" do
  setup do
    user = user_fixture(%{}, confirmed: false)
    ...
    ...
/test/auth_web/controllers/user_confirmation_controller_test.exs
defmodule AuthWeb.UserConfirmationControllerTest do
  ...
  ...

  setup do
    %{user: user_fixture(%{}, confirmed: false)}
  end
...
...

We’re back to passing tests.

Terminal
mix test

Fantastic!

Adding a test for our new functionality

Before moving on we should add a new test to ensure we don’t return an unconfirmed user from get_user_by_email_and_password.

/test/auth/accounts_test.exs
describe "get_user_by_email_and_password/1" do
  ...
  ...

  test "does not return the user if their account has not been confirmed" do
    user = user_fixture(%{}, confirmed: false)

    refute Accounts.get_user_by_email_and_password(user.email, valid_user_password())
  end

  ...
  ...

When we run our tests, we should now see an additional passing test.

Terminal
mix test

How do things look in the UI?

So we have our tests sorted, let’s try out the new functionality. Logging in with an unconfirmed user yields:

Good news and bad news. The user is being refused entry to the application, but the message doesn’t make a lot of sense.

What about if we register a new user?

Yikes, that’s not good, they get authenticated on registration just like before. Looks like we still have some work ahead of us.

Updating the registration workflow (for real!)

We’ve made some decent progress, but need to address the messaging and the registration workflow.

Updating the code

We’ll ignore the tests for now and get thru the implementation, the changes are pretty simple. We’re going to need to update what we return from get_user_by_email_and_password to include more information about what has occurred. After doing so we’ll make some controller updates.

Let’s start off by changing get_user_by_email_and_password.

/lib/auth/accounts.ex
def get_user_by_email_and_password(email, password)
    when is_binary(email) and is_binary(password) do
  user = Repo.get_by(User, email: email)

  cond do
    !User.valid_password?(user, password) -> {:error, :bad_username_or_password}
    !User.is_confirmed?(user) -> {:error, :not_confirmed}
    true -> {:ok, user}
  end
end

We’re now returning :ok / :error tuples so we can provide specific error status atoms.

This will require us to change user_session_controller where we call get_user_by_email_and_password.

/lib/auth_web/controllers/user_session_controller.ex
def create(conn, %{"user" => user_params}) do
  %{"email" => email, "password" => password} = user_params

  with {:ok, user} <- Accounts.get_user_by_email_and_password(email, password) do
    UserAuth.log_in_user(conn, user, user_params)
  else
    {:error, :bad_username_or_password} ->
      render(conn, "new.html", error_message: "Invalid e-mail or password")

    {:error, :not_confirmed} ->
      user = Accounts.get_user_by_email(email)

      Accounts.deliver_user_confirmation_instructions(
        user,
        &Routes.user_confirmation_url(conn, :confirm, &1)
      )

      render(conn, "new.html",
        error_message:
          "Please confirm your email before signing in.  An email confirmation link has been sent to you."
      )
  end
end

We’ve swapped out the “if Accounts.get_user_by_email_and_password(email, password) do...” condition for a with statement. This allows us to handle the different reasons for why a user may be denied access. In the case where they haven’t confirmed their account, we provide appropriate feedback in the error_message and also re-send the confirmation notification.

The only thing left to do is not authenticate the user on registration.

/lib/auth_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, :confirm, &1)
        )

      conn
      |> put_flash(
        :info,
        "User created successfully.  Please check your email for confirmation instructions."
      )
      # |> UserAuth.log_in_user(user)
      |> redirect(to: Routes.user_session_path(conn, :new))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

We’ve removed the call to UserAuth.log_in_user(user) (the AuthWeb.UserAuth alias can be removed as well) and updated the success message. A redirect has been added after the put_flash statement as UserAuth.login was handling the redirect previously.

Now if we register a user we see they are no longer authenticated upon registation.

And if we attempt to log-in prior to confirming the user account, we get both a more informative message:

… and a confirmation notification:

Note: each time a confirmation notification is sent, a new confirmation token will be inserted in the database.

This won’t be an issue however, as the tokens will all be cleaned up on confirmation, i.e.

And the tokens are gone, perfect!

We’ve made a number of changes, let’s see what happens with the tests.

Terminal
mix test

Five failures, not too bad, our last task for the day will be getting the tests sorted.

Fixing the tests

Even without looking at the failures I know the primary issue is going to be the return type change we made to get_user_by_email_and_password. So let’s update the associated accounts.exs tests to handle the new return type.

/test/auth/accounts_test.exs
describe "get_user_by_email_and_password/1" do
  test "does not return the user if the email does not exist" do
    assert {:error, :bad_username_or_password} ==
             Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
  end

  test "does not return the user if the password is not valid" do
    user = user_fixture()

    assert {:error, :bad_username_or_password} ==
             Accounts.get_user_by_email_and_password(user.email, "invalid")
  end

  test "does not return the user if their account has not been confirmed" do
    user = user_fixture(%{}, confirmed: false)

    assert {:error, :not_confirmed} ==
             Accounts.get_user_by_email_and_password(user.email, valid_user_password())
  end

  test "returns the user if the email and password are valid" do
    %{id: id} = user = user_fixture()

    assert {:ok, %User{id: ^id}} =
             Accounts.get_user_by_email_and_password(user.email, valid_user_password())
  end
end

With the updated return values, running the tests now yields…

Terminal
mix test

A single failure with the registration controller. We need to update the create test to indicate we no longer log the user in upon registation.

/test/auth_web/controllers/user_registration_controller_test.exs …line 23
test "creates account and DOES NOT log the user in", %{conn: conn} do
  email = unique_user_email()

  conn =
    post(conn, Routes.user_registration_path(conn, :create), %{
      "user" => %{
        "email" => email,
        "password" => valid_user_password(),
        "password_confirmation" => valid_user_password()
      }
    })

  refute get_session(conn, :user_token)
  assert redirected_to(conn) =~ "/users/log_in"

  assert flash_messages_contain(
           conn,
           "User created successfully.  Please check your email for confirmation instructions."
         )
end

defp flash_messages_contain(conn, text) do
  conn
  |> Phoenix.Controller.get_flash()
  |> Enum.any?(fn item -> String.contains?(elem(item, 1), text) end)
end

The test title has been updated to indicate we no longer log in the user upon registation. We also call refute on get_session to ensure we don’t have a user session after registering. Finally, the expected flash message has also been updated to reflect the new message.

And with that, our tests are all passing!

Terminal
mix test

Adding one more test

We should also add a user_session_controller test to reflect what occurs when a user attempts to log in without first confirming their account.

We’ll add this right after the emits error message with invalid credentials test.

/test/auth_web/controllers/user_session_controller_test.exs …line 57
test "emits error message with invalid credentials", %{conn: conn, user: user} do
  ...
  ...
end

test "emits error message when account is not confirmed", %{conn: conn} do
  user = user_fixture(%{}, confirmed: false)

  conn =
    post(conn, Routes.user_session_path(conn, :create), %{
      "user" => %{
        "email" => user.email,
        "password" => valid_user_password(),
        "remember_me" => "true"
      }
    })

  response = html_response(conn, 200)
  assert response =~ "<h1>Log in</h1>"
  assert response =~ "Please confirm your email before signing in.  An email confirmation link has been sent to you."
end

We create a non-confirmed user thru the fixture, attempt to log in them in and check for an appropriate response.

A final test run shows 103 passing tests.

Terminal
mix test

Summary

That’s it for today, Phx.Gen.Auth provides a really nice authentication solution. It’s easy to set up and modify; while the included tests provide a nice safety blanket when customizing the functionality.

In part 2 we’ll look at:

  • Adding the ability to block a user.
  • Swapping out the console notifications with email notifications.

Thanks for reading and I hope you enjoyed the post!



Comment on this post!