Authentication in Elixir Web Applications with Ueberauth and Guardian: Part 2

This is the second post in a series covering authentication in a Phoenix application using Guardian and Ueberauth. In the first post, we created a basic Phoenix application with pages for registering for an account and logging into an account, as well as setting up our test framework for outside-in testing. In this post, we will add the ability for a user to create an account with the application using an email address and password.

Test driving account creation from the outside in

As with the first post, a great place to begin is by adding a test from the user’s perspective for registering for an account. This feature test will visit the registration page, fill in the required information, submit the request, and make assertions that verify the user is logged in to their new account.

# test/yauth_web/features/registering_for_an_account_test.exs
defmodule YauthWeb.Features.RegisteringForAnAccountTest do
  use YauthWeb.FeatureCase, async: true
  import Query, only: [fillable_field: 1, button: 1]

  @email_field fillable_field("account[email]")
  @password_field fillable_field("account[password]")
  @password_confirmation_field fillable_field("account[password_confirmation]")
  @submit_button button("Register")

  test "Registering for an account with valid info", %{session: session} do
    session =
      session
      |> visit("/register")
      |> fill_in(@email_field, with: "me@example.com")
      |> fill_in(@password_field, with: "superdupersecret")
      |> fill_in(@password_confirmation_field, with: "superdupersecret")
      |> click(@submit_button)

    assert_text(session, "Hello, me@example.com")
  end
end

This test will fail when we run it because the text the test expects to see on the page isn’t present. Unfortunately, this test doesn’t reveal the source of this failure. If we run through the feature in a browser, we’ll see that the browser is displaying a 404 page. Displaying a 404 page is the default behavior in Phoenix (when MIX_ENV is not “dev”) when a route is requested that does not exist. Our form on the registration page submits its data to the server as a POST request to “/register” that our router does not yet support.

To fix that add a post route to our Router for the “/register” path.

# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
  use YauthWeb, :router

  # ...
  scope "/", YauthWeb do
    pipe_through [:browser]
    get "/register", RegistrationController, :new
    post "/register", RegistrationController, :create
    # ...
  end
end

Running the test again reveals that our RegistrationController does not have a create/2 function.

Creating an account from user-provided parameters

Up to this point in our posts, we’ve largely been adding views and templates. Now things are starting to get interesting. Now that we have a POST route, we will need code that receives the incoming parameters, ensures those parameters are valid, creates an account, and adds the account token to the browser session. That’s a fair amount of work to do and we don’t want all of that sitting in our controller. Let’s break it down one step at a time.

First, add the create/2 function to our RegistrationController.

# lib/yauth_web/controllers/registration_controller.ex
defmodule YauthWeb.RegistrationController do
  use YauthWeb, :controller
  plug Ueberauth
  # ...
  def create(%{assigns: %{ueberauth_auth: auth_params}} = conn, _params) do
    # ...
  end
end

In Phoenix, most controller functions that handle POST requests deal with the second argument to the function as they contain the form parameters. By Plugging Ueberauth in our controller, however, Ueberauth adds the form arguments to the assigns field of the %Plug.Conn{} and places them within the standard data structure provided by and expected by Ueberauth functions. Instead of getting the data we need from the parameters, then, we will get them from the connection assigns by pattern matching on that structure to extract the data we need.

Now that we have bound a variable to those parameters, we need to validate them and create an account. Those responsibilities are better handled outside of our controller function. A popular pattern in Phoenix development is to use “contexts”—slices of our application domain that provide an API to the rest of the application code. As our user is registering for an “account”, an “Accounts” context seems a fitting place for our controller to send the parameters for processing. Let’s “write the code we wish we had” and then fill in the details:

# lib/yauth_web/controllers/registration_controller.ex
defmodule YauthWeb.RegistrationController do
  # ...
  plug Ueberauth
  alias Yauth.Accounts

  def create(%{assigns: %{ueberauth_auth: auth_params}} = conn, _params) do
    case Accounts.register(auth_params) do
      {:ok, account} ->
        redirect(conn, to: Routes.profile_path(conn, :show))

      {:error, changeset} ->
        render(conn, :new,
          changeset: changeset,
          action: Routes.registration_path(conn, :create)
        )
    end
  end
end

Here our controller sends the parameters to a register/1 function and expects to receive back either the tuple {:ok, account} with a newly created account or an error tuple with a changeset. If an account is created and returned, we will redirect the user to their profile page. If not, we will show them the form again, along with any errors present on the changeset.

Before diving into that function, let’s pause and add the dependencies and configuration we’ll need to work with Ueberauth. Add the following dependencies to your project:

defmodule Yauth.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:ueberauth, "~> 0.6.3"},
      {:ueberauth_identity, "~> 0.3.0"},
      # ...
    ]
  end
  # ...
end

Next, add the following Ueberauth configuration:

# config/config.exs
# ...
config :ueberauth, Ueberauth,
  providers: [
    identity: {Ueberauth.Strategy.Identity, [
      param_nesting: "account",
      request_path: "/register",
      callback_path: "/register",
      callback_methods: ["POST"]
    ]}
  ]

# ...
import_config "#{Mix.env()}.exs"

Ueberauth is a two-phased authentication approach. In the first phase the user is challenged to present their credentials. In the second phase those credentials are validated by the service presenting the challenge—be that your application or an OAuth provider. To accomplish this two phase approach while maximizing flexibility, Ueberauth provides a common “strategy” interface that allows developers to implement different authentication strategies (e.g. Google OAuth, Twitter OAuth, GitHub OAuth, etc). Strategies are available for many common authentication options. We are using the “identity” strategy, so called because the user provides information about their identity such as an email address and password of their choosing.

As we are using an Account changeset for our challenge-phase form, Phoenix prefixes each of our form fields with account and submits the data nested under an “account” key. As such, we need to tell the Identity strategy to find its data nested under “account” so we use the param_nesting configuration option. Most OAuth authentication options send data about an authenticated resource as a GET request but our form is submitted as a POST request. We allow the Identity strategy to use that request by adding the callback_methods configuration options.

With that configuration accomplished, we can now return to our Accounts context and implement our register/1 function. Up to this point, our feature test has been sufficient to drive our changes. With the introduction of our “context” module, now is a good time to drop down a level of the testing pyramid and introduce an integration test for this register/1 function. What does that register function need to do? Let’s stipulate a few requirements up front:

  • If it receives a new email and matching passwords, it should create a new account.
  • If it receives an email that already exists, it should not create a new account; email addresses should be unique in the application.
  • If it receives a password and password confirmation that do not match, it should not create a new account.

For the sake of brevity, let’s jump ahead a bit in the test-driven process and add all of the tests for those requirements in one go.

# test/yauth/accounts_test.exs
defmodule Yauth.AccountsTest do
  use Yauth.DataCase

  alias Yauth.Accounts
  alias Yauth.Accounts.Account

  test "register for an account with valid information" do
    pre_count = count_of(Account)
    params = valid_account_params()

    result = Accounts.register(params)

    assert {:ok, %Account{}} = result
    assert pre_count + 1 == count_of(Account)
  end

  test "register for an account with an existing email address" do
    params = valid_account_params()
    Repo.insert!(%Account{email: params.info.email})

    pre_count = count_of(Account)

    result = Accounts.register(params)

    assert {:error, %Ecto.Changeset{}} = result
    assert pre_count == count_of(Account)
  end

  test "register for an account without matching password and confirmation" do
    pre_count = count_of(Account)
    %{credentials: credentials} = params = valid_account_params()

    params = %{
      params
      | credentials: %{
          credentials
          | other: %{
              password: "superdupersecret",
              password_confirmation: "somethingelse"
            }
        }
    }

    result = Accounts.register(params)

    assert {:error, %Ecto.Changeset{}} = result
    assert pre_count == count_of(Account)
  end

  defp count_of(queryable) do
    Yauth.Repo.aggregate(queryable, :count, :id)
  end

  defp valid_account_params do
    %Ueberauth.Auth{
      credentials: %Ueberauth.Auth.Credentials{
        other: %{
          password: "superdupersecret",
          password_confirmation: "superdupersecret"
        }
      },
      info: %Ueberauth.Auth.Info{
        email: "me@example.com"
      }
    }
  end
end

The Accounts context

The first step in getting this test to pass is to create the Accounts module and its register/1 function. That function will need to create an account changeset that can validate the parameters provided and attempt to insert it into the database. The changeset validation, however, can exist on the account module itself. Add this Accounts module and register/1 function:

# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
  alias Yauth.Repo
  alias __MODULE__.Account

  def register(%Ueberauth.Auth{} = params) do
    %Account{}
    |> Account.changeset(extract_account_params(params))
    |> Yauth.Repo.insert()
  end

  defp extract_account_params(%{credentials: %{other: other}, info: info}) do
    info
    |> Map.from_struct()
    |> Map.merge(other)
  end
end

This implementation depends on the existence of an Account module. As we will want to persist this data to the database, we will need both a migration to set up the data table and the actual implementation module. The easiest way to create these is to use the generator provided by Phoenix. This schema will require both an email address and an encrypted version of the user’s password. Run the following command to create those modules:

mix phx.gen.schema Accounts.Account accounts \
  email:string encrypted_password:string

In order to support the requirement that an account email be unique, we will need to add a unique index to the migration. Add the following to the end of the change/0 function in our migration:

# priv/repo/migrations/<timestamp>_create_accounts.exs
  def change do
     # ...
    create unique_index(:accounts, [:email])
  end

At this point we can migrate our database.

mix ecto.migrate

Ideally we would continue down the testing pyramid into unit tests before implementing this module but we’ll skip that for the sake of brevity and lean solely on our higher level tests. The next step, then, is to implement the changeset function that will validate the data provided and be available for interaction with the database. This changeset will do several things. It will:

  • validate that email and password have been provided
  • validate that the password and the password confirmation match
  • convert a database-level unique constraint error to a changeset error for the email address
  • transform the provided password to its encrypted form and place it on the changeset

In order to support password encryption we will want to use an encryption package. I’ve chosen to use the Argon2 package for Elixir. Add the package to our mix file and get the dependency.

  # ...
  defp deps do
    [
      # ...
      {:argon2_elixir, "~> 2.3"},
      # ...
    ]
  end
mix deps.get

With that in place we can add the following to our Account module, noting the addition of the virtual field :password. This field does not exist in our database table but is present in the Elixir schema. This allows us to receive the password from the user in plain text but only store it in the database as an encrypted string under the :encrypted_password field.

# lib/yauth/accounts/account.ex
defmodule Yauth.Accounts.Account do
  use Ecto.Schema
  import Ecto.Changeset

  schema "accounts" do
    field :email, :string
    field :password, :string, virtual: true
    field :encrypted_password, :string

    timestamps()
  end

  def changeset(struct, params) do
    struct
    |> cast(params, [:email, :password])
    |> validate_required([:email, :password])
    |> validate_confirmation(:password, required: true)
    |> unique_constraint(:email)
    |> put_encrypted_password()
  end

  defp put_encrypted_password(%{valid?: true, changes: %{password: pw}} = changeset) do
    put_change(changeset, :encrypted_password, Argon2.hash_pwd_salt(pw))
  end

  defp put_encrypted_password(changeset) do
    changeset
  end
end

This function receives an account struct and params, validates that the email and password are present, validates that the password confirmation matches the password, adds a unique constraint on email, and finally adds the encrypted password to a valid changeset using the Argon2 package to encrypt the plain text password.

Now that a changeset is available we can begin using it in our controllers and templates. To keep our controller communicating exclusively with our context, we can add a convenience function to our Accounts context that will provide that changeset.

defmodule Yauth.Accounts do
  # ...
  def change_account(account \\ %Account{}) do
    Account.changeset(account, %{})
  end
  # ...
end

Update our RegistrationController to use that changeset and provide the action to submit the form using our routing helper.

defmodule YauthWeb.RegistrationController do
  # ...
  def new(conn, _) do
    render(conn, :new,
      changeset: Accounts.change_account(),
      action: Routes.registration_path(conn, :create)
    )
  end
  # ...
end

Our template is already referencing the @changeset and @action assigns so we don’t need to make an update there.

Logging in a newly created account

At this point the RegistrationController is directing the creation of an account from an email address and password. We also need, however, to log in the account so the account owner can access their resources. This is where Guardian enters the picture.

The Guardian package uses JSON Web Tokens (JWTs) to provide the mechanism for authentication. Guardian can be configured to inspect various parts of a web request for a token and validate it. It also gives us the ability to add that token and the resource it represents to the browser session, providing us with a “logged in” experience.

To use Guardian, first add it as a dependency.

  defp deps do
    [
      # ...
      {:guardian, "~> 2.1"},
      # ...
    ]
  end
mix deps.get

Incorporating Guardian

In order to work with Guardian we’ll need to make several additions to our project. We will need to create a module that implements the Guardian behaviour and provides two key callback functions. We will need an error handling module that is called in the event a user attempts to access a protected resource without authenticating. We will build a pipeline module that will tell Guardian where to look for tokens in the web request. Lastly, we’ll need to provide some configuration values that will configure Guardian. We will walk through each of these one at a time.

The central module of our Guardian system is an implementation module for the Guardian behaviour. This module provides two key functions for interacting with our JWT. Many examples of using Guardian name the module Guardian within the application name space. I’d like, however, to give the module a more generic name of Authentication, in anticipation of giving it other authentication related functions that will place the external library behind our own API. Add the module below with these two callback functions.

# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
  @moduledoc """
  Implementation module for Guardian and functions for authentication.
  """
  use Guardian, otp_app: :yauth
  alias Yauth.{Accounts, Accounts.Account}

  def subject_for_token(resource, _claims) do
    {:ok, to_string(resource.id)}
  end

  def resource_from_claims(%{"sub" => id}) do
    case Accounts.get_account(id) do
      nil -> {:error, :resource_not_found}
      account -> {:ok, account}
    end
  end
end

This module implements the two functions needed to work with Guardian. The first is subject_for_token/2. The job of this function is to encode a resource into the token. In Yauth our resource is an Account but this could be a User, a Player, or any number of other schemas depending on our application context and naming. The point is that the schema that represents the entity being authenticated is encoded into the token so that subsequent requests can validate and load that resource from the token. In our implementation we are using the unique database id of the resource for encoding. Other attributes could be used for this purpose provided they uniquely identify the resource within our application.

The second function, resource_from_claims/1, does the opposite. It receives the decoded token as an argument and uses the subject identifier to load the resource from the database. Our subject_for_token/2 function encoded the account id, so we use that value to find the account it represents in the database and return it as part of a tuple. We will need to add the function used to load the account to our Accounts module.

# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
  # ...

  def get_account(id) do
    Repo.get(Account, id)
  end
end

Next, we’ll want to define a pipeline module that can be used by our router to tell Guardian where to look for tokens and what to do in the event it finds one.

# lib/yauth_web/authn/pipeline.ex
defmodule YauthWeb.Authentication.Pipeline do
  use Guardian.Plug.Pipeline,
    otp_app: :yauth,
    error_handler: YauthWeb.Authentication.ErrorHandler,
    module: YauthWeb.Authentication

  plug Guardian.Plug.VerifySession, claims: %{"typ" => "access"}
  plug Guardian.Plug.LoadResource, allow_blank: true
end

This pipeline receives the %Plug.Conn{} that represents the web request and passes it through a series of Guardian plugs. The first plug tells Guardian to look in the session for a token and to validate it if found. It also restricts the token type to an access token. If a token is found and validated, the LoadResource plug invokes the resource_from_claims/1 function in our implementation module to load the resource and adds it to the private section of the %Plug.Conn{}. We also pass the LoadResource plug the allow_blank: true option to allow the resource to be nil rather than raise an exception if the resource isn’t found. This allows us to deal with that situation elsewhere in the stack.

Our pipeline above references the YauthWeb.Authentication.ErrorHandler module, which we can to create now.

# lib/yauth_web/authentication/error_handler.ex
defmodule YauthWeb.Authentication.ErrorHandler do
  use YauthWeb, :controller

  @behaviour Guardian.Plug.ErrorHandler

  @impl Guardian.Plug.ErrorHandler
  def auth_error(conn, {type, _reason}, _opts) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(401, to_string(type))
  end
end

The function in this error handler module is invoked if a user tries to access a protected resource without authentication. For the time being, we’ll just have it send a 401 response. Once we’ve added the authentication and log in code, we’ll return to this and make it friendlier for browser users.

Finally, we can provide some configuration for Guardian. Add the following to our config/config.exs file:

# ...
config :yauth, YauthWeb.Authentication,
  issuer: "yauth",
  secret_key: System.get_env("GUARDIAN_SECRET_KEY")

# ...
import_config "#{Mix.env()}.exs"

Here we define the issuer of the JWT as the “yauth” application. We also provide a secret_key used for encoding the token. We can generate the secret key by running mix guardian.gen.secret. We don’t want to store this value in source control, though, so we will want to add it to our secrets management strategy and reference it from there. Here we load it from an environment variable, so be sure to export it in our shell.

export GUARDIAN_SECRET_KEY=whatever-key-was-generated

Now that we have the implementation module, pipeline, error handler, and configuration in place, we can begin using it in the rest of the application. Our first step is to add our pipeline to the Router.

defmodule YauthWeb.Router do
  # ...
  pipeline :guardian do
    plug YauthWeb.Authentication.Pipeline
  end

  # ...
  scope "/", YauthWeb do
    pipe_through [:browser, :guardian]

    get "/register", RegistrationController, :new
    post "/register", RegistrationController, :create
    get "/login", SessionController, :new
  end
  # ...
end

Here we created a router pipeline called :guardian that simply calls through to our pipeline module. We also added that to our pipe_through call for all of our registration and session routes. Remember that the pipeline is not logging in or authenticating the resource. It is simply checking for and validating the token in the session and loading the resource onto the connection in the event it is found. We’ll want this on these routes so that we can easily redirect an authenticated user who tries to access these routes to their resources without another authentication challenge.

We can now add the log in functionality to our RegistrationController. To “log in” the account we will want to add the token to the browser session that is passed back and forth between the browser and server. Rather than add that as part of the controller directly, however, we’ll add functions to our Authentication module to take care of that.

First, add the code we wish we had to the controller.

# lib/yauth_web/controllers/registration_controller.ex
defmodule YauthWeb.RegistrationController do
  # ...
  alias YauthWeb.Authentication

  def create(conn, %{"account" => account_params}) do
    case Accounts.register(account_params) do
      {:ok, account} ->
        conn
        |> Authentication.log_in(account)
        |> redirect(to: Routes.profile_path(conn, :show))

      {:error, changeset} ->
        render(conn, :new,
          changeset: changeset,
          action: Routes.registration_path(conn, :create)
        )
    end
  end
end

Now add the log_in/2 function to the Authentication module.

# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
  # ...

  def log_in(conn, account) do
    __MODULE__.Plug.sign_in(conn, account)
  end
  # ...
end

This function introduces the sign_in/1 function of Guardian. The Authentication module is our Guardian implementation module. As such it has access to the Plug.sign_in/2 function. That function does a few things relevant to our situation. First, it adds the current resource to the connection in the Guardian-configured location. Second, it adds the token to the session to indicate the account has logged in.

Protecting resources from non-authenticated users

At this point we are creating an account and logging in after so doing. However, we haven’t done anything to protect resources. Our RegistrationController redirects the user to their profile page after logging in. That page, however, should be protected from access by users who have not authenticated with the application. Let’s add that now.

Let’s first create a test from the user’s perspective. In this test the user will try to access the profile page without first logging in. We expect that the user is redirected to the log in page instead of arriving at the profile page.

# test/yauth_web/features/profile_access_test.exs
defmodule YauthWeb.ProfileAccessTest do
  use YauthWeb.FeatureCase, async: true

  test "Accessing a protected resource without logging in", %{session: session} do
    visit(session, "/profile")

    assert current_path(session) == "/login"
  end
end

This test fails when we run it, which is what we want at this point. Thinking about this feature from the high level, we want to ensure the user accessing this page has successfully authenticated with the application. If they have, we grant them access to the resource. If they haven’t authenticated with the application, we want to redirect them to the log in page so that they can authenticate.

Guardian provides a plug that helps with just that. Add the following to our Router:

# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
  # ...
  pipeline :browser_auth do
    plug Guardian.Plug.EnsureAuthenticated
  end
  # ...
  scope "/", YauthWeb do
    pipe_through [:browser, :guardian, :browser_auth]

    resources "/profile", ProfileController, only: [:show], singleton: true
  end
end

Now any requests to the /profile path will pass through the :browser_auth pipeline. This pipeline uses another Guardian-provided plug called EnsureAuthenticated. This plug ensures that the session contains a valid token. If it does not, it invokes the ErrorHandler module’s auth_error/3 function. Previously we set that function to simply render a plain text 401 page. What we want it to do at this point, however, is to redirect the unauthenticated user to the log in page so they can authenticate. Update our ErrorHandler module to the following:

# lib/yauth_web/authentication/error_handler.ex
defmodule YauthWeb.Authentication.ErrorHandler do
  # ...
  def auth_error(conn, {_type, _reason}, _opts) do
    conn
    |> put_flash(:error, "Authentication error.")
    |> redirect(to: Routes.session_path(conn, :new))
  end
end

At this point our protected resource test should pass. The log in test, however, is still failing because we haven’t added the profile code. Let’s do that now.

As with previous route additions, we’ll need to add a controller, view, and template. Add these simple views and templates to our project:

# lib/yauth_web/views/profile_view.ex
defmodule YauthWeb.ProfileView do
  use YauthWeb, :view
end
<!-- lib/yauth_web/templates/profile/show.html.eex -->
<h1>Hello, <%= @current_account.email %></h1>

Our controller, however, needs to do a bit more work. We need to get the current account that is logged in and add it as an assign on the connection so that it is available in the template. The Router is protecting access to this page, so we just need to get the account from where Guardian left it as a result of our Pipeline call. Guardian provides a function for easily accessing this called current_resource/2. To simplify our controller’s communication, however, let’s put that call behind a function in our Authentication module. Add the following controller:

# lib/yauth_web/controllers/profile_controller.ex
defmodule YauthWeb.ProfileController do
  use YauthWeb, :controller
  alias YauthWeb.Authentication

  def show(conn, _params) do
    current_account = Authentication.get_current_account(conn)
    render(conn, :show, current_account: current_account)
  end
end

Then add the function to our Authentication module.

# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
  # ...
  def get_current_account(conn) do
    __MODULE__.Plug.current_resource(conn)
  end
  # ...
end

With those changes all our tests should now be passing.

Recap

In this post we added the ability for users to register for accounts in Yauth using an email address and password. We did so by adding a POST route and adding an Accounts context that handles validating the user-submitted parameters and creating an account. We brought Guardian into the project, provided an implementation of its behaviour unique to Yauth, and used its functions to add the user to the session. We then redirected our logged in user to a resource that requires authentication to view.

In the next post we’ll add the ability for account holders to log in and log out of their existing account.