diff --git a/assets/js/app.js b/assets/js/app.js index 64b078032..5695e3fa7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -14,6 +14,7 @@ import { LiveSocket } from "phoenix_live_view"; import hooks from "./hooks"; import { morphdomOptions } from "./dom"; import { loadUserData } from "./lib/user"; +import { loadAppAuthToken } from "./lib/app"; import { settingsStore } from "./lib/settings"; import { registerTopbar, registerGlobalEventHandlers } from "./events"; @@ -30,6 +31,7 @@ const liveSocket = new LiveSocket( _csrf_token: csrfToken, // Pass the most recent user data to the LiveView in `connect_params` user_data: loadUserData(), + app_auth_token: loadAppAuthToken(), }; }, hooks: hooks, diff --git a/assets/js/hooks/app_auth.js b/assets/js/hooks/app_auth.js new file mode 100644 index 000000000..dbe1296e7 --- /dev/null +++ b/assets/js/hooks/app_auth.js @@ -0,0 +1,15 @@ +import { storeAppAuthToken } from "../lib/app"; + +/** + * A hook for the app auth page. + */ +const AppAuth = { + mounted() { + this.handleEvent("persist_app_auth", ({ slug, token }) => { + storeAppAuthToken(slug, token); + this.pushEvent("app_auth_persisted"); + }); + }, +}; + +export default AppAuth; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index b53922226..859ffc492 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,3 +1,4 @@ +import AppAuth from "./app_auth"; import AudioInput from "./audio_input"; import Cell from "./cell"; import CellEditor from "./cell_editor"; @@ -21,6 +22,7 @@ import UserForm from "./user_form"; import VirtualizedLines from "./virtualized_lines"; export default { + AppAuth, AudioInput, Cell, CellEditor, diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js index abf3a6fff..96053e9d4 100644 --- a/assets/js/hooks/js_view.js +++ b/assets/js/hooks/js_view.js @@ -46,11 +46,11 @@ import { initializeIframeSource } from "./js_view/iframe"; * * `data-js-path` - a relative path for the initial view-specific * JS module * - * * `data-session-token` - token is sent in the "connect" message - * to the channel + * * `data-session-token` - a session-specific token passed when + * joining the JS view channel * - * * `data-session-id` - the identifier of the session that this - * view belongs go + * * `data-connect-token` - a JS view specific token passed in the + * "connect" message to the channel * * * `data-iframe-local-port` - the local port where the iframe is * served @@ -75,7 +75,7 @@ const JSView = { this.initTimeout = setTimeout(() => this.handleInitTimeout(), 2_000); - this.channel = getChannel(this.props.sessionId, this.props.clientId); + this.channel = getChannel(this.props.sessionToken); this.iframeActions = this.createIframe(); @@ -139,7 +139,7 @@ const JSView = { this.channel.push( "connect", { - session_token: this.props.sessionToken, + connect_token: this.props.connectToken, ref: this.props.ref, id: this.id, }, @@ -175,8 +175,7 @@ const JSView = { assetsBasePath: getAttributeOrThrow(this.el, "data-assets-base-path"), jsPath: getAttributeOrThrow(this.el, "data-js-path"), sessionToken: getAttributeOrThrow(this.el, "data-session-token"), - sessionId: getAttributeOrThrow(this.el, "data-session-id"), - clientId: getAttributeOrThrow(this.el, "data-client-id"), + connectToken: getAttributeOrThrow(this.el, "data-connect-token"), iframePort: getAttributeOrThrow( this.el, "data-iframe-local-port", diff --git a/assets/js/hooks/js_view/channel.js b/assets/js/hooks/js_view/channel.js index cb33ecff3..3f5e80d70 100644 --- a/assets/js/hooks/js_view/channel.js +++ b/assets/js/hooks/js_view/channel.js @@ -13,13 +13,10 @@ let channel = null; /** * Returns channel used for all JS views in the current session. */ -export function getChannel(sessionId, clientId) { +export function getChannel(sessionToken) { if (!channel) { socket.connect(); - channel = socket.channel("js_view", { - session_id: sessionId, - client_id: clientId, - }); + channel = socket.channel("js_view", { session_token: sessionToken }); channel.join(); } diff --git a/assets/js/lib/app.js b/assets/js/lib/app.js new file mode 100644 index 000000000..5fafd42c4 --- /dev/null +++ b/assets/js/lib/app.js @@ -0,0 +1,22 @@ +import { load, store } from "./storage"; + +const APP_AUTH_TOKEN_PREFIX = "app_auth_token:"; + +export function storeAppAuthToken(slug, token) { + store(APP_AUTH_TOKEN_PREFIX + slug, token); +} + +export function loadAppAuthToken() { + const path = window.location.pathname; + + if (path.startsWith("/apps/")) { + const slug = path.split("/")[2]; + const token = load(APP_AUTH_TOKEN_PREFIX + slug); + + if (token) { + return token; + } + } + + return null; +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 5e54b57fc..2fd712546 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -60,10 +60,10 @@ "license": "MIT" }, "../deps/phoenix_html": { - "version": "3.2.0" + "version": "3.3.0" }, "../deps/phoenix_live_view": { - "version": "0.18.3", + "version": "0.18.15", "license": "MIT" }, "node_modules/@ampproject/remapping": { diff --git a/lib/livebook/apps.ex b/lib/livebook/apps.ex index 2316e20c8..b953ab8f5 100644 --- a/lib/livebook/apps.ex +++ b/lib/livebook/apps.ex @@ -53,5 +53,20 @@ defmodule Livebook.Apps do end end + @doc """ + Looks up app session with the given slug and returns its settings. + """ + @spec fetch_settings_by_slug(String.t()) :: {:ok, Livebook.Notebook.AppSettings.t()} | :error + def fetch_settings_by_slug(slug) do + case :global.whereis_name(name(slug)) do + :undefined -> + :error + + pid -> + app_settings = Session.get_app_settings(pid) + {:ok, app_settings} + end + end + defp name(slug), do: {:app, slug} end diff --git a/lib/livebook/notebook/app_settings.ex b/lib/livebook/notebook/app_settings.ex index 0ecb92ec9..aeec2f1b8 100644 --- a/lib/livebook/notebook/app_settings.ex +++ b/lib/livebook/notebook/app_settings.ex @@ -3,15 +3,21 @@ defmodule Livebook.Notebook.AppSettings do use Ecto.Schema - import Ecto.Changeset, except: [change: 1] + import Ecto.Changeset, except: [change: 1, change: 2] @type t :: %__MODULE__{ - slug: String.t() | nil + slug: String.t() | nil, + access_type: access_type(), + password: String.t() | nil } + @type access_type :: :public | :protected + @primary_key false embedded_schema do field :slug, :string + field :access_type, Ecto.Enum, values: [:public, :protected] + field :password, :string end @doc """ @@ -19,7 +25,11 @@ defmodule Livebook.Notebook.AppSettings do """ @spec new() :: t() def new() do - %__MODULE__{slug: nil} + %__MODULE__{slug: nil, access_type: :protected, password: generate_password()} + end + + defp generate_password() do + :crypto.strong_rand_bytes(10) |> Base.encode32(case: :lower) end @doc """ @@ -27,9 +37,7 @@ defmodule Livebook.Notebook.AppSettings do """ @spec change(t(), map()) :: Ecto.Changeset.t() def change(%__MODULE__{} = settings, attrs \\ %{}) do - settings - |> changeset(attrs) - |> Map.put(:action, :validate) + changeset(settings, attrs) end @doc """ @@ -43,11 +51,25 @@ defmodule Livebook.Notebook.AppSettings do defp changeset(settings, attrs) do settings - |> cast(attrs, [:slug]) - |> validate_required([:slug]) + |> cast(attrs, [:slug, :access_type]) + |> validate_required([:slug, :access_type]) |> validate_format(:slug, ~r/^[a-zA-Z0-9-]+$/, message: "slug can only contain alphanumeric characters and dashes" ) + |> cast_access_attrs(attrs) + end + + defp cast_access_attrs(changeset, attrs) do + case get_field(changeset, :access_type) do + :protected -> + changeset + |> cast(attrs, [:password]) + |> validate_required([:password]) + |> validate_length(:password, min: 12) + + _other -> + changeset + end end @doc """ diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 1b7c2f497..eaf59d650 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -192,6 +192,14 @@ defmodule Livebook.Session do GenServer.call(pid, :get_notebook, @timeout) end + @doc """ + Returns the current app settings. + """ + @spec get_app_settings(pid()) :: Notebook.AppSettings.t() + def get_app_settings(pid) do + GenServer.call(pid, :get_app_settings, @timeout) + end + @doc """ Subscribes to session messages. @@ -827,6 +835,10 @@ defmodule Livebook.Session do {:reply, state.data.notebook, state} end + def handle_call(:get_app_settings, _from, state) do + {:reply, state.data.notebook.app_settings, state} + end + def handle_call(:save_sync, _from, state) do {:reply, :ok, maybe_save_notebook_sync(state)} end diff --git a/lib/livebook_web/channels/js_view_channel.ex b/lib/livebook_web/channels/js_view_channel.ex index 239905295..7ca6b2d6c 100644 --- a/lib/livebook_web/channels/js_view_channel.ex +++ b/lib/livebook_web/channels/js_view_channel.ex @@ -2,14 +2,25 @@ defmodule LivebookWeb.JSViewChannel do use Phoenix.Channel @impl true - def join("js_view", %{"session_id" => session_id, "client_id" => client_id}, socket) do - {:ok, assign(socket, session_id: session_id, client_id: client_id, ref_with_info: %{})} + def join("js_view", %{"session_token" => session_token}, socket) do + case Phoenix.Token.verify(LivebookWeb.Endpoint, "session", session_token) do + {:ok, data} -> + {:ok, + assign(socket, + session_id: data.session_id, + client_id: data.client_id, + ref_with_info: %{} + )} + + _error -> + {:error, %{reason: "invalid token"}} + end end @impl true - def handle_in("connect", %{"session_token" => session_token, "ref" => ref, "id" => id}, socket) do - {:ok, data} = Phoenix.Token.verify(LivebookWeb.Endpoint, "js view", session_token) - %{pid: pid} = data + def handle_in("connect", %{"connect_token" => connect_token, "ref" => ref, "id" => id}, socket) do + {:ok, %{pid: pid}} = + Phoenix.Token.verify(LivebookWeb.Endpoint, "js-view-connect", connect_token) send(pid, {:connect, self(), %{origin: socket.assigns.client_id, ref: ref}}) diff --git a/lib/livebook_web/channels/socket.ex b/lib/livebook_web/channels/socket.ex index 4c9a8a688..d39ac8bad 100644 --- a/lib/livebook_web/channels/socket.ex +++ b/lib/livebook_web/channels/socket.ex @@ -5,9 +5,10 @@ defmodule LivebookWeb.Socket do @impl true def connect(_params, socket, info) do - auth_mode = Livebook.Config.auth_mode() - - if LivebookWeb.AuthPlug.authenticated?(info.session || %{}, info.uri.port, auth_mode) do + # The session is present only if the CSRF token is valid. We rely + # on CSRF token, because we don't check connection origin as noted + # in LivebookWeb.Endpoint. + if info.session do {:ok, socket} else :error diff --git a/lib/livebook_web/controllers/auth_controller.ex b/lib/livebook_web/controllers/auth_controller.ex index e06918725..29fb8d41b 100644 --- a/lib/livebook_web/controllers/auth_controller.ex +++ b/lib/livebook_web/controllers/auth_controller.ex @@ -1,7 +1,7 @@ defmodule LivebookWeb.AuthController do use LivebookWeb, :controller - plug :require_unauthenticated + plug(:require_unauthenticated) alias LivebookWeb.AuthPlug @@ -15,8 +15,14 @@ defmodule LivebookWeb.AuthController do end end - def index(conn, params) do - render(conn, "index.html", auth_mode: Livebook.Config.auth_mode(), errors: params["errors"]) + def index(conn, %{"redirect_to" => path}) do + conn + |> put_session(:redirect_to, path) + |> redirect(to: current_path(conn, %{})) + end + + def index(conn, _params) do + render(conn, "index.html", errors: [], auth_mode: Livebook.Config.auth_mode()) end def authenticate(conn, %{"password" => password}) do @@ -40,7 +46,8 @@ defmodule LivebookWeb.AuthController do end defp render_form_error(conn, auth_mode) do - index(conn, %{"errors" => [{"%{auth_mode} is invalid", [auth_mode: auth_mode]}]}) + errors = [{"%{auth_mode} is invalid", [auth_mode: auth_mode]}] + render(conn, "index.html", errors: errors, auth_mode: auth_mode) end defp redirect_to(conn) do diff --git a/lib/livebook_web/live/app_auth_live.ex b/lib/livebook_web/live/app_auth_live.ex new file mode 100644 index 000000000..3d5508a1e --- /dev/null +++ b/lib/livebook_web/live/app_auth_live.ex @@ -0,0 +1,85 @@ +defmodule LivebookWeb.AppAuthLive do + use LivebookWeb, :live_view + + @impl true + def mount(%{"slug" => slug}, _session, socket) when not socket.assigns.app_authenticated? do + {:ok, assign(socket, slug: slug, password: "", errors: [])} + end + + def mount(%{"slug" => slug}, _session, socket) do + {:ok, push_navigate(socket, to: Routes.app_path(socket, :page, slug))} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ + livebook + +
+ This app is password-protected +
+ +
+ Type the app password to access it or + login into Livebook. +
+
+
+
+ + <%= for error <- @errors do %> + + <% end %> +
+ +
+
+
+
+ """ + end + + @impl true + def handle_event("authenticate", %{"password" => password}, socket) do + socket = + if LivebookWeb.AppAuthHook.valid_password?(password, socket.assigns.app_settings) do + token = LivebookWeb.AppAuthHook.get_auth_token(socket.assigns.app_settings) + push_event(socket, "persist_app_auth", %{"slug" => socket.assigns.slug, "token" => token}) + else + assign(socket, password: password, errors: [{"app password is invalid", []}]) + end + + {:noreply, socket} + end + + def handle_event("app_auth_persisted", %{}, socket) do + {:noreply, push_navigate(socket, to: Routes.app_path(socket, :page, socket.assigns.slug))} + end +end diff --git a/lib/livebook_web/live/app_live.ex b/lib/livebook_web/live/app_live.ex index a5e11cd72..8ac6d2e36 100644 --- a/lib/livebook_web/live/app_live.ex +++ b/lib/livebook_web/live/app_live.ex @@ -6,37 +6,41 @@ defmodule LivebookWeb.AppLive do alias Livebook.Notebook.Cell @impl true - def mount(%{"slug" => slug}, _session, socket) do - case Livebook.Apps.fetch_session_by_slug(slug) do - {:ok, %{pid: session_pid, id: session_id}} -> + def mount(%{"slug" => slug}, _session, socket) when socket.assigns.app_authenticated? do + {:ok, %{pid: session_pid, id: session_id}} = Livebook.Apps.fetch_session_by_slug(slug) + + {data, client_id} = + if connected?(socket) do {data, client_id} = - if connected?(socket) do - {data, client_id} = - Session.register_client(session_pid, self(), socket.assigns.current_user) + Session.register_client(session_pid, self(), socket.assigns.current_user) - Session.subscribe(session_id) + Session.subscribe(session_id) - {data, client_id} - else - data = Session.get_data(session_pid) - {data, nil} - end + {data, client_id} + else + data = Session.get_data(session_pid) + {data, nil} + end - session = Session.get_by_pid(session_pid) + session = Session.get_by_pid(session_pid) - {:ok, - socket - |> assign( - slug: slug, - session: session, - page_title: get_page_title(data.notebook.name), - client_id: client_id, - data_view: data_to_view(data) - ) - |> assign_private(data: data)} + {:ok, + socket + |> assign( + slug: slug, + session: session, + page_title: get_page_title(data.notebook.name), + client_id: client_id, + data_view: data_to_view(data) + ) + |> assign_private(data: data)} + end - :error -> - {:ok, redirect(socket, to: Routes.home_path(socket, :page))} + def mount(%{"slug" => slug}, _session, socket) do + if connected?(socket) do + {:ok, push_navigate(socket, to: Routes.app_auth_path(socket, :page, slug))} + else + {:ok, socket} end end @@ -49,7 +53,7 @@ defmodule LivebookWeb.AppLive do end @impl true - def render(assigns) do + def render(assigns) when assigns.app_authenticated? do ~H"""
+ livebook +
+ """ + end + defp get_page_title(notebook_name) do "Livebook - #{notebook_name}" end diff --git a/lib/livebook_web/live/hooks/app_auth_hook.ex b/lib/livebook_web/live/hooks/app_auth_hook.ex new file mode 100644 index 000000000..8aaae0757 --- /dev/null +++ b/lib/livebook_web/live/hooks/app_auth_hook.ex @@ -0,0 +1,91 @@ +defmodule LivebookWeb.AppAuthHook do + import Phoenix.Component + import Phoenix.LiveView + + alias LivebookWeb.Router.Helpers, as: 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 `:app_authenticated?` + # assign to reflect the current authentication state. For public + # apps (or in case the user has full access) it is set to `true` + # on both dead and live render. + + def on_mount(:default, %{"slug" => slug}, session, socket) do + case Livebook.Apps.fetch_settings_by_slug(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?(session, socket) or has_valid_token?(socket, app_settings) + + {:cont, + assign(socket, app_authenticated?: app_authenticated?, app_settings: app_settings)} + + :error -> + {:halt, redirect(socket, to: Routes.home_path(socket, :page))} + end + end + + defp livebook_authenticated?(session, socket) do + uri = get_connect_info(socket, :uri) + LivebookWeb.AuthPlug.authenticated?(session, uri.port, Livebook.Config.auth_mode()) + 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()) :: String.t() + 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()) :: String.t() + def valid_password?(password, app_settings) do + Plug.Crypto.secure_compare(password, app_settings.password) + end +end diff --git a/lib/livebook_web/live/hooks/auth_hook.ex b/lib/livebook_web/live/hooks/auth_hook.ex index ad7ac9070..1f50eb6da 100644 --- a/lib/livebook_web/live/hooks/auth_hook.ex +++ b/lib/livebook_web/live/hooks/auth_hook.ex @@ -1,6 +1,8 @@ defmodule LivebookWeb.AuthHook do import Phoenix.LiveView + alias LivebookWeb.Router.Helpers, as: Routes + def on_mount(:default, _params, session, socket) do uri = get_connect_info(socket, :uri) auth_mode = Livebook.Config.auth_mode() @@ -8,7 +10,7 @@ defmodule LivebookWeb.AuthHook do if LivebookWeb.AuthPlug.authenticated?(session || %{}, uri.port, auth_mode) do {:cont, socket} else - {:halt, socket} + {:halt, redirect(socket, to: Routes.home_path(socket, :page))} end end end diff --git a/lib/livebook_web/live/hub/new/enterprise_component.ex b/lib/livebook_web/live/hub/new/enterprise_component.ex index 65904f37a..cbe0c56b9 100644 --- a/lib/livebook_web/live/hub/new/enterprise_component.ex +++ b/lib/livebook_web/live/hub/new/enterprise_component.ex @@ -49,7 +49,7 @@ defmodule LivebookWeb.Hub.New.EnterpriseComponent do <.input_wrapper form={f} field={:token} class="flex flex-col space-y-1">
Token
<%= password_input(f, :token, - value: get_field(@changeset, :token), + value: input_value(f, :token), class: "input w-full phx-form-error:border-red-300", spellcheck: "false", autocomplete: "off", diff --git a/lib/livebook_web/live/hub/new/fly_component.ex b/lib/livebook_web/live/hub/new/fly_component.ex index ed0db673e..15334f51b 100644 --- a/lib/livebook_web/live/hub/new/fly_component.ex +++ b/lib/livebook_web/live/hub/new/fly_component.ex @@ -1,7 +1,7 @@ defmodule LivebookWeb.Hub.New.FlyComponent do use LivebookWeb, :live_component - import Ecto.Changeset, only: [get_field: 2, add_error: 3] + import Ecto.Changeset, only: [add_error: 3] alias Livebook.Hubs.{Fly, FlyClient} @@ -39,7 +39,7 @@ defmodule LivebookWeb.Hub.New.FlyComponent do phx_change: "fetch_data", phx_debounce: "blur", phx_target: @myself, - value: access_token(@changeset), + value: input_value(f, :access_token), class: "input w-full phx-form-error:border-red-300", autofocus: true, spellcheck: "false", @@ -141,6 +141,4 @@ defmodule LivebookWeb.Hub.New.FlyComponent do [disabled_option] ++ options end - - defp access_token(changeset), do: get_field(changeset, :access_token) end diff --git a/lib/livebook_web/live/js_view_component.ex b/lib/livebook_web/live/js_view_component.ex index 194a8bd44..37b409555 100644 --- a/lib/livebook_web/live/js_view_component.ex +++ b/lib/livebook_web/live/js_view_component.ex @@ -21,9 +21,8 @@ defmodule LivebookWeb.JSViewComponent do Routes.session_path(@socket, :show_asset, @session_id, @js_view.assets.hash, []) } data-js-path={@js_view.assets.js_path} - data-session-token={session_token(@js_view.pid)} - data-session-id={@session_id} - data-client-id={@client_id} + data-session-token={session_token(@session_id, @client_id)} + data-connect-token={connect_token(@js_view.pid)} data-iframe-local-port={LivebookWeb.IframeEndpoint.port()} data-iframe-url={Livebook.Config.iframe_url()} data-timeout-message={@timeout_message} @@ -32,7 +31,14 @@ defmodule LivebookWeb.JSViewComponent do """ end - defp session_token(pid) do - Phoenix.Token.sign(LivebookWeb.Endpoint, "js view", %{pid: pid}) + defp session_token(session_id, client_id) do + Phoenix.Token.sign(LivebookWeb.Endpoint, "session", %{ + session_id: session_id, + client_id: client_id + }) + end + + defp connect_token(pid) do + Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: pid}) end end diff --git a/lib/livebook_web/live/live_helpers.ex b/lib/livebook_web/live/live_helpers.ex index ea51a3b85..ce64c523a 100644 --- a/lib/livebook_web/live/live_helpers.ex +++ b/lib/livebook_web/live/live_helpers.ex @@ -286,10 +286,20 @@ defmodule LivebookWeb.LiveHelpers do |> assign_new(:label, fn -> nil end) |> assign_new(:tooltip, fn -> nil end) |> assign_new(:disabled, fn -> false end) + |> assign_new(:checked_value, fn -> "true" end) + |> assign_new(:unchecked_value, fn -> "false" end) |> assign_new(:class, fn -> "" end) |> assign( :attrs, - assigns_to_attributes(assigns, [:label, :name, :checked, :disabled, :class]) + assigns_to_attributes(assigns, [ + :label, + :name, + :checked, + :disabled, + :checked_value, + :unchecked_value, + :class + ]) ) ~H""" @@ -298,10 +308,10 @@ defmodule LivebookWeb.LiveHelpers do <%= @label %> <% end %>
- -
- <.runtime_info data_view={@data_view} session={@session} socket={@socket} /> +
+ <.runtime_info data_view={@data_view} session={@session} socket={@socket} /> +
diff --git a/lib/livebook_web/live/session_live/app_info_component.ex b/lib/livebook_web/live/session_live/app_info_component.ex index bba11a65c..90f3e53c6 100644 --- a/lib/livebook_web/live/session_live/app_info_component.ex +++ b/lib/livebook_web/live/session_live/app_info_component.ex @@ -1,6 +1,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do use LivebookWeb, :live_component + alias Livebook.Notebook.AppSettings alias LivebookWeb.Router.Helpers, as: Routes @impl true @@ -13,7 +14,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do changeset = case socket.assigns do %{changeset: changeset} when changeset.data == assigns.settings -> changeset - _ -> Livebook.Notebook.AppSettings.change(assigns.settings) + _ -> AppSettings.change(assigns.settings) end {:ok, @@ -67,14 +68,45 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do phx-target={@myself} autocomplete="off" > - <.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1"> -
Slug
- <%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %> - +
+ <.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1"> +
Slug
+ <%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %> + + <.input_wrapper form={f} field={:access_type} class="flex flex-col space-y-1"> + <.switch_checkbox + id={input_id(f, :access_type)} + name={input_name(f, :access_type)} + label="Password-protected" + checked={Ecto.Changeset.get_field(@changeset, :access_type) == :protected} + checked_value="protected" + unchecked_value="public" + /> + + <%= if Ecto.Changeset.get_field(@changeset, :access_type) == :protected do %> + <.input_wrapper form={f} field={:password} class="flex flex-col space-y-1"> + <.with_password_toggle id={input_id(f, :password) <> "-toggle"}> + <%= password_input(f, :password, + value: input_value(f, :password), + class: "input", + spellcheck: "false", + phx_debounce: "blur" + ) %> + + + <% end %> +
+
<% end %> @@ -194,10 +226,19 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do end @impl true - def handle_event("validate", %{"app_settings" => params}, socket) do - changeset = Livebook.Notebook.AppSettings.change(socket.assigns.settings, params) + def handle_event("validate", %{"_target" => ["reset"]}, socket) do + settings = AppSettings.new() + Livebook.Session.set_app_settings(socket.assigns.session.pid, settings) + {:noreply, assign(socket, changeset: AppSettings.change(settings))} + end - with {:ok, settings} <- Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do + def handle_event("validate", %{"app_settings" => params}, socket) do + changeset = + socket.assigns.settings + |> AppSettings.change(params) + |> Map.put(:action, :validate) + + with {:ok, settings} <- AppSettings.update(socket.assigns.settings, params) do Livebook.Session.set_app_settings(socket.assigns.session.pid, settings) end @@ -205,7 +246,7 @@ defmodule LivebookWeb.SessionLive.AppInfoComponent do end def handle_event("deploy", %{"app_settings" => params}, socket) do - case Livebook.Notebook.AppSettings.update(socket.assigns.settings, params) do + case AppSettings.update(socket.assigns.settings, params) do {:ok, settings} -> Livebook.Session.set_app_settings(socket.assigns.session.pid, settings) diff --git a/lib/livebook_web/plugs/auth_plug.ex b/lib/livebook_web/plugs/auth_plug.ex index 1e2b5911b..470dede80 100644 --- a/lib/livebook_web/plugs/auth_plug.ex +++ b/lib/livebook_web/plugs/auth_plug.ex @@ -25,6 +25,7 @@ defmodule LivebookWeb.AuthPlug do @doc """ Stores in the session the secret for the given mode. """ + @spec store(Plug.Conn.t(), Livebook.Config.auth_mode(), String.t()) :: Plug.Conn.t() def store(conn, mode, value) do conn |> put_session(key(conn.port, mode), hash(value)) diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index a0bb4f2fd..ad336bfe8 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -26,6 +26,10 @@ defmodule LivebookWeb.Router do plug LivebookWeb.UserPlug end + pipeline :user do + plug LivebookWeb.UserPlug + end + pipeline :js_view_assets do plug :put_secure_browser_headers plug :within_iframe_secure_headers @@ -86,8 +90,6 @@ defmodule LivebookWeb.Router do live "/sessions/:id/package-search", SessionLive, :package_search get "/sessions/:id/images/:image", SessionController, :show_image live "/sessions/:id/*path_parts", SessionLive, :catch_all - - live "/apps/:slug", AppLive, :page end # Public authenticated URLs that people may be directed to @@ -99,6 +101,15 @@ defmodule LivebookWeb.Router do end end + live_session :apps, on_mount: [LivebookWeb.AppAuthHook, LivebookWeb.UserHook] do + scope "/", LivebookWeb do + pipe_through [:browser, :user] + + live "/apps/:slug", AppLive, :page + live "/apps/:slug/authenticate", AppAuthLive, :page + end + end + scope "/" do pipe_through [:browser, :auth] diff --git a/lib/livebook_web/templates/auth/index.html.heex b/lib/livebook_web/templates/auth/index.html.heex index ebafb1aae..4329c4424 100644 --- a/lib/livebook_web/templates/auth/index.html.heex +++ b/lib/livebook_web/templates/auth/index.html.heex @@ -26,14 +26,14 @@
<%= if @auth_mode == :password do %> <% else %> <% end %> - <%= for error <- @errors || [] do %> + <%= for error <- @errors do %> diff --git a/mix.lock b/mix.lock index 90cb85e8f..9c12c9b68 100644 --- a/mix.lock +++ b/mix.lock @@ -18,10 +18,10 @@ "mint_web_socket": {:hex, :mint_web_socket, "1.0.2", "0933a4c82f2376e35569b2255cdce94f2e3f993c0d5b04c360460cb8beda7154", [:mix], [{:mint, ">= 1.4.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "067c5e15439be060f2ab57c468ee4ab29e39cb20b498ed990cb94f62db0efc3a"}, "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, - "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, + "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.3", "2e3d009422addf8b15c3dccc65ce53baccbe26f7cfd21d264680b5867789a9c1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c8845177a866e017dcb7083365393c8f00ab061b8b6b2bda575891079dce81b2"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.15", "58137e648fca9da56d6e931c9c3001f895ff090291052035f395bc958b82f1a5", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "888dd8ea986bebbda741acc65aef788c384d13db91fea416461b2e96aa06a193"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, diff --git a/test/livebook_web/channels/js_view_channel_test.exs b/test/livebook_web/channels/js_view_channel_test.exs index a85d1af26..c22004a45 100644 --- a/test/livebook_web/channels/js_view_channel_test.exs +++ b/test/livebook_web/channels/js_view_channel_test.exs @@ -8,15 +8,14 @@ defmodule LivebookWeb.JSViewChannelTest do LivebookWeb.Socket |> socket() |> subscribe_and_join(LivebookWeb.JSViewChannel, "js_view", %{ - "session_id" => session_id, - "client_id" => Livebook.Utils.random_id() + "session_token" => session_token(session_id, Livebook.Utils.random_id()) }) %{socket: socket} end test "loads initial data from the widget server and pushes to the client", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) assert_receive {:connect, from, %{}} send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) @@ -25,8 +24,8 @@ defmodule LivebookWeb.JSViewChannelTest do end test "loads initial data for multiple connections separately", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id2"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id2"}) assert_receive {:connect, from, %{}} send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) @@ -38,7 +37,7 @@ defmodule LivebookWeb.JSViewChannelTest do end test "sends client events to the corresponding widget server", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) assert_receive {:connect, from, %{}} send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) @@ -49,7 +48,7 @@ defmodule LivebookWeb.JSViewChannelTest do end test "sends server events to the target client", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) assert_receive {:connect, from, %{}} send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) @@ -60,7 +59,7 @@ defmodule LivebookWeb.JSViewChannelTest do describe "binary payload" do test "initial data", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) assert_receive {:connect, from, %{}} payload = {:binary, %{message: "hey"}, <<1, 2, 3>>} @@ -71,7 +70,7 @@ defmodule LivebookWeb.JSViewChannelTest do end test "form client to server", %{socket: socket} do - push(socket, "connect", %{"session_token" => session_token(), "ref" => "1", "id" => "id1"}) + push(socket, "connect", %{"connect_token" => connect_token(), "ref" => "1", "id" => "id1"}) assert_receive {:connect, from, %{}} send(from, {:connect_reply, [1, 2, 3], %{ref: "1"}}) @@ -84,7 +83,14 @@ defmodule LivebookWeb.JSViewChannelTest do end end - defp session_token() do - Phoenix.Token.sign(LivebookWeb.Endpoint, "js view", %{pid: self()}) + defp session_token(session_id, client_id) do + Phoenix.Token.sign(LivebookWeb.Endpoint, "session", %{ + session_id: session_id, + client_id: client_id + }) + end + + defp connect_token() do + Phoenix.Token.sign(LivebookWeb.Endpoint, "js-view-connect", %{pid: self()}) end end diff --git a/test/livebook_web/live/app_auth_live_test.exs b/test/livebook_web/live/app_auth_live_test.exs new file mode 100644 index 000000000..ecb40967f --- /dev/null +++ b/test/livebook_web/live/app_auth_live_test.exs @@ -0,0 +1,125 @@ +defmodule LivebookWeb.AppAuthLiveTest do + use LivebookWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + setup ctx do + {slug, session} = create_app(ctx[:app_settings] || %{}) + + on_exit(fn -> + Livebook.Session.close(session.pid) + end) + + Application.put_env(:livebook, :authentication_mode, :password) + Application.put_env(:livebook, :password, ctx[:livebook_password]) + + on_exit(fn -> + Application.put_env(:livebook, :authentication_mode, :disabled) + Application.delete_env(:livebook, :password) + end) + + %{slug: slug} + end + + defp create_app(app_settings_attrs) do + slug = Livebook.Utils.random_id() + + app_settings = + Livebook.Notebook.AppSettings.new() + |> Map.replace!(:slug, slug) + |> Map.merge(app_settings_attrs) + + notebook = %{Livebook.Notebook.new() | app_settings: app_settings} + + {:ok, session} = Livebook.Sessions.create_session(notebook: notebook, mode: :app) + Livebook.Session.app_subscribe(session.id) + Livebook.Session.app_build(session.pid) + + session_id = session.id + assert_receive {:app_registration_changed, ^session_id, true} + + {slug, session} + end + + # Integration tests for the authentication scenarios + + describe "public app" do + @describetag app_settings: %{access_type: :public} + + test "does not require authentication", %{conn: conn, slug: slug} do + {:ok, view, _} = live(conn, "/apps/#{slug}") + assert render(view) =~ "Untitled notebook" + end + end + + describe "protected app" do + @describetag livebook_password: "long_livebook_password" + @describetag app_settings: %{access_type: :protected, password: "long_app_password"} + + test "redirect to auth page when not authenticated", %{conn: conn, slug: slug} do + {:error, {:live_redirect, %{to: to}}} = live(conn, "/apps/#{slug}") + assert to == "/apps/#{slug}/authenticate" + end + + test "shows an error on invalid password", %{conn: conn, slug: slug} do + {:ok, view, _} = live(conn, "/apps/#{slug}/authenticate") + + assert view + |> element("form") + |> render_submit(%{password: "invalid password"}) =~ "app password is invalid" + end + + test "persists authentication across requests", %{conn: conn, slug: slug} do + {:ok, view, _} = live(conn, "/apps/#{slug}/authenticate") + + view + |> element("form") + |> render_submit(%{password: "long_app_password"}) + + # The token is stored on the client + + assert_push_event(view, "persist_app_auth", %{"slug" => ^slug, "token" => token}) + + assert {:error, {:live_redirect, %{to: to}}} = render_hook(view, "app_auth_persisted") + assert to == "/apps/#{slug}" + + # Then, the client passes the token in connect params + + {:ok, view, _} = + conn + |> put_connect_params(%{"app_auth_token" => token}) + |> live("/apps/#{slug}") + + assert render(view) =~ "Untitled notebook" + + # The auth page redirects to the app + + {:error, {:live_redirect, %{to: to}}} = + conn + |> put_connect_params(%{"app_auth_token" => token}) + |> live("/apps/#{slug}/authenticate") + + assert to == "/apps/#{slug}" + end + + test "redirects to the app page when authenticating in Livebook", %{conn: conn, slug: slug} do + conn = get(conn, "/authenticate?redirect_to=/apps/#{slug}") + assert redirected_to(conn) == "/authenticate" + + conn = post(conn, "/authenticate", password: "long_livebook_password") + assert redirected_to(conn) == "/apps/#{slug}" + + {:ok, view, _} = live(conn, "/apps/#{slug}") + assert render(view) =~ "Untitled notebook" + + # The auth page redirects to the app + + {:error, {:live_redirect, %{to: to}}} = live(conn, "/apps/#{slug}/authenticate") + assert to == "/apps/#{slug}" + end + end + + test "redirects to homepage when accessing non-existent app", %{conn: conn} do + assert {:error, {:redirect, %{to: "/"}}} = live(conn, "/apps/nonexistent") + end +end