Securing Your Phoenix LiveView Apps
Tags: elixir, liveview
Published: 2022-02-01 12:03:29

LiveView is a compelling choice for building modern web apps. Built on top of Elixir's OTP tooling, and leveraging WebSockets, it offers super fast real-time, interactive features alongside impressive developer productivity.

In this post, we'll show you how to secure your live view routes with function plugs and group live routes in a secure live session.

Let's dive straight in!

## Using Live View to Build a Phoenix Web App

We'll be building some authentication and authorization features into a Phoenix web app built with live view.

The Arcade web app presents regular users with many online games to play. Our app has a survey feature that collects users' demographic data and game ratings. It also has an admin dashboard that should  _only_ be accessible to app admins to view survey results.

For the purpose of this post, we'll assume that logged-in users can visit the `/games` index and `/games/:id` show routes to select and play a game, along with the `/survey` route to fill out the user survey.

Additionally, admin users should **only** be able to visit the `/admin-dashboard` page. We'll assume that these pages and the live views that back them have already been built. Our focus is on introducing the authentication and authorization code we need to secure these live views.

Let's get started.

## Protect Sensitive Routes in Your Phoenix LiveView App

This post assumes you've already built your registration and login flows, along with some function plugs for authenticating the current user and storing their token in the Phoenix session. I recommend using the [Phoenix Auth generator](https://hexdocs.pm/phx_gen_auth/overview.html) to generate this code for free.

This generated code ensures that Phoenix will add a key of `:current_user` to the `conn` struct and a `"user_token"` key to the session when a user logs in.

Now, let's start in the router by putting some live routes behind authentication.

If you run the Phoenix Auth generator, you generate a module, `ArcadeWeb.UserAuth`, that implements a function plug `require_authenticated_user/2`, shown here:

```elixir
def require_authenticated_user(conn, _opts) do
  if conn.assigns[:current_user] do
    conn
  else
    conn
    |> put_flash(:error, "You must log in to access this page.")
    |> maybe_store_return_to()
    |> redirect(to: Routes.user_session_path(conn, :new))
    |> halt()
  end
end
```

The details of this function aren't too important. Just understand that it takes in a first argument of the `conn` struct and checks for the presence of a `:current_user` key. If one is found, it returns the conn. If not, then it redirects to the login path.

When the auth generator creates the `UserAuth` module, it also imports into your router, like this:

```elixir
# router.ex
import ArcadeWeb.UserAuth
```

We can create a new router scope using this function plug. Let's require that a user is logged in for access to the scope routes, like this:

```elixir
scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]
  # ...
end
```

Add the product index, show routes, and the `/survey` route that any authenticated user can currently visit:

```elixir
scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]
  live "/products", ProductLive.Index
  live "/products/:id", ProductLive.Show
  live "/survey", SurveyLive
end
```

Now, when a user visits `/products` or any other route in our new scope, Phoenix invokes the `require_authenticated_user` function plug. Believe it or not, that's all we have to do to restrict our live routes to logged-in users.

We can take a similar approach to authorizing admins to visit the `/admin-dashboard`. We'll add a new function plug to the `UserAuth` module, like this:

```elixir
def require_admin_user(%{current_user: current_user} = conn, _opts) do
  if current_user.admin do
    conn
  else
    conn
    |> put_flash(:error, "You must log in to access this page.")
    |> maybe_store_return_to()
    |> redirect(to: Routes.page_path(conn))
    |> halt()
  end
end

def require_admin_user(conn, _opts) do
  conn
    |> put_flash(:error, "You must log in to access this page.")
    |> maybe_store_return_to()
    |> redirect(to: Routes.page_path(conn))
    |> halt()
end
```

If the function plug is invoked with a `conn` struct that does not contain the current user, we will redirect to the root path.

If the function plug is called with a `conn` that contains a current user, we will check if that user is an admin. If so, return the `conn`, otherwise, redirect. The details of our check for the admin status, `current_user.admin`, don't really matter here. Your app may implement admin logic differently. The main takeaway is that we now have a function plug that can _authorize_ certain routes by enforcing that the current user is present _and_ an admin.

Let's now use our new function plug in our router. We'll create a second scope with a pipeline that uses the `require_admin_user/1` function plug:

```elixir
scope "/", ArcadeWeb do
  pipe_through [:browser, :require_admin_user]
  live "/admin-dashboard", Admin.DashboardLive
end
```

Great! If a user points their browser at `/admin-dashboard`, our function plug will be invoked.

We've ensured that our live view routes are secure with nothing more than normal Phoenix auth plugs. We can implement authentication — requiring the presence of a current user — and authorization — requiring that the current user has specific permissions or roles — just like you would for regular Phoenix routes.

Now, let's look at a new LiveView feature for grouping live routes together and a security challenge that it presents.

## Group Live Views in a Live Session

You'll use live sessions to group similar live routes with shared layouts and auth logic. Grouping live routes together in a live session means that we can live redirect to those routes from any other route in the same live session group.

A [live redirect](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_redirect/2) is a special kind of redirect that leverages the existing WebSocket connection, minimizing network traffic and keeping your live view speedy.

When you live redirect from one live view to another in the same live session, the current live view process terminates. The new live view is mounted over the current WebSocket connection without reloading the whole page.

This works great for live views that share a layout. The shared layout that frames the live view content will stay in place, and only the portion of the page that renders the current live view within that layout will change.

Let's create our first live session group now for the routes behind regular user authentication:

```elixir
scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :user, root_layout: {ArcadeWeb.LayoutView, "authenticated.html"} do
    live "/products", ProductLive.Index
    live "/products/:id", ProductLive.Show
    live "/survey", SurveyLive
  end
end
```

Here, we establish a live session block to contain all of our routes that need to be authenticated for regular users and tell them to share a common layout found in `lib/arcade_web/templates/layout/authenticated.html.heex`. You might notice that the `live_session` macro is called with a first argument, `:user`. We'll see how that comes into play in just a bit.

Whenever a user live redirects from the `/products` route to `/products/:id`, for example, the existing WebSocket connection will not terminate. Instead, we'll kill the current live view process, mount the new live view, and only re-render the necessary portions of the page within the shared layout.

### Grouping Live Routes: The Security Problem
This approach presents a security challenge. If we re-use the existing WebSocket connection, we _won't_ be sending a new HTTP request, and we _won't_ go through the plug pipeline defined in our router.

So we must perform authentication and authorization in our router to prevent direct navigation to sensitive routes from the browser. We must also ensure that our live views can perform their own authentication and authorization _every time they mount_ (whether due to a user pointing their browser directly at a live route or a live redirect between live routes in a shared live session).

Luckily for us, LiveView presents an API for performing authorization and authentication when the live view mounts, making it easy for us to apply this logic across all live routes in a shared session. Let's take a look.

## Protect Live Views When They Mount

The LiveView framework allows us to hook into a callback function that will run whenever a live view mounts. The `on_mount/4` lifecycle hook will fire before the live view mounts, making it the perfect place to isolate re-usable auth logic that can be shared among live views in a live session.

Start by defining a module that implements an `on_mount/4` function, like this:

```elixir
defmodule ArcadeWeb.UserAuthLive do
  import Phoenix.LiveView
  alias Arcade.Accounts

  def on_mount(:user, params, %{"user_token" => user_token} = _session, socket) do
    socket =
      socket
      |> assign(:current_user, Accounts.get_user_by_session_token(user_token))
    if socket.assigns.current_user do
      {:cont, socket}
    else
      {:halt, redirect(socket, to: "/login")}
    end
  end
end
```

This function will be called with:

- a first argument of the atom that we passed to our `live_session` macro
- a second argument of any params that were part of the incoming web request
- a third argument of the session containing the `"user_token"` used to identify the current user
- a fourth argument of the socket (remember, if you use the Phoenix Auth generator, Phoenix will add this `"user_token"` to the session when a user logs in.)

We perform some basic authentication here by looking up the current user and assigning the result to the socket. If the socket then contains a current user value rather than `nil`, we continue. Otherwise, we halt and redirect. Any `on_mount/4` function must conform to this API, returning the `:cont` tuple or the `:halt` tuple.

Next up, let's tell our live session to apply this `on_mount/4` callback to all of the live routes in its grouping:

```elixir
scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]

  live_session :user, on_mount: UserAuthLive, root_layout: {ArcadeWeb.LayoutView, "authenticated.html"} do
    live "/products", ProductLive.Index
    live "/products/:id", ProductLive.Show
    live "/survey", SurveyLive
  end
end
```

Whenever there is a live redirect to `"/products"` route (or any other route in that live session), the given live view will invoke `ArcadeWeb.UserAuthLive.on_mount/4` with a first argument of `:user` and our authentication logic will execute. Furthermore, any live view within the live session will mount with the `:current_user` already set in its socket assigns, since we're adding it in the `on_mount` callback.

Let's set up a similar callback for the admin live session. Add this function to `UserAuthLive`:

```elixir
def on_mount(:admin, params, %{"user_token" => user_token} = _session, socket) do
  socket =
    socket
    |> assign(:current_user, Accounts.get_user_by_session_token(user_token))
  if socket.assigns.current_user.admin do
    {:cont, socket}
  else
    {:halt, redirect(socket, to: "/")}
  end
end
```

Here, when `on_mount/4` is called with a first argument of `:admin`, we will authorize the current user _and_ authenticate them as an admin. Let's add this to a new live session for the admin-protected routes now:

```elixir
 scope "/", ArcadeWeb do
  pipe_through [:browser, :require_admin_user]
  live_session :admin, on_mount: {UserAuthLive, :admin}, root_layout: {ArcadeWeb.LayoutView, "admin.html"} do
    live "/admin-dashboard", Admin.DashboardLive
    # more admin routes
  end
end
```

Here, we group admin-protected routes with a shared admin layout. And whenever any of the live views in this session block mount, the `UserAuthLive.on_mount/4` function will be called with the `:admin` atom as a first argument. This ensures that only admin users can access those pages, even when live redirected.

Thanks to Elixir's pattern matching, we can group all of our auth-related `on_mount/4` callbacks in a shared module and implement however many `live_session` blocks we need to organize our live views.

## Wrap Up: Easily Group Live Views to Secure Your Phoenix LiveView App

In this post, we explored how LiveView allows you to group live routes in a shared session. Grouping enables live views to easily share a layout and implement shared authentication and authorization logic.

Remember, you must authenticate and authorize _both_ your protected routes in the router _and_ your live views when they mount. Reach for function plug pipelines to achieve the former, and live session and the `on_mount/4` callback to accomplish the latter. With this combination of tools, you can bulletproof your live views, making them highly secure and capable of sophisticated authorization logic.

I hope you've found this post useful. Until next time: happy coding!

**P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, [subscribe to our Elixir Alchemy newsletter and never miss a single post](/elixir-alchemy)!**