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.