#1 Handle authentication with Phoenix framework

First article of a series about Phoenix and Elixir. This one will be focused on authentication for both HTML and API usage.

#1 Handle authentication with Phoenix framework
First article of a series about Phoenix and Elixir. This one will be focused on authentication for both HTML and API usage.


Discover the Phoenix Series
#1・Handle HTML & API authentication
#2・Automatic deployment
#3・Pimp my Phoenix
#4・Discord Bot & Stackable Queries

A fresh start

Creating a new web application often means that you will need an authentication logic to handle users and their resources. I offer you today to give a look at Elixir, Phoenix framework and phx_auth_gen package to do so.

I will assume that you already know the basics (at least the setup!) of language Elixir and Phoenix framework. If not, I suggest you read the official guide for both of them:

The idea is to create a web application, from scratch, to show you how to handle user logic in both HTML and API ways. And way more! Because as the title of today's article suggests, this is the first one of a series... But for now, let's focus on authentication.

Let's create our project

Good doggo of science is here to help.

First, be sure that Elixir and Phoenix are installed on your computer. If so, we will create our project by using:

 mix phx.new tasty_recipes --binary-id --live

Oh, I forgot to tell you that we will build a website for people who want to share their cooking recipes! Yummy.

The --binary-id is used to tell Phoenix that for our models, we don't want simple integers ID but UUIDs instead. Also, --live means that Phoenix LiveView will be installed and configured in our project. I really advise you to do it now because it can be a little bit tricky to do it later!

Once Phoenix is done generating all our source code, say yes when asked to fetch and install dependencies. I had some trouble installing nodes modules because of my Node's version. The best way to get everything to work quickly is to be sure that you use Node LTS 14.17.5 (nvm can be helpful here!).

You should now see something like this :

➜ mix phx.new tasty_recipes --binary-id --live
* creating tasty_recipes/config/config.exs
* creating tasty_recipes/config/dev.exs
...
* creating tasty_recipes/assets/static/images/phoenix.png
* creating tasty_recipes/assets/static/robots.txt

Fetch and install dependencies? [Yn] Y
* running mix deps.get
* running cd assets && npm install && node node_modules/webpack/bin/webpack.js --mode development
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd tasty_recipes

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

Setup database

Now that our project is created, before going further, we need to set up our database. Let's say we are using PostgreSQL.

➜ psql

postgres=# CREATE USER tasty WITH ENCRYPTED PASSWORD 'tasty!';
CREATE ROLE
postgres=# CREATE DATABASE tasty_recipes_dev;
CREATE DATABASE
postgres=# GRANT ALL PRIVILEGES ON DATABASE tasty_recipes_dev TO tasty;
GRANT

Nice, our database is created and we also have a user to interact with it.

We should now update the settings config/dev.exs of our project to use the correct database and user that we just created :

use Mix.Config

# Configure your database
config :tasty_recipes, TastyRecipes.Repo,
  username: "tasty",
  password: "tasty!",
  database: "tasty_recipes_dev",
  hostname: "localhost",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10

...
config/dev.exs

As the name of the file config/dev.exs suggests, this is the file where we configure our staging / development environment. There are two other files called prod.exs and prod.secrets.exs to handle everything related to production. We will discuss it in another article!

Run server, run!

We are so proud of you, little server.

Now that our database is set up and configured, let's run our server :

➜  mix phx.server
Compiling 13 files (.ex)
Generated tasty_recipes app
[info] Running TastyRecipesWeb.Endpoint with cowboy 2.9.0 at 0.0.0.0:4000 (http)
[info] Access TastyRecipesWeb.Endpoint at http://localhost:4000

webpack is watching the files…

[hardsource:110f7d29] Using 1 MB of disk space.
[hardsource:110f7d29] Writing new cache 110f7d29...
[hardsource:110f7d29] Tracking node dependencies with: package-lock.json.
Hash: 89d1b8f2fdca3182fe0c
Version: webpack 4.46.0
Time: 1215ms
Built at: 08/29/2021 11:05:32 AM
                Asset       Size  Chunks             Chunk Names
       ../css/app.css   11.5 KiB     app  [emitted]  app
       ../favicon.ico   1.23 KiB          [emitted]
../images/phoenix.png   13.6 KiB          [emitted]
        ../robots.txt  202 bytes          [emitted]
               app.js    353 KiB     app  [emitted]  app
Entrypoint app = ../css/app.css app.js
[0] multi ./js/app.js 28 bytes {app} [built]
[../deps/phoenix/priv/static/phoenix.js] 39.3 KiB {app} [built]
[../deps/phoenix_html/priv/static/phoenix_html.js] 2.21 KiB {app} [built]
[../deps/phoenix_live_view/priv/static/phoenix_live_view.js] 113 KiB {app} [built]
[./css/app.css] 39 bytes {app} [built]
[./js/app.js] 1.43 KiB {app} [built]
    + 3 hidden modules
Child mini-css-extract-plugin node_modules/css-loader/dist/cjs.js!node_modules/sass-loader/dist/cjs.js!css/app.css:
    Entrypoint mini-css-extract-plugin = *
    [./node_modules/css-loader/dist/cjs.js!./css/phoenix.css] 10.4 KiB {mini-css-extract-plugin} [built]
    [./node_modules/css-loader/dist/cjs.js!./node_modules/sass-loader/dist/cjs.js!./css/app.css] 1.88 KiB {mini-css-extract-plugin} [built]
        + 1 hidden module

You can go to http://localhost:4000/ and see the magic, our website is running!

Create the models

No, we are not!

Okay, cool, the server is running, the website is working but what now? It's time to create the models that will handle our users and the TastyRecipes™! As mentioned before and because we are not idiot sandwiches, we will use phx_auth_gen to take care of it.

First, add it to the list of dependencies in mix.exs :

def deps do
  [
    {:phx_gen_auth, "~> 0.7", only: [:dev], runtime: false},
    ...
  ]
end
mix.exs

Then, install and compile it :

➜ mix do deps.get, deps.compile

Once it is done, let's generate our users' model :

➜ mix phx.gen.auth Accounts User users --binary-id --web html

Planty of fancy things happens here, models are created, templates are updated, new routes are added, etc.

--web html is used to specify to Phoenix that we want to customize the namespace of our users' module. I like to do it this way to keep the code more readable.

Run the migrations :

➜ mix ecto.migrate

...

12:57:53.846 [info]  == Running 20210829103031 TastyRecipes.Repo.Migrations.CreateUsersAuthTables.change/0 forward

12:57:53.848 [info]  execute "CREATE EXTENSION IF NOT EXISTS citext"

12:57:53.878 [info]  create table users

12:57:53.892 [info]  create index users_email_index

12:57:53.897 [info]  create table users_tokens

12:57:53.907 [info]  create index users_tokens_user_id_index

12:57:53.912 [info]  create index users_tokens_context_token_index

12:57:53.917 [info]  == Migrated 20210829103031 in 0.0s

If you start again your server, you should see some new links on the index page, Register and Log in. You can navigate through these links and see that all basic features for users are available:

  • Sign up
  • Email sign up validation
  • Sign in
  • Forgot password
  • Update account settings
  • Logout

Since we don't have set up anything to handle emails, if some of them need to be sent, they will simply be printed in the shell where the server is running. Try register and you should see something like this :

...

[debug] QUERY OK db=5.6ms queue=0.6ms idle=1260.1ms
INSERT INTO "users" ("email","hashed_password","inserted_at","updated_at","id") VALUES ($1,$2,$3,$4,$5) ["[email protected]", "$2b$12$/0ztrsXCu8N1dcDft75r9uHD8jTOY1c6E24wLbRZ6bclY4Ks4KJAS", ~N[2021-08-29 11:12:38], ~N[2021-08-29 11:12:38], <<77, 61, 82, 203, 49, 28, 74, 142, 145, 218, 169, 165, 49, 62, 65, 172>>]
[debug] QUERY OK db=3.3ms queue=0.5ms idle=1269.3ms
INSERT INTO "users_tokens" ("context","sent_to","token","user_id","inserted_at","id") VALUES ($1,$2,$3,$4,$5,$6) ["confirm", "[email protected]", <<127, 77, 176, 118, 30, 197, 71, 251, 114, 184, 131, 179, 74, 23, 85, 205, 64, 26, 152, 114, 145, 214, 69, 6, 222, 64, 198, 242, 148, 24, 149, 186>>, <<77, 61, 82, 203, 49, 28, 74, 142, 145, 218, 169, 165, 49, 62, 65, 172>>, ~N[2021-08-29 11:12:38], <<125, 117, 18, 243, 41, 87, 64, 49, 130, 188, 105, 73, 0, 102, 177, 184>>]
[debug]
==============================

Hi [email protected],

You can confirm your account by visiting the URL below:

http://localhost:4000/html/users/confirm/pPktLpywaTh5TnFewBvGhaj1OhhY_LGlie9ebEXpMdw

If you didn't create an account with us, please ignore this.

==============================

...
mix phx.server shell output

Fine, after a few commands, we have a fully working user logic. Awesome!

Yummy! Yummy!

Let's create and migrate our recipes :

➜ mix phx.gen.live Recipes Recipe recipes name description:text owner:references:users --web html

➜ mix ecto.migrate

For the sake of keeping the code and this article simple, we will just say that our recipe consists in having a name, a description and a user that owns the recipe.

Add the new routes to your router :

...

    scope "/html/recipes", TastyRecipesWeb.Html, as: :html do
      pipe_through [:browser, :require_authenticated_user]

      live "/", RecipeLive.Index, :index
      live "/new", RecipeLive.Index, :new
      live "/:id/edit", RecipeLive.Index, :edit

      live "/:id", RecipeLive.Show, :show
      live "/:id/show/edit", RecipeLive.Show, :edit
    end

...
lib/tasty_recipes_web/router.ex
Note that we added require_authenticated_user to scope's pipe_through so only authenticated users can access these routes.

If you visit http://localhost:4000/html/recipes you should be able to list, show, create, update and delete recipes. That's nice but we have a problem, owner field is not populated. To overcome this, you need to update the Recipe changeset and add owner field to the cast method:

  @doc false
  def changeset(recipe, attrs) do
    recipe
    |> cast(attrs, [:name, :description, :owner])
    |> validate_required([:name, :description])
  end
tasty_recipes/lib/tasty_recipes/recipes/recipe.ex

Assign the currently logged user to the live view socket in mount so we will be able to use it later:

  def mount(_params, session, socket) do
    assigns = [
      conn: socket,
      recipes: list_recipes(),
      current_user: Accounts.get_user_by_session_token(session["user_token"])
    ]

    {:ok, assign(socket, assigns)}
  end
lib/tasty_recipes_web/live/html/recipe_live/index.ex

Pass the currently logged user to the modal where the recipe form is located:

<%= if @live_action in [:new, :edit] do %>
  <%= live_modal @socket, TastyRecipesWeb.Html.RecipeLive.FormComponent,
    id: @recipe.id || :new,
    title: @page_title,
    action: @live_action,
    recipe: @recipe,
    current_user: @current_user,
    return_to: Routes.html_recipe_index_path(@socket, :index) %>
<% end %>
lib/tasty_recipes_web/live/html/recipe_live/index.html.leex

And update the save_recipe method with action :new

  defp save_recipe(socket, :new, recipe_params) do
    case Recipes.create_recipe(Map.put(recipe_params, "owner", socket.assigns.current_user.id)) do
      {:ok, _recipe} ->
        {:noreply,
         socket
         |> put_flash(:info, "Recipe created successfully")
         |> push_redirect(to: socket.assigns.return_to)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, changeset: changeset)}
    end
  end
lib/tasty_recipes_web/live/html/recipe_live/form_component.ex
As you can see in the form_component.ex file, there is two methods called save_recipe with the same number of arguments. What kind of sorcery is this?! It's called pattern matching and if you are not in touch with that, I really advise you to check this awesome article written by Mathieu!
It really is.

If we save a new recipe again, owner field should now be populated with the user that created it, nice!

You can check this by updating a little bit the index template:

<h1>Listing Recipes</h1>

<%= if @live_action in [:new, :edit] do %>
  <%= live_modal @socket, TastyRecipesWeb.Html.RecipeLive.FormComponent,
    id: @recipe.id || :new,
    title: @page_title,
    action: @live_action,
    recipe: @recipe,
    current_user: @current_user,
    return_to: Routes.html_recipe_index_path(@socket, :index) %>
<% end %>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Description</th>
      <th>User</th>

      <th></th>
    </tr>
  </thead>
  <tbody id="recipes">
    <%= for recipe <- @recipes do %>
      <tr id="recipe-<%= recipe.id %>">
        <td><%= recipe.name %></td>
        <td><%= recipe.description %></td>
        <td><%= recipe.owner %></td>

        <td>
          <span><%= live_redirect "Show", to: Routes.html_recipe_show_path(@socket, :show, recipe) %></span>
          <span><%= live_patch "Edit", to: Routes.html_recipe_index_path(@socket, :edit, recipe) %></span>
          <span><%= link "Delete", to: "#", phx_click: "delete", phx_value_id: recipe.id, data: [confirm: "Are you sure?"] %></span>
        </td>
      </tr>
    <% end %>
  </tbody>
</table>

<span><%= live_patch "New Recipe", to: Routes.html_recipe_index_path(@socket, :new) %></span>
lib/tasty_recipes_web/live/html/recipe_live/index.html.leex

And voilà! That's it for the HTML part. We still miss a lot of things for the website to be usable (owner check, design, etc.) but we will see that in the next article, let's focus on API for now!

Oh mighty API, show me your recipes...

For this part, we will use Guardian which is a token-based authentication library for Elixir applications.

First, add it to the list of dependencies :

def deps do
  [
    {:guardian, "~> 2.0"},
    ...
  ]
end
mix.exs

Then, install and compile it :

➜ mix do deps.get, deps.compile

Following the Guardian documentation, we have to :

[...] create an "implementation module" which includes Guardian's functionality and the code for encoding and decoding our token's values. To do this, create a module that uses Guardian and implements the subject_for_token/2 and resource_from_claims/1 function :
defmodule TastyRecipes.Guardian do
  use Guardian, otp_app: :tasty_recipes

  def subject_for_token(%{id: id}, _claims) do
    # You can use any value for the subject of your token but
    # it should be useful in retrieving the resource later, see
    # how it being used on `resource_from_claims/1` function.
    # A unique `id` is a good subject, a non-unique email address
    # is a poor subject.
    sub = to_string(id)
    {:ok, sub}
  end
  def subject_for_token(_, _) do
    {:error, :reason_for_error}
  end

  def resource_from_claims(%{"sub" => id}) do
    # Here we'll look up our resource from the claims, the subject can be
    # found in the `"sub"` key. In above `subject_for_token/2` we returned
    # the resource id so here we'll rely on that to look it up.
    resource = TastyRecipes.Accounts.get_user!(id)
    {:ok,  resource}
  end
  def resource_from_claims(_claims) do
    {:error, :reason_for_error}
  end
end
lib/tasty_recipes_web/guardian.ex

And update the configuration :

...

config :tasty_recipes, TastyRecipes.Guardian,
  secret_key: "QEijcHVZw9X8lgP/9TJROU+MVyvUMLv/mUY2+Kx7XwXfXeVACQsX4vTfaiLapTLv",
  issuer: "tasty_recipes",
  ttl: {7, :days}

config :tasty_recipes, TastyRecipesWeb.ApiAuthPipeline,
  error_handler: TastyRecipesWeb.ApiAuthErrorHandler,
  module: TastyRecipes.Guardian
config/config.exs
Note: You can generate your own secret_key with mix phx.gen.secret

Let's create the ApiAuthPipeline and ApiAuthErrorHandler that we just defined in the configuration.

defmodule TastyRecipesWeb.ApiAuthPipeline do
  use Guardian.Plug.Pipeline, otp_app: :tasty_recipes

  plug Guardian.Plug.VerifyHeader, claims: %{"typ" => "access"}
  plug Guardian.Plug.EnsureAuthenticated
  plug Guardian.Plug.LoadResource, allow_blank: true
end
lib/tasty_recipes_web/auth/api_auth_pipeline.ex
defmodule TastyRecipesWeb.ApiAuthErrorHandler 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
lib/tasty_recipes_web/auth/api_auth_error_handler.ex

Cool, we now have all we need to start creating our first API route aka sign_in:

...

  pipeline :api_authenticated do
    plug TastyRecipesWeb.ApiAuthPipeline
  end

  scope "/api", TastyRecipesWeb.Api, as: :api do
    pipe_through :api

    post "/sign_in", SessionController, :create
  end

...
lib/tasty_recipes_web/router.ex

And of course, we need to create the SessionController and the view that renders our response:

defmodule TastyRecipesWeb.Api.SessionController do
  use TastyRecipesWeb, :controller

  alias TastyRecipes.Accounts
  alias TastyRecipes.Accounts.User

  alias TastyRecipes.Guardian

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

      _ ->
        conn
        |> put_status(401)
        |> render("error.json", message: "Wrong credentials")
    end
  end
end
lib/tasty_recipes_web/controllers/api/session_controller.ex

Everything should be ready now! Let's try it:

➜  http POST localhost:4000/api/sign_in [email protected] password=mypassword
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 485
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 15:01:40 GMT
server: Cowboy
x-request-id: Fp_PGhi_E5gRoL0AABGF

{
    "data": {
        "email": "[email protected]",
        "token": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ0YXN0eV9yZWNpcGVzIiwiZXhwIjoxNjMwODU0MTAxLCJpYXQiOjE2MzAyNDkzMDEsImlzcyI6InRhc3R5X3JlY2lwZXMiLCJqdGkiOiI4NjdlOWJmMi04Nzk3LTRjMTQtODdjOS0xODg0ZWVlMmEyNDkiLCJuYmYiOjE2MzAyNDkzMDAsInN1YiI6ImViMDdlYTQ5LTVkYTctNGRiOC04OGFlLWI0YmJjNWIyZmEwZSIsInR5cCI6ImFjY2VzcyJ9.Sa--cLa-6NRvxT7tJGj8LFmND8gUX2NKQ9_2TkbOzZS2YHldQeGYFo6XCtylmtfEVmMAl8lssnzmkjQCEcGnAg"
    },
    "message": "Successful log in",
    "status": "ok"
}
Note: I am using httpie to test the endpoints.

Awesome, looks like we could authenticate ourselves! We now just need to update a little bit our api_authenticated plug to fetch user when there is an authenticated API call.

Add the fetch_current_user_api method in the user_auth file :

  @doc """
  Fetch user info linked to JWT .
  """
  def fetch_current_user_api(conn, _opts) do
    user = conn.private.guardian_default_resource
    assign(conn, :current_user, user)
  end
lib/tasty_recipes_web/controllers/html/user_auth.ex

Update the api_authenticated plug :

  pipeline :api_authenticated do
    plug TastyRecipesWeb.ApiAuthPipeline
    plug :fetch_current_user_api
  end
lib/tasty_recipes_web/router.ex

Now that we can retrieve our JWT token and use it to retrieve the user linked to it, we need to create the routes and logic to interact with the recipes. Thanks to Pheonix we can generate it on the fly :

➜ mix phx.gen.json Recipes Recipe recipes name description:text owner:references:users --web api --no-ecto --no-context --no-schema

We use --no-ecto (database migration), --no-context and --no-schema because they were already created in the previous steps. You can add the newly created resource to the router :

  scope "/api", TastyRecipesWeb.Api, as: :api do
    pipe_through :api_authenticated

    resources "/recipes", RecipeController
  end
lib/tasty_recipes_web/router.ex

We will also update the create method to pass the retrieved user from JWT token as owner :

  def create(conn, %{"recipe" => recipe_params}) do
    with {:ok, %Recipe{} = recipe} <-
           Recipes.create_recipe(Map.put(recipe_params, "owner", conn.assigns.current_user.id)) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", Routes.api_recipe_path(conn, :show, recipe))
      |> render("show.json", recipe: recipe)
    end
  end
lib/tasty_recipes_web/controllers/api/recipe_controller.ex

Time to try to create a recipe!

With a light pinch of salt...
➜ http POST http://localhost:4000/api/recipes \
    recipe:='{
        "name": "Awesome recipe",
        "description": "Short but awesome recipe!"
    }' \
    Authorization:'Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9....'

HTTP/1.1 201 Created
cache-control: max-age=0, private, must-revalidate
content-length: 120
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 16:00:37 GMT
location: /api/recipes/8031b9a3-ba17-4d55-ae8b-06fd27532ed3
server: Cowboy
x-request-id: Fp_SUb19xXArlWAAAAuG

{
    "data": {
        "description": "Short but awesome recipe!",
        "id": "8031b9a3-ba17-4d55-ae8b-06fd27532ed3",
        "name": "Awesome recipe"
    }
}

And what about retrieving them all?

➜ http http://localhost:4000/api/recipes \
    Authorization:'Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9....'

HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 736
content-type: application/json; charset=utf-8
date: Sun, 29 Aug 2021 16:01:20 GMT
server: Cowboy
x-request-id: Fp_SW5G1BJQl06MAAAeJ

{
    "data": [
        {
            "description": "Short but awesome recipe!",
            "id": "8031b9a3-ba17-4d55-ae8b-06fd27532ed3",
            "name": "Awesome recipe"
        }
    ]
}

And that's it! We can now handle authentication & our TastyRecipes™ in both HTML and API way.

And that was easy !

You can go further and create more API endpoints for users so they can sign up, update their password and things like that, directly from API, but on our side we will keep it simple!

In the next articles, we will see a few things such as :

  • Handle user ownership
  • Admin role
  • Improve our recipes model
  • Implement some real-time features using LiveView
  • ...

Don't forget to subscribe if you don't want to miss these updates!

If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.