Add access control to apps (#1715)

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2023-02-18 01:16:42 +01:00 committed by GitHub
parent 76fec4d162
commit 4334e8a58e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 603 additions and 104 deletions

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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",

View file

@ -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();
}

22
assets/js/lib/app.js Normal file
View file

@ -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;
}

View file

@ -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": {

View file

@ -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

View file

@ -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 """

View file

@ -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

View file

@ -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}})

View file

@ -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

View file

@ -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

View file

@ -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"""
<div class="h-screen flex items-center justify-center" id="app-auth" phx-hook="AppAuth">
<div class="flex flex-col space-y-4 items-center">
<a href={Routes.path(@socket, "/")}>
<img
src={Routes.static_path(@socket, "/images/logo.png")}
height="128"
width="128"
alt="livebook"
/>
</a>
<div class="text-2xl text-gray-800">
This app is password-protected
</div>
<div class="max-w-2xl text-center text-gray-700">
<span>Type the app password to access it or</span>
<a
class="border-b border-gray-700 hover:border-none"
href={
Routes.auth_path(@socket, :index, redirect_to: Routes.app_path(@socket, :page, @slug))
}
>login into Livebook</a>.
</div>
<div class="text-2xl text-gray-800 w-full pt-2">
<form class="flex flex-col space-y-4 items-center" phx-submit="authenticate">
<div
phx-feedback-for="password"
class={"w-[20ch] #{if(@errors != [], do: "show-errors")}"}
>
<input
type="password"
name="password"
class="input"
value={@password}
placeholder="Password"
autofocus
/>
<%= for error <- @errors do %>
<span class="mt-1 hidden text-red-600 text-sm phx-form-error:block">
<%= translate_error(error) %>
</span>
<% end %>
</div>
<button type="submit" class="button-base button-blue">
Authenticate
</button>
</form>
</div>
</div>
</div>
"""
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

View file

@ -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"""
<div class="grow overflow-y-auto relative" data-el-notebook>
<div
@ -107,6 +111,20 @@ defmodule LivebookWeb.AppLive do
"""
end
def render(assigns) do
~H"""
<div class="flex justify-center items-center h-screen w-screen">
<img
src={Routes.static_path(@socket, "/images/logo.png")}
height="128"
width="128"
alt="livebook"
class="animate-pulse"
/>
</div>
"""
end
defp get_page_title(notebook_name) do
"Livebook - #{notebook_name}"
end

View file

@ -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

View file

@ -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

View file

@ -49,7 +49,7 @@ defmodule LivebookWeb.Hub.New.EnterpriseComponent do
<.input_wrapper form={f} field={:token} class="flex flex-col space-y-1">
<div class="input-label">Token</div>
<%= 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",

View file

@ -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

View file

@ -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

View file

@ -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
<span class="text-gray-700 tooltip top" data-tooltip={@tooltip}><%= @label %></span>
<% end %>
<label class={"switch-button #{if(@disabled, do: "switch-button--disabled")}"}>
<input type="hidden" value="false" name={@name} />
<input type="hidden" value={@unchecked_value} name={@name} />
<input
type="checkbox"
value="true"
value={@checked_value}
class={"switch-button__checkbox #{@class}"}
name={@name}
checked={@checked}

View file

@ -209,9 +209,9 @@ defmodule LivebookWeb.SessionLive do
apps={@data_view.apps}
/>
</div>
</div>
<div data-el-runtime-info>
<.runtime_info data_view={@data_view} session={@session} socket={@socket} />
<div data-el-runtime-info>
<.runtime_info data_view={@data_view} session={@session} socket={@socket} />
</div>
</div>
<div class="grow overflow-y-auto relative" data-el-notebook>
<div data-el-js-view-iframes phx-update="ignore" id="js-view-iframes"></div>

View file

@ -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">
<div class="input-label">Slug</div>
<%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %>
</.input_wrapper>
<div class="flex flex-col space-y-4">
<.input_wrapper form={f} field={:slug} class="flex flex-col space-y-1">
<div class="input-label">Slug</div>
<%= text_input(f, :slug, class: "input", spellcheck: "false", phx_debounce: "blur") %>
</.input_wrapper>
<.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"
/>
</.input_wrapper>
<%= 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"
) %>
</.with_password_toggle>
</.input_wrapper>
<% end %>
</div>
<div class="mt-5 flex space-x-2">
<button class="button-base button-blue" type="submit" disabled={not @changeset.valid?}>
Deploy
</button>
<button
class="button-base button-outlined-gray bg-transparent"
type="reset"
name="reset"
>
Reset
</button>
</div>
</.form>
<% 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)

View file

@ -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))

View file

@ -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]

View file

@ -26,14 +26,14 @@
<input type="hidden" value={Phoenix.Controller.get_csrf_token()} name="_csrf_token" />
<div
phx-feedback-for={@auth_mode}
class={if(@errors, do: "show-errors w-[20ch]", else: "w-[20ch]")}
class={"w-[20ch] #{if(@errors != [], do: "show-errors")}"}
>
<%= if @auth_mode == :password do %>
<input type="password" name="password" class="input" placeholder="Password" autofocus />
<% else %>
<input type="text" name="token" class="input" placeholder="Token" autofocus />
<% end %>
<%= for error <- @errors || [] do %>
<%= for error <- @errors do %>
<span class="mt-1 hidden text-red-600 text-sm phx-form-error:block">
<%= translate_error(error) %>
</span>

View file

@ -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"},

View file

@ -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

View file

@ -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