livebook/lib/livebook_web/live/hooks/app_auth_hook.ex
2024-06-14 18:59:54 +02:00

106 lines
3.8 KiB
Elixir

defmodule LivebookWeb.AppAuthHook do
import Phoenix.Component
import Phoenix.LiveView
use LivebookWeb, :verified_routes
# For apps with password, we want to store the hashed password
# (let's call it token) in the session, as we do for the main auth.
# However, the session uses cookies, which have a ~4kb size limit.
# We could use multiple cookies, however there are also limits on
# the number of cookies, and we don't want the browser to clear
# them all at some point. Additionally, accumulating cookies for
# all apps would imply larger payloads on every regular request.
#
# Since we don't have any persistence on the server side, the other
# option is to use the browser local storage and manage it through
# JavaScript. Therefore, the whole auth is built using LiveView.
# The flows are:
#
# * unauthenticated - the auth LiveView shows a regular form and
# validates the user-provided password. Once the password is
# correct, it pushes an event to the client to store the token.
# Once the token is stored it redirects to the app page.
#
# * authenticated - on dead render the app LiveView renders just
# a loading screen. On the client side, provided it's the app
# page, we read the token from local storage (if stored) and
# send it in mount connect params via the socket. Then on the
# server we use that token to authenticate.
#
# This module defines a hook that sets the following assigns:
#
# * `:app_authenticated?` - reflects the current authentication.
# For public apps (or in case the user has full access) it is
# set to `true` on both dead and live render
#
# * `:livebook_authenticated?` - if the user has full Livebook
# access
#
# * `:app_settings` - the current app settings
#
def on_mount(:default, %{"slug" => slug}, session, socket) do
livebook_authenticated? = livebook_authenticated?(session, socket)
socket = assign(socket, livebook_authenticated?: livebook_authenticated?)
case Livebook.Apps.fetch_settings(slug) do
{:ok, %{access_type: :public} = app_settings} ->
{:cont, assign(socket, app_authenticated?: true, app_settings: app_settings)}
{:ok, %{access_type: :protected} = app_settings} ->
app_authenticated? = livebook_authenticated? or has_valid_token?(socket, app_settings)
{:cont,
assign(socket, app_authenticated?: app_authenticated?, app_settings: app_settings)}
:error ->
{:halt, redirect(socket, to: ~p"/")}
end
end
# Skip auth for non-app-specific routes
def on_mount(:default, %{}, _session, socket) do
{:cont, socket}
end
defp livebook_authenticated?(session, socket) do
uri = get_connect_info(socket, :uri)
LivebookWeb.AuthPlug.authenticated?(session, uri.port)
end
defp has_valid_token?(socket, app_settings) do
connect_params = get_connect_params(socket) || %{}
if token = connect_params["app_auth_token"] do
valid_auth_token?(token, app_settings)
else
false
end
end
@doc """
Generates auth token that can be sent to the client.
"""
@spec get_auth_token(Livebook.Notebook.AppSettings.t()) :: String.t()
def get_auth_token(app_settings) do
:crypto.hash(:sha256, app_settings.password) |> Base.encode64()
end
@doc """
Checks the given token is valid.
"""
@spec valid_auth_token?(String.t(), Livebook.Notebook.AppSettings.t()) :: boolean()
def valid_auth_token?(token, app_settings) do
Plug.Crypto.secure_compare(token, get_auth_token(app_settings))
end
@doc """
Checks if the given password is valid.
"""
@spec valid_password?(String.t(), Livebook.Notebook.AppSettings.t()) :: boolean()
def valid_password?(password, app_settings) do
Plug.Crypto.secure_compare(password, app_settings.password)
end
end