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 byCSRF 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 beDELETE
(orPOST
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
app.js
:
1. Define a JavaScript Hook in 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).
- Login to both tabs
- Logout in one tab
- the other tab should also logout
Congratulations, you have made it through this assignment!