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
Plug
ging 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.