Tutorial
Combining authentication solutions with Guardian and Phx Gen Auth
This post was updated 27 Mar
Many web apps have both a web interface and a JSON API. When the normal web app has a classic session-based authentication, an API needs something like JSON Web Tokens to authenticate the requests.
In this tutorial, I will start with a web app that already has a standard web authentication solution with phx_gen_auth. Then I will make an API with protected endpoints that will require a JSON Web Token solution with Guardian.
Step 1 - The starting point
I have already generated an app with the tutorial generator that contains Phx Gen Auth. However, I want to register as a user, so I can start the webserver and go to /users/register.
And register with:
- email: andreas@example.com
- password: supersecret123
But I also want to set up some resources that should have some protected routes. I can generate products with:
mix phx.gen.pretty_html Products Product products name description:text price:float
Note that I use a custom HTML generator that comes with the tailwind option from the tutorial.
Step 2 - Add some protected routes
I want to have the products index route and show route visible to everyone as if this was a webshop. However, the rest of the CRUD actions require that a user is logged in to access.
Note that I totally disregard that as the app works now, anyone can signup.
Inside the routes file, I already have route scopes set up for private routes and public routes. Make sure that the create, update and delete are private.
# lib/my_app_web/router.ex
# Private routes
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/products", ProductController, except: [:index, :show]
# ...other routes
end
# Public routes
scope "/", MyAppWeb do
pipe_through [:browser]
# ...other routes
resources "/products", ProductController, only: [:index, :show]
get "/", PageController, :index
end
When that is done, I should be able to run the migrations without warnings:
mix ecto.migrate
Step 3 - Update Tests
If I run the tests now, I will get some failing controller tests. That is because some of the routes are private and we need to make sure we run them as an authenticated user.
Open the test file and add a setup before the first describe block:
# test/my_app_web/controllers/product_controller_test.exs
defmodule MyAppWeb.ProductControllerTest do
use MyAppWeb.ConnCase
...Test setup
# Add this
setup :register_and_login_user
describe "index" do
...Rest of the tests
end
The function register_and_login_user is added through ConnCase when phx_gen_auth was installed.
Now when I run the tests, they should all pass.
mix test
Step 4 - Install Guardian
Guardian is a token-based authentication library for use with Elixir applications. It defaults to use JSON Web Tokens but you can use other standards. You can use it for both endpoint authentication and also web sockets. The installation is pretty straightforward, and I am following the Github guide.
defp deps do
[{:guardian, "~> 2.0"}]
end
And run:
mix deps.get
Next, we need to add our own Guardian implementation module. I can use it for customization in my app but I will just go with the default:
defmodule MyApp.Guardian do
use Guardian, otp_app: :my_app
alias MyApp.Accounts
def subject_for_token(resource, _claims) do
sub = to_string(resource.id)
{:ok, sub}
end
def resource_from_claims(claims) do
id = claims["sub"]
resource = Accounts.get_user!(id)
{:ok, resource}
end
end
I need to configure my Guardian with a secret key and how long the JSON Web Token is valid.
# config/config.exs
config :my_app, MyApp.Guardian,
issuer: "my_app",
secret_key: "BY8grm00++tVtByLhHG15he/3GlUG0rOSLmP2/2cbIRwdR4xJk1RHvqGHPFuNcF8",
ttl: {3, :days}
config :my_app, MyAppWeb.AuthAccessPipeline,
module: MyApp.Guardian,
error_handler: MyAppWeb.AuthErrorHandler
Note that I am also adding configuration for authentication plugs. Let’s add those as well:
# lib/my_app_web/plugs/auth_access_pipeline.ex
defmodule MyAppWeb.AuthAccessPipeline do
use Guardian.Plug.Pipeline, otp_app: :my_app
plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource, allow_blank: true
end
I can also specify a response when a user tries to access a restricted route:
# lib/my_app_web/plugs/auth_error_handler.ex
defmodule MyAppWeb.AuthErrorHandler do
import Plug.Conn
@behaviour Guardian.Plug.ErrorHandler
@impl Guardian.Plug.ErrorHandler
def auth_error(conn, {type, _reason}, _opts) do
body = Jason.encode!(%{message: to_string(type)})
send_resp(conn, 401, body)
end
end
Next in this step is to add two changes to the routes file. First a pipeline for when we have restricted API endpoints. And then, inside the API block, I need a route to an api/sessions controller so we can authenticate and get an API token:
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
...Other code
pipeline :api_authenticated do
plug MyAppWeb.AuthAccessPipeline
end
scope "/api", MyAppWeb.Api, as: :api do
pipe_through :api
post "/sign_in", SessionController, :create
end
end
Since I added the SessionController in the routes, I might as well continue with adding it below:
# lib/my_app_web/controllers/api/session_controller.ex
defmodule MyAppWeb.Api.SessionController do
use MyAppWeb, :controller
alias MyApp.Accounts
alias MyApp.Accounts.User
alias MyApp.Guardian
def create(conn, %{"email" => nil}) do
conn
|> put_status(401)
|> json(%{status: :not_found, data: %{}, message: "User could not be authenticated"})
end
def create(conn, %{"email" => email, "password" => password}) do
case Accounts.get_user_by_email_and_password(email, password) do
%User{} = user ->
{:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, %{})
conn
|> json(%{
status: :ok,
data: %{token: jwt, email: user.email},
message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
})
nil ->
conn
|> put_status(401)
|> json(%{status: :not_found, data: %{}, message: "User could not be authenticated"})
end
end
end
And to test the signup/JSON Web Token creation I can add these tests:
# test/my_app_web/controllers/api/session_controller_test.exs
defmodule MyAppWeb.Api.SessionControllerTest do
use MyAppWeb.ConnCase, async: true
import MyApp.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "POST /api/session" do
test "with no credentials user can't login", %{conn: conn} do
conn = post(conn, ~p"/api/sign_in", email: nil, password: nil)
assert %{"message" => "User could not be authenticated"} = json_response(conn, 401)
end
test "with invalid password user cant login", %{conn: conn, user: user} do
conn =
post(conn, ~p"/api/sign_in",
email: user.email,
password: "wrongpass"
)
assert %{"message" => "User could not be authenticated"} = json_response(conn, 401)
end
test "with valid password user can login", %{conn: conn, user: user} do
conn =
post(conn, ~p"/api/sign_in",
email: user.email,
password: valid_user_password()
)
assert %{
"data" => %{"token" => "" <> _},
"message" => "You are successfully logged in" <> _
} = json_response(conn, 200)
end
end
end
And again. If I run the tests, they should all pass:
mix test
I can also test this with an API client tool:
Start with adding it to the mix file:
Step 5 - Add API endpoints for products
We have already made an HTML scaffold for products but there is yet no API. So I need to add API routes to the routes. And same as before, I want index and show to be public routes. Create, update and delete should require an authenticated user.
# lib/my_app_web/router.ex
scope "/api", MyAppWeb.Api, as: :api do
pipe_through :api
post "/sign_in", SessionController, :create # Added before
resources "/products", ProductController, only: [:index, :show]
end
## Authentication API routes
scope "/api", MyAppWeb.Api, as: :api do
pipe_through :api_authenticated
resources "/products", ProductController, except: [:index, :show]
end
The controller for the product API looks like:
defmodule MyAppWeb.Api.ProductController do
use MyAppWeb, :controller
alias MyApp.Products
alias MyApp.Products.Product
action_fallback MyAppWeb.FallbackController
def index(conn, _params) do
products = Products.list_products()
json(conn, %{data: Enum.map(products, &product_json/1)})
end
def create(conn, %{"product" => product_params}) do
with {:ok, %Product{} = product} <- Products.create_product(product_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/products/#{product}")
|> json(%{data: product_json(product)})
end
end
def show(conn, %{"id" => id}) do
product = Products.get_product!(id)
json(conn, %{data: product_json(product)})
end
def update(conn, %{"id" => id, "product" => product_params}) do
product = Products.get_product!(id)
with {:ok, %Product{} = product} <- Products.update_product(product, product_params) do
json(conn, %{data: product_json(product)})
end
end
def delete(conn, %{"id" => id}) do
product = Products.get_product!(id)
with {:ok, %Product{}} <- Products.delete_product(product) do
send_resp(conn, :no_content, "")
end
end
defp product_json(product) do
%{
id: product.id,
name: product.name,
description: product.description,
price: product.price
}
end
end
I also need a fallback controller for handling validation errors and 404s. This is a standard one generated by Phoenix:
# lib/my_app_web/controllers/fallback_controller.ex
defmodule MyAppWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use MyAppWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: MyAppWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(json: MyAppWeb.ErrorJSON)
|> render(:"404")
end
end
Here, I also need a standard changeset JSON module, that also comes with Phoenix:
# lib/my_app_web/controllers/changeset_json.ex
defmodule MyAppWeb.ChangesetJSON do
@doc """
Traverses and translates changeset errors.
See `Ecto.Changeset.traverse_errors/2` and
`MyAppWeb.CoreComponents.translate_error/1` for more details.
"""
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: translate_errors(changeset)}
end
end
This also needs to be tested. I can set up the tests like:
defmodule MyAppWeb.Api.ProductControllerTest do
use MyAppWeb.ConnCase
alias MyApp.Products
alias MyApp.Products.Product
@create_attrs %{
description: "some description",
name: "some name",
price: 120.5
}
@update_attrs %{
description: "some updated description",
name: "some updated name",
price: 456.7
}
@invalid_attrs %{description: nil, name: nil, price: nil}
def fixture(:product) do
{:ok, product} = Products.create_product(@create_attrs)
product
end
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "index" do
test "lists all products", %{conn: conn} do
conn = get(conn, ~p"/api/products")
assert json_response(conn, 200)["data"] == []
end
end
describe "create product" do
setup [:create_user_and_assign_valid_jwt]
test "renders product when data is valid", %{conn: conn} do
conn = post(conn, ~p"/api/products", product: @create_attrs)
assert %{"id" => id} = json_response(conn, 201)["data"]
conn = get(conn, ~p"/api/products/#{id}")
assert %{
"id" => ^id,
"description" => "some description",
"name" => "some name",
"price" => 120.5
} = json_response(conn, 200)["data"]
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/api/products", product: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "update product" do
setup [:create_product, :create_user_and_assign_valid_jwt]
test "renders product when data is valid", %{conn: conn, product: %Product{id: id} = product} do
conn = put(conn, ~p"/api/products/#{product}", product: @update_attrs)
assert %{"id" => ^id} = json_response(conn, 200)["data"]
conn = get(conn, ~p"/api/products/#{id}")
assert %{
"id" => ^id,
"description" => "some updated description",
"name" => "some updated name",
"price" => 456.7
} = json_response(conn, 200)["data"]
end
test "renders errors when data is invalid", %{conn: conn, product: product} do
conn = put(conn, ~p"/api/products/#{product}", product: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "delete product" do
setup [:create_product, :create_user_and_assign_valid_jwt]
test "deletes chosen product", %{conn: conn, product: product} do
conn = delete(conn, ~p"/api/products/#{product}")
assert response(conn, 204)
assert_error_sent 404, fn ->
get(conn, ~p"/api/products/#{product}")
end
end
end
defp create_product(_) do
product = fixture(:product)
%{product: product}
end
end
Note that some of the tests require authentication and have a setup block like:
setup [:create_user_and_assign_valid_jwt]
I need to add that function in conn_case so it’s globally reachable.
# test/support/conn_case.ex
@doc """
Setup helper that registers and assigns a valid jwt for users.
setup :create_user_and_assign_valid_jwt
It stores an updated connection and a registered user in the
test context.
"""
def create_user_and_assign_valid_jwt(%{conn: conn}) do
user = MyApp.AccountsFixtures.user_fixture()
{:ok, jwt, _full_claims} = MyApp.Guardian.encode_and_sign(user, %{})
conn = Plug.Conn.put_req_header(conn, "authorization", "bearer: " <> jwt)
{:ok, conn: conn, user: user}
end
Conclusion
With this tutorial, I now have a system that a signed in user can access either through the web browser or an API. And that with using the same credentials.