简体   繁体   中英

Why is this liveview mounting again after being redirected to a non-liveview page?

I'm working in a project that is mostly a freshly generated web app from phx.new and phx.gen.auth . I have an index page that is non-liveview. After login, the user is redirected to the main page, which is a liveview.

The expectation: After clicking the generated Log out link, the user should be redirected to the / index page, which is not a liveview. This behavior is specified by the generated authentication.

The experience: The problem is that when I click the generated Log out link, instead of being redirected to the logged out index splash page, as the generated authentication is written to do, instead I'm redirected to the login page, where I see two flash messages: one :info flash indicating successful logout, and a second :error flash complaining ""You must be logged in to access this page." I don't want users to see that :error flash on the login page, and worse, I think, is the fact that the reason that :error flash is appearing is because the PageLive liveview, which is not present on the index page, is running its mount/3 function again (a third time), which is causing the liveview authentication to run again, and cause a second redirect. Importantly, this issue occurs intermittently , ie sometimes the redirect works correctly and sends the user to the index page without issue, and other times the second, redundant redirect and the mistaken flash message is displayed. I think this indicates some kind of race condition.

I have a relatively newly generated project with these routes (among others):

router.ex

  scope "/", MyappWeb do
    pipe_through :browser

    live_session :default do
      live "/dash", PageLive, :index
    end
  end

  scope "/", MyappWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]

    get "/", PageController, :index
  end

  scope "/", MyappWeb do
    pipe_through [:browser]

    delete "/users/log_out", UserSessionController, :delete
  end

The authentication was generated by phx.gen.auth . The delete action in the generated UserSessionController triggers the generated UserAuth.log_out_user/1 to fire.

user_session_controller.ex

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "Logged out successfully.")
    |> UserAuth.log_out_user()
  end

user_auth.ex

  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_session_token(user_token)

    if live_socket_id = get_session(conn, :live_socket_id) do
      MyappWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

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

The live route in the router to /dash routes through a liveview called PageLive , which simply mounts over some authentication, as recommended in liveview docs :

page_live.ex

defmodule MyappWeb.PageLive do
  use MyappWeb, :live_view
  alias MyappWeb.Live.Components.PackageSearch
  alias MyappWeb.Live.Components.Tabs
  alias MyappWeb.Live.Components.Tabs.TabItem
  on_mount MyappWeb.UserLiveAuth
end

user_live_auth.ex

defmodule MyappWeb.UserLiveAuth do
  import Phoenix.LiveView, only: [assign_new: 3, redirect: 2]
  alias Myapp.Accounts
  alias Myapp.Accounts.User
  alias MyappWeb.Router.Helpers, as: Routes

  def mount(_params, session, socket) do
    socket =
      assign_new(socket, :current_user, fn ->
        find_current_user(session)
      end)

    case socket.assigns.current_user do
      %User{} ->
        {:cont, socket}

      _ ->
        socket =
          socket
          |> put_flash(:error, "You must be logged in to access this page.")
          |> redirect(to: Routes.user_session_path(socket, :new))

        {:halt, socket}
    end
  end

  defp find_current_user(session) do
    with user_token when not is_nil(user_token) <- session["user_token"],
         %User{} = user <- Accounts.get_user_by_session_token(user_token),
         do: user
  end
end

Here's the log of the process after the user clicks log out:

**[info] POST /users/log_out**
[debug] Processing with MyappWeb.UserSessionController.delete/2
  Parameters: %{"_csrf_token" => "ET8xMSU5KSEedycKEAcJfX0JCl45LmcF_VEHANhinNqHcaz6MFRkIqWu", "_method" => "delete"}
  Pipelines: [:browser]
[debug] QUERY OK source="users_tokens" db=1.8ms idle=389.7ms
SELECT u1."id", u1."email", u1."hashed_password", u1."confirmed_at", u1."first_name", u1."last_name", u1."username", u1."inserted_at", u1."updated_at" FROM "users_tokens" AS u0 INNER JOIN "users" AS u1 ON u1."id" = u0."user_id" WHERE ((u0."token" = $1) AND (u0."context" = $2)) AND (u0."inserted_at" > $3::timestamp + (-(60)::numeric * interval '1 day')) [<<159, 144, 113, 83, 223, 12, 183, 119, 50, 248, 83, 234, 128, 237, 129, 112, 138, 147, 148, 100, 67, 163, 50, 244, 127, 26, 254, 184, 102, 74, 11, 52>>, "session", ~U[2021-10-06 22:13:44.080128Z]]
[debug] QUERY OK source="users_tokens" db=1.7ms idle=391.8ms
DELETE FROM "users_tokens" AS u0 WHERE ((u0."token" = $1) AND (u0."context" = $2)) [<<159, 144, 113, 83, 223, 12, 183, 119, 50, 248, 83, 234, 128, 237, 129, 112, 138, 147, 148, 100, 67, 163, 50, 244, 127, 26, 254, 184, 102, 74, 11, 52>>, "session"]
**[info] Sent 302 in 6ms**
**[info] CONNECTED TO Phoenix.LiveView.Socket in 64µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"_csrf_token" =>** "ET8xMSU5KSEedycKEAcJfX0JCl45LmcF_VEHANhinNqHcaz6MFRkIqWu", "_mounts" => "0", "_track_static" => %{"0" => "http://localhost:4000/assets/app.css", "1" => "http://localhost:4000/assets/app.js"}, "vsn" => "2.0.0"}
[debug] QUERY OK source="users_tokens" db=1.6ms idle=422.5ms
SELECT u1."id", u1."email", u1."hashed_password", u1."confirmed_at", u1."first_name", u1."last_name", u1."username", u1."inserted_at", u1."updated_at" FROM "users_tokens" AS u0 INNER JOIN "users" AS u1 ON u1."id" = u0."user_id" WHERE ((u0."token" = $1) AND (u0."context" = $2)) AND (u0."inserted_at" > $3::timestamp + (-(60)::numeric * interval '1 day')) [<<159, 144, 113, 83, 223, 12, 183, 119, 50, 248, 83, 234, 128, 237, 129, 112, 138, 147, 148, 100, 67, 163, 50, 244, 127, 26, 254, 184, 102, 74, 11, 52>>, "session", ~U[2021-10-06 22:13:44.110158Z]]
**[info] GET /users/log_in**
[debug] Processing with MyappWeb.UserSessionController.new/2
  Parameters: %{}
  Pipelines: [:browser, :redirect_if_user_is_authenticated]
[info] Sent 200 in 6ms

Notice how in the logs above, the 302 redirect occurs, and then immediately the socket reconnects and mount/3 runs, which then triggers another redirect, this time to the /users/log_in route. As far as I understand, the socket should not be trying to reconnect here, and I can't see what's triggering this.

Why is the PageLive mount being triggered again after the 302 redirect to a non-liveview page upon logout, thus triggering a second redirect to the login page?

They key is this code

MyappWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})

in log_out_user/1 . Here you disconnect the live view socket via an Erlang message.

This triggers the socket to shutdown server side (see Phoenix.Socket )

def __info__(%Broadcast{event: "disconnect"}, state) do
  {:stop, {:shutdown, :disconnected}, state}
end

But then it is a live view, which will reconnect after a disconnect happened via client side javascript and then remount the live view, which causes MyappWeb.UserLiveAuth to add the flash message again.

For reference, check the LiveView docs :

Once a LiveView is disconnected, the client will attempt to reestablish the connection and re-execute the mount/3 callback. In this case, if the user is no longer logged in or it no longer has access to the current resource, mount/3 will fail and the user will be redirected.

A potential solution could be, to do the flash + redirect already in a plug inside of the routing pipeline instead of the mount, so non logged in users will be redirected when loading the page, and then just {:halt, socket} in the live view mount, so there is no redirect on logout. Or alternatively send the broadcast for disconnecting after the logout request has already redirected (maybe spinning off an async Task could help).

So maybe wrap the broadcast like this, to make the browser close the liveview itself (while still redirecting all other open live views):

Task.async(fn -> 
  :timer.sleep(1000) # Give the browser some time to process the response and close the LV
  MyappWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end)

In user_auth.ex , I have these lines, which delete the user session from the db:

  def log_out_user(conn) do
    user_token = get_session(conn, :user_token)
    user_token && Accounts.delete_session_token(user_token)

The reason the problem is occurring is because the live mount is failing authentication, since the session token no longer exists in the database. This triggers a redirect before the regular UserAuth.log)out_user/1 redirect has a chance to fire.

Inspired by @smallbutton's solution, I'm using ensuring that the session token is not deleted until after the log_out_user/1 redirect to avoid the race condition:

  def log_out_user(conn) do
    if live_socket_id = get_session(conn, :live_socket_id) do
      MyAppWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
    end

    # TODO is there a better way to handle this issue?
    Task.async(fn ->
      :timer.sleep(1000)
      user_token = get_session(conn, :user_token)
      user_token && Accounts.delete_session_token(user_token)
    end)

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

This is an unsatisfying workaround though, because I don't like to have to manually coordinate events like this.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM