Skip to content

Commit

Permalink
Implement the logout button (#2906)
Browse files Browse the repository at this point in the history
  • Loading branch information
aleDsz authored Jan 8, 2025
1 parent 117ef00 commit 601e93a
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 11 deletions.
17 changes: 14 additions & 3 deletions lib/livebook/config.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
defmodule Livebook.Config do
alias Livebook.FileSystem

@type authentication_mode :: :token | :password | :disabled

@type authentication ::
%{mode: :password, secret: String.t()}
| %{mode: :token, secret: String.t()}
Expand Down Expand Up @@ -68,7 +66,7 @@ defmodule Livebook.Config do
@doc """
Returns the authentication configuration.
"""
@spec authentication() :: authentication_mode()
@spec authentication() :: authentication()
def authentication() do
case Application.fetch_env!(:livebook, :authentication) do
{:password, password} -> %{mode: :password, secret: password}
Expand Down Expand Up @@ -270,6 +268,19 @@ defmodule Livebook.Config do
module not in @identity_provider_no_id
end

@doc """
Returns if the identity provider supports logout.
"""
@spec logout_enabled?() :: boolean()
def logout_enabled?() do
{_type, module, _key} = Livebook.Config.identity_provider()

identity_logout? =
Code.ensure_loaded?(module) and function_exported?(module, :logout, 2)

authentication().mode != :disabled or identity_logout?
end

@doc """
Returns whether the application is running inside an iframe.
"""
Expand Down
9 changes: 9 additions & 0 deletions lib/livebook/teams/requests.ex
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,15 @@ defmodule Livebook.Teams.Requests do
get("/api/v1/org/identity", %{access_token: access_token}, team)
end

@doc """
Send a request to Livebook Team API to revoke session from given access token.
"""
@spec logout_identity_provider(Team.t(), String.t()) ::
{:ok, String.t()} | {:error, map()} | {:transport_error, String.t()}
def logout_identity_provider(team, access_token) do
post("/api/v1/org/identity/revoke", %{access_token: access_token}, team)
end

@doc """
Normalizes errors map into errors for the given schema.
"""
Expand Down
2 changes: 1 addition & 1 deletion lib/livebook/zta/basic_auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ defmodule Livebook.ZTA.BasicAuth do
end

@impl true
def authenticate(name, conn, _options) do
def authenticate(name, conn, _opts) do
{username, password} = Livebook.ZTA.get(name)
conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password)

Expand Down
11 changes: 11 additions & 0 deletions lib/livebook/zta/livebook_teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ defmodule Livebook.ZTA.LivebookTeams do
end
end

# Our extension to Livebook.ZTA to deal with logouts
def logout(name, %{assigns: %{current_user: %{payload: %{"access_token" => token}}}}) do
team = Livebook.ZTA.get(name)

case Teams.Requests.logout_identity_provider(team, token) do
{:ok, _no_content} -> :ok
{:error, %{}} -> {:error, "You are already logged out."}
{:transport_error, reason} -> {:error, reason}
end
end

defp handle_request(conn, team, %{"teams_identity" => _, "code" => code}) do
with {:ok, access_token} <- retrieve_access_token(team, code),
{:ok, metadata} <- get_user_info(team, access_token) do
Expand Down
2 changes: 1 addition & 1 deletion lib/livebook/zta/pass_through.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Livebook.ZTA.PassThrough do
end

@impl true
def authenticate(_, conn, _) do
def authenticate(_name, conn, _opts) do
{conn, %{}}
end
end
28 changes: 23 additions & 5 deletions lib/livebook_web/components/layout_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,39 @@ defmodule LivebookWeb.LayoutComponents do
to={~p"/settings"}
current={@current_page}
/>
<button
:if={Livebook.Config.shutdown_callback()}
class="h-7 flex items-center text-gray-400 hover:text-white border-l-4 border-transparent hover:border-white"
aria-label="shutdown"
phx-click="shutdown"
>
<.remix_icon
icon="shut-down-line"
class="text-lg leading-6 w-[56px] flex justify-center"
/>
<span class="text-sm font-medium">
Shut Down
</span>
</button>
</div>
<.hub_section hubs={@saved_hubs} current_page={@current_page} />
</div>
<div class="flex flex-col">
<button
:if={Livebook.Config.shutdown_callback()}
:if={Livebook.Config.logout_enabled?()}
class="h-7 flex items-center text-gray-400 hover:text-white border-l-4 border-transparent hover:border-white"
aria-label="shutdown"
phx-click="shutdown"
aria-label="logout"
phx-click="logout"
>
<.remix_icon icon="shut-down-line" class="text-lg leading-6 w-[56px] flex justify-center" />
<.remix_icon
icon="logout-box-line"
class="text-lg leading-6 w-[56px] flex justify-center"
/>
<span class="text-sm font-medium">
Shut Down
Logout
</span>
</button>
<button
class="mt-6 flex items-center group border-l-4 border-transparent"
aria_label="user profile"
Expand Down
11 changes: 11 additions & 0 deletions lib/livebook_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ defmodule LivebookWeb.AuthController do
end
end

def logout(conn, _params) do
if get_session(conn, :user_id) do
conn
|> configure_session(renew: true)
|> clear_session()
|> render("logout.html")
else
redirect_to(conn)
end
end

defp render_form_error(conn, authentication_mode) do
errors = [{"%{authentication_mode} is invalid", [authentication_mode: authentication_mode]}]

Expand Down
18 changes: 18 additions & 0 deletions lib/livebook_web/controllers/auth_html/logout.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<div class="h-screen w-full px-4 py-8 bg-gray-900 flex justify-center items-center">
<div class="max-w-[400px] w-full flex flex-col">
<a href={~p"/"} class="mb-2 -ml-2">
<img src={~p"/images/logo.png"} height="96" width="96" alt="livebook" />
</a>
<div class="mb-2 text-xl text-gray-100 font-medium">
You have been logged out
</div>

<div class="mb-8 text-sm text-gray-200">
Thank you for using <strong>Livebook</strong>
</div>

<div class="text-gray-50 w-full">
<.button navigate={~p"/"}>Sign in back</.button>
</div>
</div>
</div>
27 changes: 27 additions & 0 deletions lib/livebook_web/live/hooks/sidebar_hook.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
defmodule LivebookWeb.SidebarHook do
use LivebookWeb, :verified_routes
require Logger

import Phoenix.Component
Expand All @@ -17,6 +18,8 @@ defmodule LivebookWeb.SidebarHook do
|> attach_hook(:hubs, :handle_info, &handle_info/2)
|> attach_hook(:shutdown, :handle_info, &handle_info/2)
|> attach_hook(:shutdown, :handle_event, &handle_event/3)
|> attach_hook(:logout, :handle_info, &handle_info/2)
|> attach_hook(:logout, :handle_event, &handle_event/3)

{:cont, socket}
end
Expand All @@ -25,6 +28,15 @@ defmodule LivebookWeb.SidebarHook do
{:halt, put_flash(socket, :info, "Livebook is shutting down. You can close this page.")}
end

defp handle_info(:logout, socket) do
{_type, module, _key} = Livebook.Config.identity_provider()

case module.logout(LivebookWeb.ZTA, socket) do
:ok -> {:halt, redirect(socket, to: ~p"/logout")}
{:error, reason} -> {:cont, put_flash(socket, :error, reason)}
end
end

@connection_events ~w(hub_connected hub_changed hub_deleted)a

defp handle_info(event, socket) when elem(event, 0) in @connection_events do
Expand Down Expand Up @@ -59,5 +71,20 @@ defmodule LivebookWeb.SidebarHook do
)}
end

defp handle_event("logout", _params, socket) do
on_confirm = fn socket ->
Phoenix.PubSub.broadcast(Livebook.PubSub, "sidebar", :logout)
put_flash(socket, :info, "Livebook is logging out. You will be redirected soon.")
end

{:halt,
confirm(socket, on_confirm,
title: "Log out",
description: "Are you sure you want to log out Livebook now?",
confirm_text: "Log out",
confirm_icon: "logout-box-line"
)}
end

defp handle_event(_event, _params, socket), do: {:cont, socket}
end
4 changes: 3 additions & 1 deletion lib/livebook_web/plugs/user_plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ defmodule LivebookWeb.UserPlug do
we get possibly updated `user_data` from `connect_params`.
"""
def build_current_user(session, user_data_override \\ nil) do
identity_data = Map.new(session["identity_data"], fn {k, v} -> {Atom.to_string(k), v} end)
identity_data =
Map.new(session["identity_data"] || %{}, fn {k, v} -> {Atom.to_string(k), v} end)

attrs = user_data_override || session["user_data"] || %{}

attrs =
Expand Down
5 changes: 5 additions & 0 deletions lib/livebook_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ defmodule LivebookWeb.Router do
post "/", AuthController, :authenticate
end

scope "/", LivebookWeb do
pipe_through [:browser]
get "/logout", AuthController, :logout
end

defp within_iframe_secure_headers(conn, _opts) do
if Livebook.Config.within_iframe?() do
delete_resp_header(conn, "x-frame-options")
Expand Down
54 changes: 54 additions & 0 deletions test/livebook_teams/zta/livebook_teams_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,58 @@ defmodule Livebook.ZTA.LivebookTeamsTest do
"Failed to authenticate with Livebook Teams: you do not belong to this org"
end
end

describe "logout/2" do
test "revoke access token from Livebook Teams", %{conn: conn, node: node, test: test} do
# Step 1: Get redirected to Livebook Teams
conn = init_test_session(conn, %{})
{conn, nil} = LivebookTeams.authenticate(test, conn, [])

[_, location] = Regex.run(~r/URL\("(.*?)"\)/, html_response(conn, 200))
uri = URI.parse(location)
assert uri.path == "/identity/authorize"
assert %{"code" => code} = URI.decode_query(uri.query)

erpc_call(node, :allow_auth_request, [code])

# Step 2: Emulate the redirect back with the code for validation
conn =
build_conn(:get, "/", %{teams_identity: "", code: code})
|> init_test_session(%{})

assert {conn, %{id: _id, name: _, email: _, payload: %{"access_token" => _}} = metadata} =
LivebookTeams.authenticate(test, conn, [])

assert redirected_to(conn, 302) == "/"

# Step 3: Confirm the token/metadata is valid for future requests
conn =
build_conn(:get, "/")
|> init_test_session(%{identity_data: metadata})

assert {%{halted: false}, ^metadata} = LivebookTeams.authenticate(test, conn, [])

# Step 4: Revoke the token and the metadata will be invalid for future requests
user =
metadata.id
|> Livebook.Users.User.new()
|> Livebook.Users.User.changeset(metadata)
|> Ecto.Changeset.apply_changes()

conn =
build_conn(:get, "/")
|> init_test_session(%{identity_data: metadata})
|> assign(:current_user, user)

assert LivebookTeams.logout(test, conn) == :ok

# Step 5: If we try to revoke again, it should fail
assert {:error, _} = LivebookTeams.logout(test, conn)

# Step 6: It we try to authenticate again, it should redirect to Teams
{conn, nil} = LivebookTeams.authenticate(test, conn, [])
assert conn.halted
assert html_response(conn, 200) =~ "window.location.href = "
end
end
end

0 comments on commit 601e93a

Please sign in to comment.