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
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
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
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
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
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
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
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
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
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
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
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!
Top comments (0)