User authentication across multiple tabs with LiveView

I’ve been reading about session management on Phoenix LiveView for the last couple of days and can’t seem to find a solution for this problem.

My app is basically entirely built with live views, almost all redirects are live redirects. Most views can be accessed whether you are logged in or not, similar to an e-commerce app. I have a basic authentication system generated by the mix phx.gen.auth. The login is a post HTTP request (so we can set the session cookie).

When a user logs in, ideally, the other tabs that are opened in the same browser would notice that and act accordingly. Another option would be to update the session state of those tabs once the user does an action, e.g. click on a link and navigate to another view. The problem is that everything is a live redirect so the other tabs don’t update the session state unless the user refreshes the page, which is not a great user experience.

Does anyone have any idea on how to solve this?

I’m wondering if it would be possible to force a full page reload on the other tabs when logging in.

1 Like

You’ve implemented phx.gen.auth so you’re probably aware of the LiveView disconnect when the user logs out. That forces the tabs to reload.

If you come up with a way to identify all the tabs which belongs to the same guest user (ip?, browser fingerprint?, setting an identifier in the session at first page load and using it afterwards [like a fake user id]?) and set the live_socket_id accordingly, you can also disconnect all the “guest” LiveViews to force a page reload that will fetch the newly signed in user.

4 Likes

My naive first approach would be something like:

  1. At any new tab check if there’s a token (preferably tamper proof) in local storage and send it to the server, if not, ask for one from the server with an unique identifiable payload (maybe an UUID) and save it in the local storage, in this case only the first page load will actually create a token.

  2. Using on_mount use attach_hook/4 to handle the pushed event with the uuid to subscribe to a “guest#{uuid}” channel and the incoming handle_info/2 messages from pubsub.

  3. When the user successfully log in you can broadcast a message to the guest#{uuid} channel that will be handled by the handle_info/2 callback of all liveviews subscribed to that channel.

Security considerations aside (short lived tokens, single use, yadda, yadda, yadda) I think someone could build it in a couple hours.

A bit more javascript than I like to write though…

2 Likes

Ok, did a proof of concept.

Global hook at app.html.heex

<main id="main" phx-hook="MainHook" class="px-4 py-20 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

The hook

Hooks.MainHook = {
    mounted() {
        let token = localStorage.getItem("uuid")
        if(token == null) {
            // here you should push an event to the server and fetch a
            // tamper proof token and save it to localStorage and
            // all other security considerations
            token = "1234"
        }
        this.pushEvent("subscribe_to_channel", {uuid: token})
    }
}

The module attaching the server hooks

defmodule ExampleWeb.MultipleTabs do
  import Phoenix.LiveView

  def on_mount(:default, _params, _session, socket) do
    {:cont,
     socket
     |> attach_hook(
       "subscribe_to_channel",
       :handle_event,
       &subscribe_to_channel/3
     )
     |> attach_hook(:handle_reload, :handle_info, &reload_page/2)}
  end

  defp reload_page(_msg, socket) do
    # Here you'll need to figure out a way to get the url
    # to redirect to the correct place
    {:cont, socket |> redirect(to: "/")}
  end

  defp subscribe_to_channel("subscribe_to_channel", %{"uuid" => uuid}, socket) do
    # Here you should validate the token before subscribing
    Phoenix.PubSub.subscribe(Example.PubSub, "reload##{uuid}")
    {:cont, socket}
  end
end

And then just put it in a live session in the router

    live_session :default, on_mount: ExampleWeb.MultipleTabs do
      live "/", HomeLive
    end

Then you can call Phoenix.PubSub.broadcast(Example.PubSub, "reload#1234", []) from anywhere and all pages will redirect to /.

Security considerations aside it is pretty simple.

6 Likes

wow, that was awesome. Thank you both @shamanime @thomas.fortes for your help. The proof of concept was incredibly helpful.

It’s working as expected but I had to change the subscribe_to_channel function to return {:halt, socket} instead of :cont.

  defp subscribe_to_channel("subscribe_to_channel", %{"uuid" => uuid}, socket) do
    Phoenix.PubSub.subscribe(Swayze.PubSub, "reload##{uuid}")
    {:halt, socket}
  end

I’m not sure why but with :cont I was getting an error:

[debug] HANDLE EVENT "subscribe_to_channel" in AppWeb.HomeLive
  Parameters: %{"uuid" => "1234"}
"running subscribe_to_channel"
function AppWeb.HomeLive.handle_event/3 is undefined or private
AppWeb.HomeLive.handle_event("subscribe_to_channel", %{"uuid" => "1234"}, #Phoenix.LiveView.Socket...)

I noticed that the subscribe_to_channel was being executed and the error would happen after that so I tried the :halt and it worked but, to be honest, I’m not 100% sure why. In your example, Is the event being sent to HomeLive after being processed by the subscribe_to_channel function?

1 Like

Had the same error, not in my computer right now but if I recall correctly I used an empty handle event in my liveview (just a catch all handle event that does nothing and just returns {:noreply, socket})

If all your tabs are in the same session, could you not store your channel-id in the session?
Then there is no need for localstorage.
If the tabs are in different sessions, I would not want them to automagically login to this user.

2 Likes

AFAIK, the session is stored in a cookie by default and live views have no access to it, unless you make an HTTP request, read the session information you want, and then store it somehow in the LiveView.

In my case, I’m not doing any HTTP requests (unless of course, the user refreshes the page) so I can’t really use session information to update the live view states.

To get to your live view initially you have to do a GET request, there in mount/3 you have access to the session. So each tab would have access to the session and use a “token” stored in it to connect to you channel that will them when there was a login.

not quite, because the other tabs already have the liveview mounted, they were opened before the user logged in, so no HTTP requests will be made from that point on.

But when you opened that tab a GET request was issued to your app.

right, but at that time, the session didn’t exist, the user was not logged in.

The user is not logged in but phoenix creates a session anyway.
When someone logs in you add their user_id to the session.

2 Likes

oh, I see what you mean. I’m gonna give it a try. thank you

1 Like

Thanks, everyone. I ended up with a solution that is a mix of your suggestions.

Summary:

  • I’m adding a guest token to the browser session/cookie (there is a session cookie even when the user is not logged in) when the first tab is opened. When the user logs in, the guest token is cleared from the session.
  • In the on_mount of every LiveView we check if such token exists, if it does, we subscribe to reload##{guest_token}.
  • When a LiveView receives that message we send a force redirect to the socket.

I’m using the same solution to fix the issue I mentioned here where other tabs would see the “We can’t find the internet. Attempting to reconnect” message when logging out.

Although forcing a page reload is an okay solution, for now, I would like to have more control over sessions in LiveView so I could refresh the page state without a page reload. I think that’s not a solved issue at the moment. I understand why we need a cookie-based session for the Plug requests (and initiating the WebSockets connections) but I’m thinking of also having a localStorage-based session (with all the security you can have) to allow the live sessions to be more dynamic.

Implementation Details:

In the fetch_current_user method that is run in the :browser pipeline I add a “guest session token” to the cookie.

defmodule AppWeb.UserAuth do
  ...
  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)

    conn
    |> assign(:current_user, user)
    |> maybe_add_guest_session_token()  <--- Added this
  end

  defp maybe_add_guest_session_token(conn) do
    if conn.assigns.current_user || get_session(conn, :guest_session_token) do
      conn
    else
      put_session(conn, :guest_session_token, Ecto.UUID.generate())
    end
  end
end

The default live session runs the on_mount of a new module:

live_session :default, on_mount: AppWeb.RealTimeSessions do

The RealTimeSessions module looks like this:

defmodule SwayzeWeb.RealTimeSessions do
  @moduledoc false

  import Phoenix.LiveView

  def on_mount(:default, _params, session, socket) do
    if connected?(socket) do
      session |> maybe_subscribe_to_guest_session() |> maybe_subscribe_to_logged_in_session()

      socket =
        socket
        |> attach_hook(
          :save_request_path,
          :handle_params,
          &save_request_path/3
        )
        |> attach_hook(:handle_reload, :handle_info, &reload_page/2)

      {:cont, socket}
    else
      {:cont, socket}
    end
  end

  defp reload_page(_msg, socket) do
    if socket.assigns[:trigger_submit] do
      {:cont, socket}
    else
      {:cont, socket |> redirect(to: socket.assigns.current_uri)}
    end
  end

  defp save_request_path(_params, uri, socket) do
    {:cont, Phoenix.Component.assign(socket, :current_uri, URI.parse(uri) |> Map.get(:path))}
  end

  defp maybe_subscribe_to_guest_session(session) do
    guest_session_token = Map.get(session, "guest_session_token")

    if guest_session_token do
      Phoenix.PubSub.subscribe(Swayze.PubSub, "reload##{guest_session_token}")
    end

    session
  end

  defp maybe_subscribe_to_logged_in_session(session) do
    logged_in_session_token = Map.get(session, "logged_in_session_token")

    if logged_in_session_token do
      Phoenix.PubSub.subscribe(Swayze.PubSub, "reload##{logged_in_session_token}")
    end

    session
  end
end

The force reload caused some issues with the login LiveView specs where the liveview used in the spec would be killed because it was receiving the reload message and forcing a redirect on the socket. This doesn’t really happen in production or even development because we have a full page reload on login (which is when we broadcast that message), but it happened in the specs. For now, I’m checking if socket.assigns[:trigger_submit] is assigned and aborting the force reload. I’m pretty sure that’s a bad idea but I didn’t find a better solution just yet.

1 Like