Simple token authentication for Phoenix API

Recently I am really into IoT development using Elixir programming language, Nerves IoT platform and Phoenix web framework. After quickly learning the basics of electronics, I built a real-time temperature and humidity monitoring system for my living room. It has been successful and really fun.

Now that the system is working well, I want to make it more secure. Today I will implement simple token authentication for my API server.

日本語版

hello-nerves-2

Plans

  • Implement custom plugs for token authentication
  • Manually generates a token for each user in IEx
  • Reject access if a token in the request headers is missing or invalid

Phoenix.Token

Thankfully, Phoenix has all the useful utilities for generating and verifying a token in Phoenix.Token module. Nice!

Custom plug module example

Using Phoenix.Token module, I wrote two custom plugs:

  • ExampleWeb.API.Auth - a module plug that verifies the bearer token in the request headers and assigns :current_user
  • ExampleWeb.API.Auth.authenticate_api_user/2 - a function plug that ensures that :current_user value is present.

I learned about the plugs from the Programming Phoenix book.

defmodule ExampleWeb.API.Auth do
  @moduledoc """
  A module plug that verifies the bearer token in the request headers and
  assigns `:current_user`. The authorization header value may look like
  `Bearer xxxxxxx`.
  """

  import Plug.Conn
  import Phoenix.Controller

  def init(opts), do: opts

  def call(conn, _opts) do
    conn
    |> get_token()
    |> verify_token()
    |> case do
      {:ok, user_id} -> assign(conn, :current_user, user_id)
      _unauthorized -> assign(conn, :current_user, nil)
    end
  end

  @doc """
  A function plug that ensures that `:current_user` value is present.

  ## Examples

      # in a router pipeline
      pipe_through [:api, :authenticate_api_user]

      # in a controller
      plug :authenticate_api_user when action in [:index, :create]

  """
  def authenticate_api_user(conn, _opts) do
    if Map.get(conn.assigns, :current_user) do
      conn
    else
      conn
      |> put_status(:unauthorized)
      |> put_view(ExampleWeb.ErrorView)
      |> render(:"401")
      # Stop any downstream transformations.
      |> halt()
    end
  end

  @doc """
  Generate a new token for a user id.

  ## Examples

      iex> ExampleWeb.API.Auth.generate_token(123)
      "xxxxxxx"

  """
  def generate_token(user_id) do
    Phoenix.Token.sign(
      ExampleWeb.Endpoint,
      inspect(__MODULE__),
      user_id
    )
  end

  @doc """
  Verify a user token.

  ## Examples

      iex> ExampleWeb.API.Auth.verify_token("good-token")
      {:ok, 1}

      iex> ExampleWeb.API.Auth.verify_token("bad-token")
      {:error, :invalid}

      iex> ExampleWeb.API.Auth.verify_token("old-token")
      {:error, :expired}

      iex> ExampleWeb.API.Auth.verify_token(nil)
      {:error, :missing}

  """
  @spec verify_token(nil | binary) :: {:error, :expired | :invalid | :missing} | {:ok, any}
  def verify_token(token) do
    one_month = 30 * 24 * 60 * 60

    Phoenix.Token.verify(
      ExampleWeb.Endpoint,
      inspect(__MODULE__),
      token,
      max_age: one_month
    )
  end

  @spec get_token(Plug.Conn.t()) :: nil | binary
  def get_token(conn) do
    case get_req_header(conn, "authorization") do
      ["Bearer " <> token] -> token
      _ -> nil
    end
  end
end

How to use custom plugs

The function plug authenticate_api_user/2 needs to be imported before use. There are two possible scenarios.

A: when used in a router pipeline

This pattern is useful to affect all the controllers in the pipeline. We need to import the function plug within the quote block of ExampleWeb.router function.

 defmodule ExampleWeb do

   ...

   def router do
     quote do
       use Phoenix.Router

       import Plug.Conn
       import Phoenix.Controller
       import Phoenix.LiveView.Router
+      import ExampleWeb.API.Auth, only: [authenticate_api_user: 2]
     end
   end

Here is an example usage.

 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
+    plug ExampleWeb.API.Auth
   end

   scope "/api", ExampleWeb do
-    pipe_through [:api]
+    pipe_through [:api, :authenticate_api_user]

     resources "/measurements", API.Environment.MeasurementController, only: [:index,  :show, :create]
   end

B: when used in a specific controller

This pattern is useful when we want to affect only specific controller actions. We need to import the function plug within the quote block of ExampleWeb.controller function.

 defmodule ExampleWeb do

   ...

   def controller do
     quote do
       use Phoenix.Controller, namespace: ExampleWeb

       import Plug.Conn
       import ExampleWeb.Gettext
+      import ExampleWeb.API.Auth, only: [authenticate_api_user: 2]
       alias ExampleWeb.Router.Helpers, as: Routes
     end
   end

Here is an example usage.

 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   pipeline :api do
     plug :accepts, ["json"]
+    plug ExampleWeb.API.Auth
   end
 defmodule ExampleWeb.API.MeasurementController do
   use ExampleWeb, :controller

   alias Example.Measurement

   action_fallback ExampleWeb.API.FallbackController
+
+  plug :authenticate_api_user when action in [:create]
+

Quick test

In an IEx console, generate a token.

iex> ExampleWeb.API.Auth.generate_token(1)
"xxxxxxx"

Then hit an API endpoint with or without a token.

❯ curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer SFMyNTY.g2gDYQFuBgCtL76NeAFiAAFRgB" \
  -d '{"measurement": {"temperature_c": "23.5"}}' \
  http://localhost:4000/api/measurements
{"data":{"id":37,"temperature_c":23.5}}

❯ curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"measurement": {"temperature_c": "23.5" }}' \
  http://localhost:4000/api/measurements
"Unauthorized"

Production

According to the documentation SECRET_KEY_BASE is used for the token generation, so the token generated in development environment won't work in production. I generate a token in the production IEx.

That's it!