Follow along as we create an authorization flow in Phoenix LiveView
Before we get started
Let's make a distinction between Authentication and Authorization.
Authentication refers to a user's identity and validity, while authorization refers to a user's permissions and access. For example, logging in and out of an app is an authentication concern, but distinguishing admin resources and end user resources is an authorization concern. These concepts can be somewhat intertwined.
The primary focus of this post will be authorization. We will be using Phoenix's mix phx.gen.auth
command to handle setting up a sample app's basic authentication flow; this will handle all of our authentication concerns for us.
The Setup
We are going to start by generating a sample app. We will use phx.new
to create a new Phoenix app. I will be using a Postgres database, you can specify which database adapter you want to use with ecto with the --database
option.
mix phx.new authorization_sample --database postgres
Next, in order to setup our app's basic Authentication flow we will use mix phx.gen.auth
to autogenerate our User schema.
mix phx.gen.auth Accounts User users
For this command, Accounts
refers to the context, User
refers to the name of the schema module for our users, and the last option users
is what the table will be called in our Postgres database.
This generator command will add some dependencies, so you will need to run
mix deps.get
Also in order for the users table to be created, you will first need to setup up the database. You can do this by running
mix ecto.setup
Now you should be able to run your local server with
mix phx.server
Permissions
For our new users table, we want to define two different roles: Admin and Client. These roles will be how we differentiate the permissions of our users and decide which resources they will be allowed to access.
Lets generate a migration for our users table to include the new :role
field.
mix ecto.gen.migration AddRoleToUsers
# Generating the migration
# Writing the migration
defmodule AuthorizationSample.Repo.Migrations.AddRoleToUsers do
use Ecto.Migration
def change do
alter table (:users) do
add :role, :string, null: false, default: "admin"
end
end
end
mix ecto.migrate
# running the migration
Now we need to define this new field in our User
schema. We will use an Ecto.Enum type so that we can be strict about the values allowed for the role field.
defmodule AuthorizationSample.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
...
field :role, Ecto.Enum, values: [:client, :admin]
timestamps(type: :utc_datetime)
end
...
Okay great, Now we have a User
schema with a role
field. Let's create two new users for our sample app: an admin and a client.
Creating our test users
With your localhost server is running, visit the address in your browser. You should see a link in the top right corner to Register
. Create an account with the email admin@example.com
and password adminpassword
. Log out and repeat that process to create a second user: Email:client@example.com
, Password:clientpassword
.
Changing User Roles
This concept is going to vary wildly depending on the use case for your application. For our sample app's purposes, we will simply write a query to update the client user and set their role
to be :client
. You may have noticed that in our migration, we set users by default to have an"admin"
role, this is just for example purposes and perhaps not something you'd want to do in a real application.
With those concerns out of the way, let's connect to our database so we can update the client user with the correct role.
psql authorization_sample_dev
Now that we are connected, we can set the client's role to be client
with this query
UPDATE users SET role = 'client' WHERE email = 'client@example.com';
Creating the Admin Facing LiveView
We have users and roles, now we are just missing an exclusive LiveView that only our admin should be able to access.
defmodule AuthorizationSampleWeb.AdminFacingLive do
use AuthorizationBlogWeb, :live_view
def render(assigns) do
~H"""
<h1>Hello Admin, welcome back.</h1>
"""
end
def mount(_params, _session, socket) do
{:ok, socket}
end
end
Keeping track of the current user
If we want to enforce any rules about what the current user can access, first we need to make sure our LiveViews know who the user is. We are in luck because the authentication flow we generated does this for us:
pipeline :browser do
# The router has already created a plug
# to fetch the current user for allrequests
...
plug :fetch_current_user
end
The fetch_current_user
plug refers to the UserAuth module which will assign the currently logged in user to the websocket.
def fetch_current_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_user, user)
end
This means that when we are logged in, all of our LiveViews should conveniently have the @current_user
in their assigns.
In a similar manner, we need to also check if the current user is authorized to access the LiveView when we mount it. In order to do this, we can use the on_mount
option for the live session where we put the route for our new admin exclusive LiveView. Following the existing pattern, we will name this hook :ensure_authorized
to check the user's role when the LiveView is mounted.
# Here is my router scope which requires a logged in user
scope "/", AuthorizationSampleWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{AuthorizationSampleWeb.UserAuth, :ensure_authenticated},
{AuthorizationSampleWeb.UserAuth, :ensure_authorized}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
live "/admin", AdminFacingLive, :index
end
end
Now let's define the ensure_authorized
on mount hook.
defmodule AuthorizationSampleWeb.UserAuth do
def on_mount(:ensure_authorized, _params, _session, socket) do
socket =
socket
|> Phoenix.LiveView.attach_hook(:auth_hook, :handle_params, fn _params, url, socket ->
%{assigns: %{current_user: current_user}} = socket
case Authorization.authorized?(current_user, url, "GET") do
true ->
socket =
socket
|> Phoenix.Component.assign(:live_url, url)
{:cont, socket}
false ->
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "Not Authorized")
|> Phoenix.LiveView.redirect(to: ~p"/")
{:halt, socket}
end
end)
|> Phoenix.LiveView.attach_hook(:auth_hook_event,
:handle_event, fn event, _params, socket ->
%{assigns: %{current_user: current_user, live_url: url}} = socket
case Authorization.authorized?(current_user, url, "GET", event) do
true ->
{:cont, socket}
false ->
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "Not Authorized")
{:halt, socket}
end
end)
{:cont, socket}
end
end
You'll see that we are calling an Authorization
module which does not exist yet, don't worry, that will be our next step. Essentially, our logic here is to check if the current user is authorized to access the requested path, if they are, then we continue, but if not, we redirect them back to the root path of the app and notify them of their missing authorization.
We are using attach_hook
to hook into our LiveView's lifecycle to either show a flash message or do nothing in the case of the user having the correct permissions.
Now let's actually implement the Authorization
module and define our users' permissions. Here is where we will actually delegate the paths that a user role has access to.
defmodule AuthorizationSample.Authorization do
alias AuthorizationSampleWeb.Router
def authorized?(user, path, method, event \\ nil) when is_binary(path) do
role = user && user.role
uri = URI.parse(path)
method = String.upcase(method || "GET")
info = Router.route_info(method, uri.path, uri.host)
route = info.route
action = get_action(info)
can?(role, route, "#{event || action}")
end
defp can?(_role, "/", _action), do: true
defp can?(:client, "/admin", _action), do: false
defp can?(_role, _route, _action), do: true
defp get_action(%{phoenix_live_view: plv}), do: elem(plv, 1)
defp get_action(%{plug_opts: action}), do: action
end
We are pattern matching on the user's role and the route passed to the authorize
function. When the user is logged in as a client role, and they attempt to visit the /admin
route, they will be redirected back to the home page. Notice the event/action is also being passed to the can?
function. This could allow you to limit the user to specific actions within a LiveView if need be. For our purposes, a client is allowed to access every route in the app besides /admin
.
Try logging in to the app with each user's credentials and visit the /admin
route. You will see that the admin can securely access the LiveView, but the client cannot and will be redirected. Also, if you try while not logged in, you will be redirected to the login page, then the authorization function will be called after you are logged in.
One nice pattern to use for your app is to have role
designated policies in separate modules. For example you would keep your can?
definitions in modules like AdminPolicy
and ClientPolicy
to organize their permissions.
Conclusion
We built a simple Phoenix app and generated user authentication out of the box. Then we devised an authorization flow by checking if a user can access a LiveView based on their role's permissions with our live session's on_mount
option. For more on LiveView authorization and security, check out these docs here.