Securing Your Phoenix LiveView Apps

sophiedebenedetto

Sophie DeBenedetto

Posted on February 1, 2022

Securing Your Phoenix LiveView Apps

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 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:

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
Enter fullscreen mode Exit fullscreen mode

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:

# router.ex
import ArcadeWeb.UserAuth
Enter fullscreen mode Exit fullscreen mode

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:

scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]
  # ...
end
Enter fullscreen mode Exit fullscreen mode

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

scope "/", ArcadeWeb do
  pipe_through [:browser, :require_authenticated_user]
  live "/products", ProductLive.Index
  live "/products/:id", ProductLive.Show
  live "/survey", SurveyLive
end
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

scope "/", ArcadeWeb do
  pipe_through [:browser, :require_admin_user]
  live "/admin-dashboard", Admin.DashboardLive
end
Enter fullscreen mode Exit fullscreen mode

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 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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

 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
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
sophiedebenedetto
Sophie DeBenedetto

Posted on February 1, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related