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.
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 therenewal_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