Heading image for post: Authorization in Phoenix LiveView

Elixir Phoenix

Authorization in Phoenix LiveView

Profile picture of Jack Rosa

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.

Github Repo

More posts about Elixir Phoenix liveview

  • Adobe logo
  • Barnes and noble logo
  • Aetna logo
  • Vanderbilt university logo
  • Ericsson logo

We're proud to have launched hundreds of products for clients such as LensRentals.com, Engine Yard, Verisign, ParkWhiz, and Regions Bank, to name a few.

Let's talk about your project