Application Security

DANA 325 Q R X K F O H M L S W B N D C A G E V T U P I

Objectives

not yet graded
  • Logging out of one socket logs out all LiveView sockets of a given user. (7 Points)

First if you are interested in full time positions after Bucknell as a Web Developer checkout the Elixir Forum. You will find my announcement regarding our course there too!

We will work on a a small activity today.

First, to increase security on our website we would like to logout ALL sockets belonging to a user, when that user logs out at ANY socket.

LiveView allows us to subscribe to a channel unique to that user and then send broadcast messages to all sockets belonging to that user.

We could subscribe to the PubSub room as part of our {UserAuth, :ensure_authenticated} instruction that applies to all LiveViews we protect under authentication:

In UserAuth.ex modify your :ensure_authenticated function:

def on_mount(:ensure_authenticated, _params, session, socket) do
  socket = mount_current_user(socket, session)

  if socket.assigns.current_user do
    # add the next two lines:
    user_id = socket.assigns.current_user.id
    Phoenix.PubSub.subscribe(App.PubSub, "user:" <> Integer.to_string(user_id))

    {:cont, socket}
  else
    socket =
      socket
      |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
      |> Phoenix.LiveView.redirect(to: ~p"/users/log_in")

    {:halt, socket}
  end
end

In case you have not seen it before the <> is an operator to concatenate two strings:

"a" <> "b" == "ab"

This thus allows us to subscribe to a room specific to the user such as user:1.

Next I would like to have a handle_event function that performs a logout:

def handle_event("logout", _params, socket) do
  {:noreply, redirect(socket, to: ~p"/users/log_out")}
end

In Phoenix LiveView, redirect/2 is used for GET requests, but since logging out usually involves a DELETE request (assuming your app follows RESTful conventions) we will need to also allow for logging out via GET requests. We could (don't do it) change the DELETE request into a GET request in our router.ex:

get "/users/log_out", UserSessionController, :delete # <-- add
delete "/users/log_out", UserSessionController, :delete # <-- remove

The downsides as described by ChatGPT are:

  • 1. CSRF Vulnerability A GET request can be triggered unintentionally (e.g., by an <img> tag, iframe, or third-party script). If an attacker tricks a user into visiting a page with an <img src="https://yourapp.com/users/log_out">, the user will be logged out without their intent. Solution: DELETE requests in Phoenix are protected by CSRF tokens, but GET requests are not.

  • 2. REST Semantics Violation HTTP GET should not cause side effects (like logging out). GET is meant for fetching data, not modifying the session. Logout is a state-changing action (it modifies authentication state), so it should be DELETE (or POST in some cases).

  • 3. Browser Caching Issues Some browsers or proxies may cache GET requests, leading to unexpected behavior (e.g., reloading the page could trigger logout again).

We thus need a more secure strategy.

Solution: Phoenix Hooks to the rescue

1. Define a JavaScript Hook in app.js:

Notice that if you have already created a hook before (as you should have if you folled the course materials on time) you will need to add the hook to the existing Hooks variable:

let Hooks = {}; // this may already be here

Hooks.LogoutButton = {
  mounted() {
    this.handleEvent("logout", () => {
      let btn = document.getElementById("logout-button");
      if (btn) btn.click();
    });
  }
};

// add hook to your LiveView connect (this may already be here):
let liveSocket = new LiveSocket(liveSocketPath, Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks
})

2. Attach the Hook to logout button.

The logout button should be part of your app.html.heex template and thus avaiable in every LiveView. You must also add the id attribute:

<.link
  id="logout-button"
  href={~p"/users/log_out"}
  method="DELETE"
  phx-hook="LogoutButton"
>
  Logout
</.link>

3. Trigger the Event from the Server:

def handle_event("logout", _params, socket) do
  {:noreply, push_event(socket, "logout", %{})}
end

Optionally, we could also trigger the event from another LiveView button:

<button phx-click="logout">Trigger Logout</button>

You could try this out in a LiveView now. If clicking the "Trigger Logout" button logs you out, we can bring this to the next level.

The current solution only logs out the current user again thus we really want to sent the signal to logout to all sockets. Thus your handle_event function should look like:

def handle_event("logout", _params, socket) do
  Phoenix.PubSub.broadcast(App.PubSub, "user:#{socket.assigns.user.id}", :logout)
  {:noreply, socket}
end

We also need to fetch that message in every LiveView and perform the logout:

def handle_info(:logout, socket) do
  Phoenix.PubSub.broadcast(App.PubSub, "user:#{socket.assigns.user.id}", :logout)
  {:noreply, push_event(socket, "logout", %{})}
end

Now the rudimenary approach would be to add these two functions to every LiveView. Something should tell you that that is not the best approach. I suggest creating a live_helpers.ex module that containts those two functions. If you already have this module than you can simply add the functions to it:

defmodule AppWeb.LiveHelper do
  use AppWeb, :live_view

  def handle_event("logout", _params, socket) do
    Phoenix.PubSub.broadcast(App.PubSub, "user:#{socket.assigns.user.id}", :logout)
    {:noreply, socket}
  end

  def handle_info(:logout, socket) do
    Phoenix.PubSub.broadcast(App.PubSub, "user:#{socket.assigns.user.id}", :logout)
    {:noreply, push_event(socket, "logout", %{})}
  end
end

We now need to instruct every LiveView to redirect handle_event and handle_info functions to that helper module in case the LiveView has no matching functions:

I am using topic_live/index.ex as an example here:

defmodule AppWeb.TopicLive.Index do
  use AppWeb, :live_view

  alias App.Content
  alias App.Content.Topic

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :topics, Content.list_topics())}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  # .. all your other handle_event functions go here

  # All other events will be redirected and resolved by the LiveHelper
  def handle_event(event, params, socket) do
    AppWeb.LiveHelper.handle_event(event, params, socket)
  end

  # ... all your other handle_info functions go here

  # All other info handlers will be redirected and resolved by the LiveHelper
  def handle_info(message, socket) do
    AppWeb.LiveHelper.handle_info(message, socket)
  end

  # ... other functions
end

Finally we want our logout function in user_auth.ex to inform other sockets of the logout:

@doc """
Logs the user out.

It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
  user_token = get_session(conn, :user_token)
  user_token && Accounts.delete_user_session_token(user_token)

  if live_socket_id = get_session(conn, :live_socket_id) do
    # add the following 3 lines:
    if conn.assigns.current_user do
      AppWeb.Endpoint.broadcast("user:#{conn.assigns.current_user.id}", "logout", %{})
    end
    AppWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
  end

  conn
  |> renew_session()
  |> delete_resp_cookie(@remember_me_cookie)
  |> redirect(to: ~p"/")
end

If you added the redirects to every LiveView you should now be able to open two browser tabs to your website (use anonymous mode for one, or an alternative browser).

  1. Login to both tabs
  2. Logout in one tab
  3. the other tab should also logout

Congratulations, you have made it through this assignment!

Copyright © 2025 Alexander Fuchsberger, Bucknell University. All rights reserved.