Part 6 — Use Pow to secure your API with authentication features

Loi Le
9 min readOct 25, 2023

--

This is a part of the Tutorial to build a blog platform with Elixir Phoenix and Next.js that help you to develop a web application from scratch using modern technologies such as Elixir Phoenix, Next.js, and more.

Index | < Pre | Next>

We have finished the create post feature in the previous part. But we have a problem: anyone can create a post. We need to identify the post author and let them update or delete their own post. So, in this part, we will add authentication to the app and restrict the post creation to the logged in user. We will use pow, a powerful Elixir library for authentication and user management. Pow is flexible, modular and easy to use. Let’s try it out.

First, we add pow as a dependency in mix.exs:

defp deps do
[
# ...
{:pow, "~> 1.0.31"}
]
end

Then we run this command to install it and generate some files:

$ mix deps.get
$ mix pow.install

One of the files that pow modifies is lib/lani_blog_web/router.ex. We need to update this file as shown below:

defmodule LaniBlogWeb.Router do
use LaniBlogWeb, :router

pipeline :api do
plug :accepts, ["json"]
end

scope "/api", LaniBlogWeb do
pipe_through :api

resources "/posts", PostController, only: [:index, :show, :create]
end

# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router

scope "/" do
pipe_through [:fetch_session, :protect_from_forgery]

live_dashboard "/dashboard", metrics: LaniBlogWeb.Telemetry
end
end

# Enables the Swoosh mailbox preview in development.
#
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through [:fetch_session, :protect_from_forgery]

forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

We removed this code because we only support api, not website:

scope "/" do
pipe_through :browser

pow_routes()
end

Next, we notice that pow adds two migration files to our app:

LIB_PATH/users/user.ex
PRIV_PATH/repo/migrations/TIMESTAMP_create_users.ex

These files create the user table in our database. We can run the migration with this command:

$ mix ecto.migrate

To finish the pow configuration, we add this code to our config/config.exs file:

config :lani_blog, :pow,
cache_store_backend: Pow.Store.Backend.MnesiaCache

This tells pow to use Pow.Store.Backend.MnesiaCache as the cache store, which stores our cache data in the directory instead of in memory. For production, we can use other options like Redis cache.

To use the MnesiaCache, we also need to update our lib/lani_blog/application.ex file and add Pow.Store.Backend.MnesiaCache to the list of supervisors that we have to start:

 def start(_type, _args) do
children = [
# Start the Ecto repository
LaniBlog.Repo,
# Start the Telemetry supervisor
LaniBlogWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: LaniBlog.PubSub},
# Start the Endpoint (http/https)
LaniBlogWeb.Endpoint,
Pow.Store.Backend.MnesiaCache
# Start a worker by calling: LaniBlog.Worker.start_link(arg)
# {LaniBlog.Worker, arg}
]

# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: LaniBlog.Supervisor]
Supervisor.start_link(children, opts)
end

Next, we create a new file lib/lani_blog_web/api_auth_error_handler.ex with this code:

defmodule LaniBlogWeb.APIAuthErrorHandler do
use LaniBlogWeb, :controller

def call(conn, :not_authenticated) do
conn
|> put_status(401)
|> json(%{error: %{code: 401, message: "Not authenticated"}})
end
end

This module implements the Pow.Plug.ErrorHandler behaviour and returns a 401 error when an invalid token is used.

Now, we update our router lib/lani_blog_web/router.ex again and add a new pipeline and a new scope for our protected api:

defmodule LaniBlogWeb.Router do
use LaniBlogWeb, :router

pipeline :api do
plug :accepts, ["json"]
end

pipeline :api_protected do
plug Pow.Plug.RequireAuthenticated, error_handler: LaniBlogWeb.APIAuthErrorHandler
end

scope "/api", LaniBlogWeb do
pipe_through [:api, :api_protected]

resources "/posts", PostController, only: [:create]
end

scope "/api", LaniBlogWeb do
pipe_through :api

resources "/posts", PostController, only: [:index, :show]
end

# Enables LiveDashboard only for development
#
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
if Mix.env() in [:dev, :test] do
import Phoenix.LiveDashboard.Router

scope "/" do
pipe_through [:fetch_session, :protect_from_forgery]

live_dashboard "/dashboard", metrics: LaniBlogWeb.Telemetry
end
end

# Enables the Swoosh mailbox preview in development.
#
# Note that preview only shows emails that were sent by the same
# node running the Phoenix server.
if Mix.env() == :dev do
scope "/dev" do
pipe_through [:fetch_session, :protect_from_forgery]

forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

We created a new pipeline called :api_protected that uses the Pow.Plug.RequireAuthenticated plug and our error handler: LaniBlogWeb.APIAuthErrorHandler

pipeline :api_protected do
plug Pow.Plug.RequireAuthenticated, error_handler: LaniBlogWeb.APIAuthErrorHandler
end

Then, we created a new scope for our protected api and move the create post route there

scope "/api", LaniBlogWeb do
pipe_through [:api, :api_protected]

resources "/posts", PostController, only: [:create]
end

We also removed the create post route from the previous scope that does not require authentication:

scope "/api", LaniBlogWeb do
pipe_through :api

resources "/posts", PostController, only: [:index, :show]
end

Now we can test our create api with this command:

curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "Test title",
"description": "Test description",
"content": "Test content",
"category_id": 1
}
}'

You should get a response with status 401 and body:

{
"error": {
"code": 401,
"message": "Not authenticated"
}
}

Great job! We have successfully completed our task and prevented the unauthenticated user from accessing the create post api. Now, we will work on the sign up and sign in features. To do this, we will use the helper: lib/lani_blog_web/api_auth_plug.ex that allows us to:

  • Retrieve the user from the access token.
  • Generate an access and renewal token for the user.
  • Remove the access token from the cache.
  • Create new tokens using the renewal token.
defmodule LaniBlogWeb.APIAuthPlug do
use Pow.Plug.Base

alias Plug.Conn
alias Pow.{Config, Plug, Store.CredentialsCache}
alias PowPersistentSession.Store.PersistentSessionCache

def fetch(conn, config) do
with {:ok, signed_token} <- fetch_access_token(conn),
{:ok, token} <- verify_token(conn, signed_token, config),
{user, _metadata} <- CredentialsCache.get(store_config(config), token) do
{conn, user}
else
_any -> {conn, nil}
end
end

def create(conn, user, config) do
IO.puts "user"
IO.inspect user

store_config = store_config(config)
access_token = Pow.UUID.generate()
renewal_token = Pow.UUID.generate()
expriration = :timer.minutes(30)
expired_at = DateTime.utc_now() |> DateTime.add(expriration, :millisecond)

conn =
conn
|> Conn.put_private(:api_access_token, sign_token(conn, access_token, config))
|> Conn.put_private(:api_renewal_token, sign_token(conn, renewal_token, config))
|> Conn.put_private(:api_access_token_expired_at, expired_at)
|> Conn.put_private(:current_user, user)
|> Conn.register_before_send(fn conn ->
# The store caches will use their default `:ttl` setting. To change the
# `:ttl`, `Keyword.put(store_config, :ttl, :timer.minutes(10))` can be
# passed in as the first argument instead of `store_config`.
CredentialsCache.put(Keyword.put(store_config, :ttl, expriration), access_token, {user, [renewal_token: renewal_token]})

PersistentSessionCache.put(
store_config,
renewal_token,
{user, [access_token: access_token]}
)

conn
end)

{conn, user}
end

def delete(conn, config) do
store_config = store_config(config)

with {:ok, signed_token} <- fetch_access_token(conn),
{:ok, token} <- verify_token(conn, signed_token, config),
{_user, metadata} <- CredentialsCache.get(store_config, token) do
Conn.register_before_send(conn, fn conn ->
PersistentSessionCache.delete(store_config, metadata[:renewal_token])
CredentialsCache.delete(store_config, token)

conn
end)
else
_any -> conn
end
end

def renew(conn, config) do
store_config = store_config(config)

with {:ok, signed_token} <- fetch_access_token(conn),
{:ok, token} <- verify_token(conn, signed_token, config),
{user, metadata} <- PersistentSessionCache.get(store_config, token) do

{conn, user} = create(conn, user, config)

conn =
Conn.register_before_send(conn, fn conn ->
CredentialsCache.delete(store_config, metadata[:access_token])
PersistentSessionCache.delete(store_config, token)

conn
end)

{conn, user}
else
_any -> {conn, nil}
end
end

defp sign_token(conn, token, config) do
Plug.sign_token(conn, signing_salt(), token, config)
end

defp signing_salt(), do: Atom.to_string(__MODULE__)

defp fetch_access_token(conn) do
case Conn.get_req_header(conn, "authorization") do
[token | _rest] -> {:ok, token}
_any -> :error
end
end

defp verify_token(conn, token, config),
do: Plug.verify_token(conn, signing_salt(), token, config)

defp store_config(config) do
backend = Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache)

[backend: backend, pow_config: config]
end
end

In this module, we implement the renewal logic and return both an auth token and a renewal token when we create a session.

We need to add the APIAuthPlug to the :api pipeline in lib/lani_blog_web/router.ex

pipeline :api do
plug :accepts, ["json"]
plug LaniBlogWeb.APIAuthPlug, otp_app: :lani_blog
end

Next, we create a new file lib/lani_blog_web/controllers/registration_controller.ex for the sign up feature. It has this code:

defmodule LaniBlogWeb.RegistrationController do
use LaniBlogWeb, :controller

alias Ecto.Changeset
alias Plug.Conn
alias LaniBlogWeb.ErrorHelpers

def create(conn, %{"user" => user_params}) do
conn
|> Pow.Plug.create_user(user_params)
|> case do
{:ok, _user, conn} ->
json(conn, %{
data: %{
access_token: conn.private.api_access_token,
renewal_token: conn.private.api_renewal_token,
expired_at: conn.private.api_access_token_expired_at
}
})

{:error, changeset, conn} ->
errors = Changeset.traverse_errors(changeset, &ErrorHelpers.translate_error/1)

conn
|> put_status(500)
|> json(%{error: %{status: 500, message: "Couldn't create user", errors: errors}})
end
end
end

We use the create_user function from pow to create a new user and the create function from APIAuthPlug to generate the tokens. If everything goes well, we send back the tokens and the expiration time to the user. If something goes wrong, we send back an error with status 500.

Then, we create another file lib/lani_blog_web/controllers/session_controller.ex for the session features. It has this code:

defmodule LaniBlogWeb.SessionController do
use LaniBlogWeb, :controller

alias LaniBlogWeb.APIAuthPlug

def create(conn, %{"user" => user_params}) do
conn
|> Pow.Plug.authenticate_user(user_params)
|> case do
{:ok, conn} ->
json(conn, %{
data: %{
access_token: conn.private.api_access_token,
renewal_token: conn.private.api_renewal_token,
expired_at: conn.private.api_access_token_expired_at,
user: %{
email: conn.private.current_user.email
}
}
})

{:error, conn} ->
conn
|> put_status(401)
|> json(%{error: %{status: 401, message: "Invalid email or password"}})
end
end

def renew(conn, _params) do
config = Pow.Plug.fetch_config(conn)

conn
|> APIAuthPlug.renew(config)
|> case do
{conn, nil} ->
conn
|> put_status(401)
|> json(%{error: %{status: 401, message: "Invalid token"}})

{conn, _user} ->
json(conn, %{
data: %{
access_token: conn.private.api_access_token,
renewal_token: conn.private.api_renewal_token,
expired_at: conn.private.api_access_token_expired_at
}
})
end
end

def delete(conn, _params) do
conn
|> Pow.Plug.delete()
|> json(%{data: %{}})
end
end

The create function supports the log in feature. The renew function creates a new access_token from the renewal_token so that the user does not need to sign in again when the access_token expires. The delete function supports the sign out feature.

We register our new controllers to the router lib/lani_blog_web/router.ex:

scope "/api", LaniBlogWeb do
pipe_through :api

resources "/registration", RegistrationController, singleton: true, only: [:create]
resources "/session", SessionController, singleton: true, only: [:create, :delete]
post "/session/renew", SessionController, :renew

resources "/posts", PostController, only: [:index, :show]
end

We used singleton resources for performance reasons.

We are done with the implementation. Let’s test them with these commands:

  • To sign up, we use this command:
curl --location --request POST 'http://localhost:4000/api/registration' \
--header 'Content-Type: application/json' \
--data-raw '{
"user": {
"email": "john.doe@test.com",
"password": "letmein@123",
"password_confirmation": "letmein@123"
}
}'

The response is 200 with the body:

{
"data": {
"access_token": "SFMyNTY.ZDBkODA5NGItMmU4Mi00Yjc1LThkODYtZjA4ZTBkMjI1NGQ1.OK4nCHgPzy6DTHDP_cOKZdUUq7jDLBtX661LnNvgt8E",
"expired_at": "2023-10-25T11:04:31.790919Z",
"renewal_token": "SFMyNTY.N2FhZTQwMGYtNDgwNS00OGRjLWE0MzUtMGFjYzU4MWRjNWRk.pc5okTgeFmzYvU6eMoeVSd8eyld44t0DJJxzCHAzFTE"
}
}
  • To sign in, we use this command:
curl --location --request POST 'http://localhost:4000/api/session' \
--header 'Content-Type: application/json' \
--data-raw '{
"user": {
"email": "john.doe@test.com",
"password": "letmein@123"
}
}'

The response is 200 with body:

{
"data": {
"access_token": "SFMyNTY.ZWZmNzEzMWItNzA5NC00ODM2LTljOTYtNjFmY2UwOTI3ZjRk.Ng8vvAchSBBsKCwZI2nsDRXMJ9u-fam7LbwCIO7BhXk",
"expired_at": "2023-10-25T11:05:13.146024Z",
"renewal_token": "SFMyNTY.NTNlNDU4NzctOGY1MC00MzNkLWFhYWEtMDRjMmExNDIyMzBm.L4ZX417EXnGWl9qqRXYGs60jyVWCsK8SlEHQ5jQX-l8",
"user": { "email": "john.doe@test.com" }
}
}
  • To create a new post, we use the access_token in this command:
curl --location --request POST 'http://localhost:4000/api/posts' \
--header 'Authorization: SFMyNTY.ZWZmNzEzMWItNzA5NC00ODM2LTljOTYtNjFmY2UwOTI3ZjRk.Ng8vvAchSBBsKCwZI2nsDRXMJ9u-fam7LbwCIO7BhXk' \
--header 'Content-Type: application/json' \
--data-raw '{
"data": {
"title": "Amet nisl suscipit adipiscing bibendum est",
"description": "Egestas purus viverra accumsan in nisl nisi scelerisque eu ultrices vitae auctor eu augue ut lectus arcu bibendum at varius vel pharetra vel turpis nunc",
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui."
}
}'

The response is 200 with body:

{
"data": {
"content": "Ipsum dolor sit amet **consectetur** adipiscing. Leo integer malesuada nunc vel risus. Id leo in vitae turpis massa sed elementum tempus egestas.\n\n- Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus\n- Sed felis eget velit aliquet sagittis id consectetur. Interdum varius sit amet mattis\n- Orci phasellus egestas tellus rutrum tellus pellentesque eu tincidunt tortor.\n\n Integer enim neque volutpat ac tincidunt vitae. Ac turpis egestas maecenas pharetra convallis posuere. Urna neque viverra justo nec ultrices dui.",
"created_at": "2023-10-25T10:39:10",
"description": "Egestas purus viverra accumsan in nisl nisi scelerisque eu ultrices vitae auctor eu augue ut lectus arcu bibendum at varius vel pharetra vel turpis nunc",
"id": 3,
"title": "Amet nisl suscipit adipiscing bibendum est"
}
}
  • To renew the access_token, we use the renewal_token:
curl --location --request POST 'http://localhost:4000/api/session/renew' \
--header 'Authorization: SFMyNTY.NTNlNDU4NzctOGY1MC00MzNkLWFhYWEtMDRjMmExNDIyMzBm.L4ZX417EXnGWl9qqRXYGs60jyVWCsK8SlEHQ5jQX-l8'

The response is 200 with body:

{
"data": {
"access_token": "SFMyNTY.NWEyZDcyZTctZmE3Ny00MjMyLTg0M2ItNmU4OTBjN2M3NjRj.6OPLTkC7gZEqUJ6nXouR2KYYx7_nj0fSw02jUl5LHrw",
"expired_at": "2023-10-25T11:10:08.442669Z",
"renewal_token": "SFMyNTY.NGQzNGY2NGMtMTVjOS00ZTgyLTk1MTMtNmMwN2MwYzc0NjM3.aWfkpZ4HYnpJ3K6w3wgKo1C5j0-x4dm5hxf9dyvpAB4"
}
}

Awesome! We have finished the authentication implementation with pow. User has to sign up and sign in to use the create post api. But how can user view their own posts? Currently, the post has no author. We need to link the post to the current user who created it. Let’s tackle this challenge in the next part.

This part is based on the pow documentation’s instructions: https://hexdocs.pm/pow/api.html

Index | < Pre | Next>

--

--