mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Add access control to apps (#1715)
Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
parent
76fec4d162
commit
4334e8a58e
|
@ -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,
|
||||
|
|
15
assets/js/hooks/app_auth.js
Normal file
15
assets/js/hooks/app_auth.js
Normal 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;
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
22
assets/js/lib/app.js
Normal 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;
|
||||
}
|
4
assets/package-lock.json
generated
4
assets/package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 """
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
85
lib/livebook_web/live/app_auth_live.ex
Normal file
85
lib/livebook_web/live/app_auth_live.ex
Normal 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
|
|
@ -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
|
||||
|
|
91
lib/livebook_web/live/hooks/app_auth_hook.ex
Normal file
91
lib/livebook_web/live/hooks/app_auth_hook.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
4
mix.lock
4
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"},
|
||||
|
|
|
@ -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
|
||||
|
|
125
test/livebook_web/live/app_auth_live_test.exs
Normal file
125
test/livebook_web/live/app_auth_live_test.exs
Normal 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
|
Loading…
Reference in a new issue