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

Lance Johnson

In previous posts we built a basic Phoenix application with pages for authentication and then added the ability for users to register for an account using their email address and password and be logged in after so doing. In this post we will expand on that work by adding the ability for existing account holders to log in and log out.

Test driving log in and log out from the outside-in

As with our previous posts, we’ll begin the work for this feature by creating a feature test that uses a browser to emulate the way an end user will interact with this feature. We’ll want these tests to cover at least the following requirements:

  • When a user provides their email address and a correct password, the user is logged in and sees their profile page.
  • When a user provides their email address and an incorrect password, the user is not logged in and sees a message that they need to try again.
  • When a user provides an unknown email address and a password, the user is not logged in and sees a message that they need to try again.
  • When a user who has already logged in visits the log in page, the user is redirected to their profile page.

Add the following feature tests to your project to cover those requirements:

# test/yauth_web/features/account_sessions_test.exs
defmodule YauthWeb.AccountSessionsTest do
  use YauthWeb.FeatureCase

  import Wallaby.Query, only: [fillable_field: 1, button: 1, link: 1]
  alias Yauth.Accounts.Account
  alias Yauth.Repo

  @email_field fillable_field("account[email]")
  @password_field fillable_field("account[password]")
  @submit_button button("Log In")
  @log_out_link link("Log Out")

  setup do
    account =
      %Account{}
      |> Account.changeset(%{
        "email" => "me@example.com",
        "password" => "superdupersecret",
        "password_confirmation" => "superdupersecret"
      })
      |> Repo.insert!()

    {:ok, account: account}
  end

  test "Log in and out of an existing account", %{session: session, account: account} do
    session
    |> visit("/login")
    |> fill_in(@email_field, with: account.email)
    |> fill_in(@password_field, with: "superdupersecret")
    |> click(@submit_button)

    assert_text(session, "Hello, #{account.email}")

    click(session, @log_out_link)

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

  test "Log in with the wrong email and password", %{session: session, account: account} do
    session
    |> visit("/login")
    |> fill_in(@email_field, with: account.email)
    |> fill_in(@password_field, with: "thisisnotmypassword")
    |> click(@submit_button)

    assert_text(session, "Incorrect email or password")

    session
    |> visit("/login")
    |> fill_in(@email_field, with: "notmyemail@example.com")
    |> fill_in(@password_field, with: "superdupersecret")
    |> click(@submit_button)

    assert_text(session, "Incorrect email or password")
  end

  test "Visit log in when already logged in", %{session: session, account: account} do
    session
    |> visit("/login")
    |> fill_in(@email_field, with: account.email)
    |> fill_in(@password_field, with: "superdupersecret")
    |> click(@submit_button)

    visit(session, "/login")

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

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

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

Logging in

As with our previous feature test for registering for an account, if we run through the first test through a browser, we’ll discover a 404 page. We are missing our POST route to handle the submission of the log in form. Let’s add that route to our Router.

# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
  # ...
  scope "/", YauthWeb do
    # ...
    post "/login", SessionController, :create
    # ...
  end
  # ...
end

At this point our SessionController doesn’t have a create/2 function. That function will receive the email address and plain text password provided by the user nested under the key “account”. As with our RegistrationController we can pattern match on that key to extract the data with which this function is concerned.

# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
  # ...
  def create(conn, %{"account" => %{"email" => email, "password" => password}}) do
    # ...
  end
end

Now that we have that data bound to variables, we need to first get the account holder from the database using that email and then ensure that the password they provided matches the encrypted password we stored previously for that account. As with our previous controller implementations, however, we want to move the bulk of that work out of the controller layer and into our “contexts” layer. Let’s write the code we wish we had and then fill in the details.

# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
  # ...
  alias Yauth.Accounts
  alias YauthWeb.Authentication

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

      {:error, :invalid_credentials} ->
        conn
        |> put_flash(:error, "Incorrect email or password")
        |> new(%{})
    end
  end
end

As you can see, we pass the email data to an Accounts.get_by_email/1 function and pass the result of that to an Authentication.authenticate/2 function. We expect that function to return either the account in an :ok tuple or an error in an :error tuple.

We need to define those functions. Let’s add the get_by_email/1 function to our Accounts context. As you may recall, we added a database index for this field so we should have solid performance for this query.

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

  def get_by_email(email) do
    Repo.get_by(Account, email: email)
  end
end

This function will return either the account or nil so we’ll need to handle both in our Authentication.authenticate/2 function. Let’s define that now.

# lib/yauth_web/authentication.ex
defmodule YauthWeb.Authentication do
  # ...
  def authenticate(%Account{} = account, password) do
    authenticate(
      account,
      password,
      Argon2.verify_pass(password, account.encrypted_password)
    )
  end

  def authenticate(nil, password) do
    authenticate(nil, password, Argon2.no_user_verify())
  end

  defp authenticate(account, _password, true) do
    {:ok, account}
  end

  defp authenticate(_account, _password, false) do
    {:error, :invalid_credentials}
  end
  # ...
end

There is a fair amount going on in these functions so let’s walk through them from top to bottom.

def authenticate(%Account{} = account, password) do
  authenticate(
    account,
    password,
    Argon2.verify_pass(password, account.encrypted_password)
  )
end

In the first case, if we receive an %Account{} struct and the password, we call through to a 3-arity function with the account, password, and the result of calling Argon2.verify_pass/2. That function is used to compare a plain text password with an encrypted password.

def authenticate(nil, password) do
  authenticate(nil, password, Argon2.no_user_verify())
end

In the second function head, if we receive nil and the plain text password, we call through to our 3-arity function with the result of calling Argon2.no_user_verify(). This latter function always returns false and is called in order to prevent certain timing attacks.

defp authenticate(account, _password, true) do
  {:ok, account}
end

defp authenticate(_account, _password, false) do
  {:error, :invalid_credentials}
end

Finally, our 3-arity function returns {:ok, account} if the password check was successful and {:error, :invalid_credentials} otherwise.

For all of this to work, however, we’ll want to make some updates to our SessionController.new/2 function. Up to now we’ve been using the conn as our changeset and passing a hard coded string for the action. We can now update that to use both an account changeset and the route helper for the action.

# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
  # ...
  def new(conn, _params) do
    render(
      conn,
      :new,
      changeset: Accounts.change_account(),
      action: Routes.session_path(conn, :create)
    )
  end
  # ...
end

At this point our tests for incorrect email addresses and passwords are passing but we still have work to do for logging out and for redirecting an already logged in user. Let’s handle logging out first.

Logging out

In order to log out, we’ll want to make a DELETE request that can be handled by our SessionController. First, we’ll need to add a route for that request.

# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
  # ...
  scope "/", YauthWeb do
    pipe_through [:browser, :guardian, :browser_auth]
    # ...
    delete "/logout", SessionController, :delete
  end
  # ...
end

Next, we’ll need to add that function to the SessionController. Similar to our other controller functions, we’ll rely on our Authentication context for handling the log out and then we’ll send the user back to the log in page.

# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
  # ...
  def delete(conn, _params) do
    conn
    |> Authentication.log_out()
    |> redirect(to: Routes.session_path(conn, :new))
  end
end

We need to add the log_out/1 function to our Authentication module.

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

As with our log_in/2 function, this function calls out to a Guardian-provided function. Recall that our Authentication module is our implementation module for the Guardian behaviour and, as such, has access to the Guardian-provided Plug functions. That function handles clearing the account’s data from the session.

To complete the log out functionality, we need to provide a link for our user so they can initiate this request. First, let’s replace the <header> of our application layout template with one that contains a menu where we can put this link. Note, in particular, the method: :delete option that we pass to the link function.

<!-- lib/yauth_web/templates/layout/app.html.eex -->
<!-- ... -->
<header>
  <section class="container">
    <%= if @conn.assigns[:current_account] do %>
      <nav class="navigation" role="navigation">
        <section class="container">
          <a href="#" class="navigation-title" title="Yauth">Yauth</a>
          <ul class="navigation-list float-right">
            <li class="navigation-item">
              <%= @current_account.email %>
            </li>
            <li class="navigation-item">
              <%= link(
                "Log Out",
                to: Routes.session_path(@conn, :delete),
                method: :delete
              ) %>
            </li>
          </ul>
        </section>
      </nav>
    <% end %>
  </section>
</header>
<!-- ... -->

We’ll also need to add some CSS to style this menu. Add the following to our main CSS file:

# assets/css/app.css
# ...
.navigation {
  background: #f4f5f6;
  border-bottom: .1rem solid #d1d1d1;
  display: block;
  height: 5.2rem;
  left: 0;
  max-width: 100%;
  position: fixed;
  right: 0;
  top: 0;
  width: 100%;
  z-index: 1;
}

.navigation .navigation-list,
.navigation .navigation-title {
  display: inline;
  font-size: 1.6rem;
  line-height: 5.2rem;
  padding: 0;
  text-decoration: none;
}

.navigation .navigation-list {
  list-style: none;
  margin-bottom: 0;
}

.navigation .navigation-item {
  float: left;
  margin-bottom: 0;
  margin-left: 2.5rem;
  position: relative;
}

main.container {
  padding-top: 7rem;
}

Redirecting logged in users

At this point all of our tests should be passing with the exception of redirecting an already logged in account. In the event a user who has already logged in visits the “/login” route or the “/register” route, our controllers will send that user to their profile page instead. To accomplish this the controllers need to see if a user has already logged in. Update our new/2 function to the following:

# lib/yauth_web/controllers/session_controller.ex
defmodule YauthWeb.SessionController do
  # ...
  def new(conn, _params) do
    if Authentication.get_current_account(conn) do
      redirect(conn, to: Routes.profile_path(conn, :show))
    else
      render(
        conn,
        :new,
        changeset: Accounts.change_account(),
        action: Routes.session_path(conn, :create)
      )
    end
  end
  # ...
end

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

  def new(conn, _) do
    if Authentication.get_current_account(conn) do
      redirect(conn, to: Routes.profile_path(conn, :show))
    else
      render(
        conn,
        :new,
        changeset: Accounts.change_account(),
        action: Routes.registration_path(conn, :create)
      )
    end
  end
  # ...
end

Our Authentication module already has the get_current_account/1 function as we added it in a previous post for getting the resource for our profile controller. Here we use that function knowing that our Pipeline plug only loaded the resource if the session has a valid token. The Guardian-provided function current_resource either returns the account it loaded after validating the session token or it returns nil. Our SessionController uses a simple conditional to check for the presence of that account and redirects to the profile page in the event it exists. Otherwise it creates the changeset and renders the new session page.

At this point all of our tests should be passing and we’ve completed the feature of allowing users to log in and out of an existing account!

Recap

In this post we added the ability for an account holder to log in and log out of their accounts. We did so by adding to our Authentication module the ability to check a plain text password against an encrypted password and adding account tokens to the session in the event emails and passwords matched previously stored values. We added to our session controller logic to use the Authentication module to determine what to show users. We also added the ability to log out by leveraging the functions Guardian provides to remove tokens from the session.

In our next post we will add the ability for users to register for accounts using their Google or Twitter accounts instead of providing an email address and password.