Tutorial
Combining authentication solutions with Guardian and Phx Gen Auth
authentication
guardian
api
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
![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1595447080/tutorials/guardian_tutorial_image_2.png)
and register with:
email: andreas@example.com
password: supersecret123
![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1595447080/tutorials/guardian_tutorial_image_3.png)
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
![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1595447079/tutorials/guardian_tutorial_image_1.png)
### 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:
![](https://res.cloudinary.com/dwvh1fhcg/image/upload/v1595448835/tutorials/guardian_tutorial_image_4.png)
### 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.