Psst. It would be super cool if you could try the new Phoenix Boilerplate!

Try now

Tutorial

View on Github

Combining authentication solutions with Guardian and Phx Gen Auth

authenticationguardianapi

Many web apps have both a web interface and an Json api. When the normal web app has a classic session based authentication, an API need something like Json Web Tokens to use for authenticate the requests.

In those cases you want to make sure that the same user name and password works both solutions so your users don’t remember what credentials to use when.

In this tutorial I will start with a web app that already have 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 boilerplate 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 setup 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 boilerplate.

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 requires that a user is logged in to reach.

Note that i totally disregard that as the app works now, anyone can signup.

Inside the routes file, I already have route scopes setup for private routes and public routes. Make sure that the create and update and delete are private.

# Private routes
scope "/", TutorialWeb do
  pipe_through [:browser, :require_authenticated_user]

  resources "/products", ProductController, except: [:index, :show]

  ...other routes
end

# Public routes
scope "/", TutorialWeb 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/tutorial_web/controllers/product_controller_test.exs
defmodule GraphqlTutorialWeb.ProductControllerTest do
  use GraphqlTutorialWeb.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 user other standards. You can use it for both endpoint authentication but also web sockets. The installation is pretty straight forward, and I am following the Github guide.

tart with adding it to the mix file:

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 Tutorial.Guardian do
   use Guardian, otp_app: :tutorial

   alias Tutorial.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 my a secret key and how long the json web token is valid.

#config/config.exs
config :tutorial, Tutorial.Guardian,
    issuer: "tutorial",
    secret_key: "BY8grm00++tVtByLhHG15he/3GlUG0rOSLmP2/2cbIRwdR4xJk1RHvqGHPFuNcF8",
    ttl: {3, :days}

 config :tutorial, TutorialWeb.AuthAccessPipeline,
    module: Tutorial.Guardian,
    error_handler: TutorialWeb.AuthErrorHandler

Note that I am also adding configuration for authentication plugs. Lets add those as well:

# lib/tutorial_web/plugs/auth_access_pipeline.ex
defmodule TutorialWeb.AuthAccessPipeline do
  use Guardian.Plug.Pipeline, otp_app: :tutorial

  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/tutorial_web/plugs/auth_error_handler.ex
defmodule TutorialWeb.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/tutorial_web/router.ex
defmodule TutorialWeb.Router do
  use TutorialWeb, :router
  
  ...Other code

  pipeline :api_authenticated do
    plug TutorialWeb.AuthAccessPipeline
  end

  scope "/api", TutorialWeb.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/tutorial_web/controllers/api/session_controller.ex
defmodule TutorialWeb.Api.SessionController do
  use TutorialWeb, :controller

  alias Tutorial.Accounts
  alias Tutorial.Accounts.User
  alias Tutorial.Guardian

  def create(conn, %{"email" => nil}) do
    conn
    |> put_status(401)
    |> render("error.json", 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
        |> render("create.json", user: user, jwt: jwt)

      nil ->
        conn
        |> put_status(401)
        |> render("error.json", message: "User could not be authenticated")
    end
  end
end

The controller also need a view that returns the json response:

# lib/tutorial_web/views/api/session_view.ex
defmodule TutorialWeb.Api.SessionView do
  use TutorialWeb, :view

  def render("create.json", %{user: user, jwt: jwt}) do
    %{
      status: :ok,
      data: %{
        token: jwt,
        email: user.email
      },
      message: "You are successfully logged in! Add this token to authorization header to make authorized requests."
    }
  end

  def render("error.json", %{message: message}) do
    %{
      status: :not_found,
      data: %{},
      message: message
    }
  end
end

And to test the signup/json web token creation I can add these tests:

# test/tutorial_web/controllers/api/session_controller_test.exs
defmodule TutorialWeb.Api.SessionControllerTest do
  use TutorialWeb.ConnCase, async: true

  import Tutorial.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, Routes.api_session_path(conn, :create), 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, Routes.api_session_path(conn, :create),
          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, Routes.api_session_path(conn, :create),
          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 test, they should all pass:

mix test

I can also test this with an api client tool:

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/tutorial_web/router.ex
scope "/api", TutorialWeb.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", GraphqlTutorialWeb.Api, as: :api do
  pipe_through :api_authenticated

  resources "/products", ProductController, except: [:index, :show]
end

The controller for the product api looks like:

defmodule TutorialWeb.Api.ProductController do
  use TutorialWeb, :controller

  alias Tutorial.Products
  alias Tutorial.Products.Product

  action_fallback TutorialWeb.FallbackController

  def index(conn, _params) do
    products = Products.list_products()
    render(conn, "index.json", products: products)
  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", Routes.api_product_path(conn, :show, product))
      |> render("show.json", product: product)
    end
  end

  def show(conn, %{"id" => id}) do
    product = Products.get_product!(id)
    render(conn, "show.json", product: 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
      render(conn, "show.json", product: 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
end

I also need a product view for displaying the product JSON

# lib/tutorial_web/views/api/product_view.ex
defmodule TutorialWeb.Api.ProductView do
  use TutorialWeb, :view
  alias TutorialWeb.Api.ProductView

  def render("index.json", %{products: products}) do
    %{data: render_many(products, ProductView, "product.json")}
  end

  def render("show.json", %{product: product}) do
    %{data: render_one(product, ProductView, "product.json")}
  end

  def render("product.json", %{product: 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 404:s. This is a standard one generated by Phoenix:

# lib/tutorial_web/controllers/fallback_controller.ex
defmodule TutorialWeb.FallbackController do
  @moduledoc """
   Translates controller action results into valid `Plug.Conn` responses.

   See `Phoenix.Controller.action_fallback/1` for more details.
  """
  use TutorialWeb, :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(TutorialWeb.ChangesetView)
    |> render("error.json", 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(TutorialWeb.ErrorView)
    |> render(:"404")
  end
end

Here, I also need a standard changeset view, that also comes with Phoenix:

# lib/tutorial_web/views/changeset_view.ex
defmodule TutorialWeb.ChangesetView do
  use TutorialWeb, :view

  @doc """
   Traverses and translates changeset errors.

   See `Ecto.Changeset.traverse_errors/2` and
   `TutorialWeb.ErrorHelpers.translate_error/1` for more details.
  """
  def translate_errors(changeset) do
    Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
  end

  def render("error.json", %{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 TutorialWeb.Api.ProductControllerTest do
  use TutorialWeb.ConnCase

  alias Tutorial.Products
  alias Tutorial.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, Routes.api_product_path(conn, :index))
      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, Routes.api_product_path(conn, :create), product: @create_attrs)
      assert %{"id" => id} = json_response(conn, 201)["data"]

      conn = get(conn, Routes.api_product_path(conn, :show, 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, Routes.api_product_path(conn, :create), 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, Routes.api_product_path(conn, :update, product), product: @update_attrs)
      assert %{"id" => ^id} = json_response(conn, 200)["data"]

      conn = get(conn, Routes.api_product_path(conn, :show, 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, Routes.api_product_path(conn, :update, 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, Routes.api_product_path(conn, :delete, product))
      assert response(conn, 204)

      assert_error_sent 404, fn ->
        get(conn, Routes.api_product_path(conn, :show, product))
      end
    end
  end

  defp create_product(_) do
    product = fixture(:product)
    %{product: product}
  end
end

Note That some of the tests requires authenification and have a setup block like:

setup [:create_user_and_assign_valid_jwt]

I need to add that function in conn_case so its 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 = Tutorial.AccountsFixtures.user_fixture()
 {:ok, jwt, _full_claims} = Tutorial.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.


What are you working on?

If you want, you can send me a link to your Phoenix or Phoenix LiveView project so. Lets connect on Twitter or Linkedin.

- Andreas Eriksson, web developer since 2005

Related Tutorials

Published 16 Mar

Multi-tenancy and Phoenix - Part 2

powauthenticationmultitenancyphoenix

In the previous tutorial, I wrote how to set up multi-tenancy with Phoenix and Pow. In this tutorial, I will show how I scope resources the current ..

Published 30 Jul

Getting started with GraphQL and Absinthe in Phoenix

phoenixapiabsinthegraphql

In the last tutorial, there I had an app with a simple rest api that was authenticated with Guardian and Json Web Token. In this tutorial, I will go..