In Phoenix, you can build your authentication system by running mix phx.gen.auth command on your terminal. However, when working on a microservice architecture that has a dedicated identity/auth server, this mix task becomes limited and we have to explore other ways. JSON Web Token (JWTs) is one other way that can be used to achieve this requirement. It is an open standard protocol that defines a common way in which two parties, a client and a server can share information. Get more about JWT here
Our use Case
Let’s imagine that we are tasked with creating an Identity Server to be used in a microservice architecture. The identity server will be responsible for creating user accounts and issuing tokens to be used in other services. Users will be required to log in to get an access token. Because of security reasons, the access token will last for 15 minutes and users will be required to log in again. Since this is not a good user experience, we shall also need to generate a refresh token that the client can use to request a new access token. The refresh token will be stored in the database and can be revoked at any moment. The refresh token will last for 1 day. Refresh token should only be used to get access token and therefore, it should only be directed to the identity server. We are going to use Phoenix as our core web framework, Argon2 to hash and verify passwords, Guardian to generate and verify tokens, Guardian DB to keep track of our refresh token in the database, and PostgreSQL. If you want to skip ahead, here is the source code. I will focus more on setting Guardian and Guardian DB in Phoenix since the rest is just Phoenix stuff.
Setup Phoenix
Now, create a new project with mix phx.new identity --no-html --no-assets
. Once this is done, please cd to identity, and let’s configure our database next.
Configure PostgreSQL
Usually, I prefer to have the DB connection credentials in an environment file. Please create a .env file at the root of the project and put the following variables.
export DB_HOST=localhost
export IDENTITY_DB_NAME=identity_dev
export IDENTITY_DB_USER=identity_dev
export IDENTITY_DB_PASS=identity_dev
export DB_TEST_HOST=localhost
export IDENTITY_DB_TEST_USER=identity_test
export IDENTITY_DB_TEST_PASS=identity_test
In our config/dev.exs please set the Identity.Repo as follows
# Configure your database
config :identity, Identity.Repo,
username: System.get_env("IDENTITY_DB_USER", "postgres"),
password: System.get_env("IDENTITY_DB_PASS", "postgres"),
hostname: System.get_env("DB_HOST", "localhost"),
database: System.get_env("IDENTITY_DB_NAME", "identity_dev"),
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
in our config/test.exs set the Identity.Repo as follows
config :identity, Identity.Repo,
username: System.get_env("IDENTITY_DB_TEST_USER", "postgres"),
password: System.get_env("IDENTITY_DB_TEST_PASS", "postgres"),
hostname: System.get_env("DB_TEST_HOST", "localhost"),
database: "identity_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
Now, export your environment variables by executing source .env
and run database setup with mix ecto.create
. Once we have our database setup, lets generate user schema and migration using phoenix generator below
mix phx.gen.json Users User users email:string:unique oid:string:unique password_hash:string firstname:string lastname:string
This command will generate some files. but we are interested in the migration and the User Schema. so lets have a look at those starting with the migration. Please change your code to look like this.
defmodule Identity.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string, null: false
add :oid, :uuid, null: false
add :firstname, :string, null: false
add :lastname, :string, null: false
add :password_hash, :string, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:oid])
create unique_index(:users, [:email])
end
end
Email and oid fields are unique. we shall use oid (which is a uuid) to identify user publicly but id field will also be generated and can be used internally. Once we are done with our migration, lets us updated our user schema with the following.
defmodule Identity.Users.User do
use Ecto.Schema
import Ecto.Changeset
@mail_regex ~r/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/
schema "users" do
field :password_hash, :string
field :email, :string
field :oid, Ecto.UUID
field :firstname, :string
field :lastname, :string
field :password, :string, virtual: true
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :oid, :firstname, :lastname, :password])
|> validate_required([:email, :firstname, :lastname])
|> maybe_validate_password()
|> validate_format(:email, @mail_regex, message: "invalid email")
|> unique_constraint(:oid)
|> unique_constraint(:email)
|> put_password_hash()
|> maybe_put_oid()
|> put_downcased_email()
end
defp maybe_validate_password(changeset) do
if changeset.data.id do
changeset
else
changeset
|> validate_required([:password])
|> validate_length(:password, min: 6)
end
end
defp put_password_hash(%Ecto.Changeset{valid?: true, changes: %{password: pass}} = changeset) do
changeset |> put_change(:password_hash, Argon2.hash_pwd_salt(pass))
end
defp put_password_hash(changeset), do: changeset
defp put_downcased_email(%Ecto.Changeset{valid?: true, changes: %{email: email}} = changeset) do
changeset |> put_change(:email, email |> String.downcase())
end
defp put_downcased_email(changeset), do: changeset
defp maybe_put_oid(%Ecto.Changeset{valid?: true} = changeset) do
if changeset.data.id do
changeset
else
case get_field(changeset, :oid) do
nil -> changeset |> put_change(:oid, Ecto.UUID.generate())
_ -> changeset
end
end
end
defp maybe_put_oid(changeset), do: changeset
end
As you can see, we have some utility functions to help us update our schema based on some actions. We are generating the oid and down casing our email. We are also hashing our password using Argon2 library (please see the guide on how to install it in the official docs shared above) and updating our schema with it. Notice that we marked the password field as virtual and this is intentional but it will not be saved to the database. Phoenix also generate for us lib/users.ex module which I am not going to discuss in this article but we shall use in our authentication. Okay, let move on to the fun part of setting Guardian.
Guardian and Guardian DB
Guardian is the token authentication library that is used in Elixir. It provides an interface that you must implement for your specific need. Guardian DB is another library that depends on Guardian for token tracking. We shall focus on the access and refresh token but you are not limited to only these. Okay, let’s start with installation. in the mix.exs file add guardian and guardian db like so and execute mix deps.get
on your terminal
defp deps do
[
...
{:guardian, "~> 2.3"},
{:guardian_db, "~> 2.0"}
]
end
Once we have guardian installed, lets create and implementation module like so.
defmodule Identity.Guardian do
use Guardian, otp_app: :identity
alias Identity.Users
alias Identity.Users.User
def subject_for_token(%User{oid: id}, _claims) do
{:ok, id}
end
def subject_for_token(_, _) do
{:error, :user_id_not_provided}
end
def resource_from_claims(%{"sub" => id}) do
user = Users.get_user_by!(id, :uuid)
{:ok, user}
rescue
Ecto.NoResultsError -> {:error, :user_not_found}
end
def after_encode_and_sign(resource, claims, token, _options) do
with {:ok, _} <- Guardian.DB.after_encode_and_sign(resource, claims["typ"], claims, token) do
{:ok, token}
end
end
def on_verify(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_verify(claims, token) do
{:ok, claims}
end
end
def on_revoke(claims, token, _options) do
with {:ok, _} <- Guardian.DB.on_revoke(claims, token) do
{:ok, claims}
end
end
end
You must override subject_for_token
and resource_from_claim
callbacks. in our case, subject_for_token
function takes in the user struct and return the oid which will be the value of sub in the token claim. resource_from_claim
will take the oid of the user from the claim and return the user struct (basically the authenticated user). The rest of the functions in this module are callbacks that are invoked after some action. for example on_verify is called after token is verified. We are going to use these callbacks to store, verify and revoke our refresh token using Guardian.DB.
After we have created our implementation, its time to configure it. so in the config/config.exs add the following
# setup guardian
config :identity, Identity.Guardian,
issuer: "identity",
secret_key: System.get_env("AUTH_SECRET"),
tokens: [
access: [
ttl: {15, :minutes}
],
refresh: [
ttl: {1, :day}
]
]
# setup gurdian db
config :guardian, Guardian.DB,
# Add your repository module
repo: Identity.Repo,
# default
schema_name: "guardian_tokens",
# store all token types if not set
token_types: ["refresh"],
# default: 60 minutes
sweep_interval: 60
As you can see, we are configuring the guardian implementation that we just created. We are also adding our own configuration for the token expiry. I noticed that the default ttl key only accept one configuration for either access or refresh token but if you know of a better way, please comment below. By the way, the configuration must have the isssuer and the secret_key. Additionally, we are also configuring Guardian.DB and we must specify the Repo, the schema_name and for our case the token type that we want to store which is refresh token. The sweep_interval config will help us sweep away expired tokens in our database. now lets generate migration for the guardian_tokens table which will store our tokens. please execute mix guardian.db.gen.migration
. Make sure your migration looks like so
defmodule Identity.Repo.Migrations.CreateGuardianDBTokensTable do
use Ecto.Migration
def change do
create table(:guardian_tokens, primary_key: false) do
add(:jti, :string, primary_key: true)
add(:aud, :string, primary_key: true)
add(:typ, :string)
add(:iss, :string)
add(:sub, :string)
add(:exp, :bigint)
add(:jwt, :text)
add(:claims, :map)
timestamps()
end
end
end
Now run mix ecto.migrate
to run all our migration thus far. Once everything is setup, let’s create auth.ex file in the lib folder and add the following functions that will help us generate access and refresh token, renew access token and revoke refresh token.
defp create_access_token(%User{} = user) do
{:ok, access_token, _claim} =
Guardian.encode_and_sign(user, %{grant_type: "password", role: "individual.customer"},
token_type: :access,
ttl: get_ttl_opt(:access)
)
{:ok, access_token}
end
notice that in this function, we are adding role and grant_type as our claims that we shall use to make sure that users with role individual.customer and grant_type password can only access specific resource. we shall achieve this using controller plugs.
defp create_refresh_token(%User{} = user) do
{:ok, refresh_token, _claim} =
Guardian.encode_and_sign(user, %{}, token_type: :refresh, ttl: get_ttl_opt(:refresh))
{:ok, refresh_token}
end
def revoke_refresh_token(refresh_token) do
{:ok, _claim} = Guardian.revoke(refresh_token)
:ok
end
def renew_access(refresh_token) do
with {:ok, _old_stuff, {new_access_token, _new_claims}} <-
Guardian.exchange(refresh_token, "refresh", "access", ttl: get_ttl_opt(:access)) do
{:ok, new_access_token}
end
end
defp auth_reply(%User{} = user) do
with {:ok, access_token} <- create_access_token(user),
{:ok, refresh_token} <- create_refresh_token(user) do
{:ok, access_token, refresh_token}
end
end
defp get_ttl_opt(:access) do
:identity
|> Application.get_env(Identity.Guardian)
|> Keyword.get(:tokens)
|> Keyword.get(:access)
|> Keyword.get(:ttl)
end
defp get_ttl_opt(:refresh) do
:identity
|> Application.get_env(Identity.Guardian)
|> Keyword.get(:tokens)
|> Keyword.get(:refresh)
|> Keyword.get(:ttl)
end
Now lets create our register user function that will be used in our auth controller. Once a user is registered we need to generate access and refresh tokens that can be used to access protected routes. Add the following in the auth.ex file
def register(params) do
with {:ok, user} <- Users.create_user(params),
{:ok, access_token, refresh_token} <- auth_reply(user) do
{:ok, user, access_token, refresh_token}
end
end
Also, create another file called auth_controller.ex in the controllers directory and add the following code
def register(conn, %{"user" => user_params}) do
with {:ok, user, access_token, refresh_token} <- Auth.register(user_params) do
conn
|> put_status(:created)
|> render(:auth_user, user: user, access_token: access_token, refresh_token: refresh_token)
end
end
Using Guardian in our HTTP Requests
Once we have our controller ready, we need to setup guardian to help us check HTTP requests for the access token. first, lets create two pipelines, one that will be used for non protected routes and the other for protected routes. Please add the following in the pipeline folder under identity_web.
defmodule IdentityWeb.Pipelines.EnsureAuthPipeline do
use Guardian.Plug.Pipeline,
otp_app: :identity,
error_handler: IdentityWeb.Errors.GuardianAuthErrorHandler,
module: Identity.Guardian
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource
end
defmodule IdentityWeb.Pipelines.MaybeAuthPipeline do
use Guardian.Plug.Pipeline,
otp_app: :identity,
error_handler: IdentityWeb.Errors.GuardianAuthErrorHandler,
module: Identity.Guardian
# If there is an authorization header, restrict it to an access token and validate it
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
# Load the user if either of the verifications worked
plug Guardian.Plug.LoadResource, allow_blank: true
end
As you can see, each pipeline implements Guardian.Plug.Pipeline and it requires to be configured with the error_handler and the module. Now, let’s have a look at the error handler in the errors folder under identity_web.
defmodule IdentityWeb.Errors.GuardianAuthErrorHandler do
import Plug.Conn
@behaviour Guardian.Plug.ErrorHandler
@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{error: to_string(type)})
conn
|> put_resp_content_type("application/json")
|> send_resp(401, body)
end
end
The error handler must implement auth_error function. Here we are just making sure that we return the json encoded data. Now lets use our pipelines in the route file. add the following pipelines.
pipeline :maybe_auth do
plug IdentityWeb.Pipelines.MaybeAuthPipeline
end
pipeline :ensure_auth do
plug IdentityWeb.Pipelines.EnsureAuthPipeline
end
Now, let’s use each of this pipeline in our scopes like so
scope("/api/auth", IdentityWeb) do
pipe_through [:api, :maybe_auth]
post "/login", AuthController, :login
post "/register", AuthController, :register
post "/token/renew", AuthController, :refresh_token
post "/token/revoke", AuthController, :revoke_refresh_token
end
scope "/api", IdentityWeb do
pipe_through [:api, :maybe_auth, :ensure_auth]
get "/users/profile", UserController, :profile
put "/users/update", UserController, :update
end
The login, register, token renewal and revokation is using :mybe_auth pipeline while the user profile and user update routes are using combination of :maybe_auth and :ensure_auth pipeline. Please refer in the source code for the implementation of the rest of the routes.
Next, lets create controller plugs that will ensure our users are authenticated and have the correct privileges to access all the actions in the user_controller.ex. Since these are plugs, please create a folder under indentity_web called plugs and add the following code
defmodule IdentityWeb.CheckGrantTypePlug do
import Phoenix.Controller
import Plug.Conn
import Identity.GuardianHelpers
def init(default), do: default
def call(conn, grants) do
with {:ok, claim} <- get_current_claim(conn) do
grant = claim["grant_type"]
if(grant in grants) do
conn
else
conn
|> put_status(:forbidden)
|> put_view(json: IdentityWeb.ErrorJSON)
|> render(:"403",
error: %{
status: :forbidden,
reason: "resource forbidden due to grant policy"
}
)
|> halt()
end
end
end
end
this plug will ensure that our users have the grant type of password, otherwise it will deny access.
defmodule IdentityWeb.CheckRolesPlug do
import Phoenix.Controller
import Plug.Conn
import Identity.GuardianHelpers
def init(default), do: default
def call(conn, roles) do
with {:ok, claim} <- get_current_claim(conn) do
role = claim["role"]
if(role in roles) do
conn
else
conn
|> put_status(:forbidden)
|> put_view(json: IdentityWeb.ErrorJSON)
|> render(:"403",
error: %{
status: :forbidden,
reason: "Resource forbidden, not enough roles"
}
)
|> halt()
end
end
end
end
this plug will ensure that our users have the correct role, otherwise it will deny access. You may have noticed that we have guardian helpers function. The logic is as follows.
defmodule Identity.GuardianHelpers do
alias Identity.Guardian
def get_current_user(%Plug.Conn{} = conn) do
case Guardian.Plug.authenticated?(conn) do
true -> {:ok, Guardian.Plug.current_resource(conn)}
false -> {:error, :unauthorized}
end
end
def get_current_claim(%Plug.Conn{} = conn) do
case Guardian.Plug.authenticated?(conn) do
true -> {:ok, Guardian.Plug.current_claims(conn)}
false -> {:error, :unauthorized}
end
end
end
Now that everything is setup, let’s start our server with mix phx.server
and create our user. you can find the postman collection here.
In the source code shared above, I have also written how to test such implementation.
Conclusion
Authentication and Authorization are critical in every system. How you implement it highly depends on the specific requirement of the project. But Guardian library provides the building blocks that you need to implement such service in Elixir.