A few questions on assigns and assign_new()

Hello,

I am working on a web app with Phoenix + Liveview.

I have an authenticated live_session in my router.ex

live_session :dashboard,
  on_mount: [MyappWeb.RouteInfo, MyappWeb.UserLiveAuth, MyappWeb.LiveAdminSidebar] do
    scope "/dashboard", MyappWeb do
      pipe_through [:browser, :require_authenticated_user]

      live "/", WelcomeLive.Index, :index

      scope "/settings", SettingsLive do
        live "/account", Account, :edit
        live "/account/confirm_email/:token", Account, :confirm_email
      end

      scope "/users", UserLive do
        live "/", Index, :index
        live "/new", Index, :new
        live "/:id/edit", Index, :edit

        live "/:id", Show, :show
        live "/:id/show/edit", Show, :edit
      end

      scope "/roles", RoleLive do
        live "/", Index, :index
        live "/new", Index, :new
        live "/:id/edit", Index, :edit

        live "/:id", Show, :show
        live "/:id/show/edit", Show, :edit
      end
    end
  end

I use the on_mount to handle authentication and authorization as described in this doc page: Security considerations of the LiveView model

Here is my UserLiveAuth on_mount function

def on_mount(:default, _params, %{"user_token" => user_token} = _session, socket) do
    socket = socket
    |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end)
    |> assign_new(:user_return_to, fn -> &Routes.welcome_index_path(&1, :index) end)
    |> assign_current_user_access_for_routes()

    cond do
      !is_user_logged_in(socket) ->
        {
          :halt,
          socket
          |> put_flash(:error, "You must log in to access this page.")
          |> redirect(to: Routes.user_session_path(socket, :new))
        }

      !does_user_have_required_capability_for_current_view(socket) ->
        {
          :halt,
          socket
          |> put_flash(:error, "Your current role does not allow you to access this page.")
          |> push_redirect(to: socket.assigns.user_return_to.(socket))
        }

      true ->
        {
          :cont,
          assign(socket, :user_return_to, socket.assigns.current_route_info.menu_item.path)
        }
    end
  end

Here is what I’m trying to achieve.
When the authenticated user tries to go to a page he is not allowed to visit (2nd cond), I want to redirect him to the :user_return_to which would be the last valid page he was on.
So I tried to store the current page on the :cont case (3rd cond), but I realised that the assigns are not persisted after a live_redirect.

Is it a normal behaviour?

If so, I am not sure of the purpose of assign_new over assign.
Here is what is explained in the security considerations of the LiveView model

[assign_new] is a convenience to avoid fetching the current_user multiple times across LiveViews

In my case current_user is fetched everytime I navigate to a new LiveView of the same live session (using live_redirect links).

Is assign_new only useful when the socket connection is established?

How could I do to store the :user_return_to across different LiveViews? Use localStorage?

Thanks in advance for your help.

@hourram Welcome to elixir community.

assign_new(socket_or_assigns, key, fun)
Assigns the given key with value from fun into socket_or_assigns if one does not yet exist.

assign_new/3 assigns only when the key does not exists in socket_or_assigns.

assign/3 just assigns the value every time it is called.

What it means is - assign_new/3 is used so :current_user key is not assigned every time on_mount is called. It is assigned only if :current_user key does not exist in socket.

2 Likes

assign_new won’t run the function if the key already exists. So, for example, if you need to fetch the current user from the DB, if you use assign_new it will query the DB only once and store the value. Even when different live views are mounted it will not query the DB.

1 Like

Thanks for the answer.
Then I don’t know why my assigns are reset when changing LiveView.
For exemple, this line will always query the DB for session token because assigns are always empty.

|> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end)

If I put IO.inspect(socket.assigns) at the top of my UserLiveAuth on_mount, there is no :current_user or :user_return_to.

This part of the security considerations doc might clear up the confusion

If you perform user authentication and confirmation on every HTTP request via Plugs, such as this:

plug :ensure_user_authenticated
plug :ensure_user_confirmed

And from live_redirect/2

The current LiveView will be shut down and a new one will be mounted in its place, without reloading the whole page. This can also be used to remount the same LiveView, in case you want to start fresh. If you want to navigate to the same LiveView without remounting it, use live_patch/2 instead.

If you are already setting the user through a plug on first page load, then assign_new in the on_mount will ensure that you don’t hit the database again. But when you live_redirect to another live view, the framework kills the previous live view’s process and starts a new one without hitting the plug. In that case the assign will be empty, and assign_new has to fetch the user again because the plug isn’t being run.

I think this part of the security consideration doc is misleading and probably needs clarification.

We use assign_new/3. This is a convenience to avoid fetching the current_user multiple times across LiveViews.

4 Likes

It’s all clear.
Thanks for the help.

I don’t think the assigns from conn struct are transferred to the socket assigns right? So I think the function in assign_new will be run in the on_mount hook.

The conn.current_user assign is passed in socket.private.assign_new to on_mount.

My browser pipeline calls plug :fetch_current_user from phx.gen.auth’s UserAuth, which doesassign(conn, :current_user, user).

Try the following and look at your logs when refreshing a live page, and then when clicking a live_redirect link that goes to the same live_session on the page:

defmodule MyAppWeb.UserLiveAuth do
  @moduledoc """
  on_mount hook to authenticate and assign user from session["user_token"]
  """
  import Phoenix.LiveView

  alias MyApp.Accounts

  def on_mount(:default, _params, %{"user_token" => user_token} = _session, socket) do
    IO.inspect(connected?(socket), label: "in UserLiveAuth socket connected?")
    IO.inspect(Map.get(socket.private, :assign_new), label: "in UserLiveAuth socket.private.assign_new")
    socket =
      assign_new(socket, :current_user, fn ->
        IO.puts("in UserLiveAuth did not find current_user")
        Accounts.get_user_by_session_token(user_token)
      end)

    {:cont, socket}
  end
end

Tried it now and yes, it does looks like the function isn’t run when :current_user is set in the conn. I had tried earlier by just logging socket.assigns and in that, current_user wasn’t set, so I assumed the function will be run. Thanks for the clarification!

1 Like

Yes, I was also confused when I tried to inspect socket.assigns first time, since the function doc says

Referencing parent assigns

When a LiveView is mounted in a disconnected state, the Plug.Conn assigns will be available for reference via assign_new/3, allowing assigns to be shared for the initial HTTP request. The Plug.Conn assigns will not be available during the connected mount. Likewise, nested LiveView children have access to their parent’s assigns on mount using assign_new/3, which allows assigns to be shared down the nested LiveView tree.

I needed to see the function code to understand it forces the assign for you if the key is in private.assign_new.