From a3c27fe0a8f7e829c5ec42258f94f6cb3887ade3 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Thu, 11 Apr 2024 15:57:37 -0300 Subject: [PATCH 1/8] Implement Basic Auth ZTA --- lib/livebook/config.ex | 8 ++++ lib/livebook/teams/deployment_group.ex | 2 +- lib/livebook/zta/basic_auth.ex | 35 ++++++++++++++++ lib/livebook_web/components/app_components.ex | 1 + test/livebook/zta/basic_auth_test.exs | 42 +++++++++++++++++++ 5 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 lib/livebook/zta/basic_auth.ex create mode 100644 test/livebook/zta/basic_auth_test.exs diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 4ca01f289cb..a3f48454375 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -10,6 +10,14 @@ defmodule Livebook.Config do # # IMPORTANT: this list must be in sync with Livebook Teams. @identity_providers [ + %{ + type: :basic_auth, + name: "Basic Auth", + value: "Credentials (username:password)", + module: Livebook.ZTA.BasicAuth, + placeholder: "username:password", + input: "password" + }, %{ type: :cloudflare, name: "Cloudflare", diff --git a/lib/livebook/teams/deployment_group.ex b/lib/livebook/teams/deployment_group.ex index 2758deffc8a..f39876f8825 100644 --- a/lib/livebook/teams/deployment_group.ex +++ b/lib/livebook/teams/deployment_group.ex @@ -6,7 +6,7 @@ defmodule Livebook.Teams.DeploymentGroup do alias Livebook.Teams.AgentKey # If this list is updated, it must also be mirrored on Livebook Teams Server. - @zta_providers ~w(cloudflare google_iap tailscale teleport)a + @zta_providers ~w(basic_auth cloudflare google_iap tailscale teleport)a @type t :: %__MODULE__{ id: String.t() | nil, diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex new file mode 100644 index 00000000000..57280575b8f --- /dev/null +++ b/lib/livebook/zta/basic_auth.ex @@ -0,0 +1,35 @@ +defmodule Livebook.ZTA.BasicAuth do + use GenServer + require Logger + + defstruct [:identity_key] + + def start_link(options) do + name = Keyword.fetch!(options, :name) + GenServer.start_link(__MODULE__, options, name: name) + end + + def authenticate(name, conn, _options) do + user_credentials = Plug.BasicAuth.parse_basic_auth(conn) + server_credentials = GenServer.call(name, :credentials, :infinity) + + {conn, authenticate_user(user_credentials, server_credentials)} + end + + @impl true + def init(options) do + identity_key = Keyword.fetch!(options, :identity_key) + {:ok, %__MODULE__{identity_key: identity_key}} + end + + @impl true + def handle_call(:credentials, _, state) do + {:reply, state.identity_key, state} + end + + defp authenticate_user({username, password}, {username, password}) do + %{payload: %{}, id: username} + end + + defp authenticate_user(_, _), do: nil +end diff --git a/lib/livebook_web/components/app_components.ex b/lib/livebook_web/components/app_components.ex index 7518b97c27d..89637cfda81 100644 --- a/lib/livebook_web/components/app_components.ex +++ b/lib/livebook_web/components/app_components.ex @@ -134,6 +134,7 @@ defmodule LivebookWeb.AppComponents do <.text_field :if={zta_metadata = zta_metadata(@form[:zta_provider].value)} field={@form[:zta_key]} + type={Map.get(zta_metadata, :input, "text")} label={zta_metadata.value} placeholder={zta_placeholder(zta_metadata)} phx-debounce diff --git a/test/livebook/zta/basic_auth_test.exs b/test/livebook/zta/basic_auth_test.exs new file mode 100644 index 00000000000..fdc28ff4783 --- /dev/null +++ b/test/livebook/zta/basic_auth_test.exs @@ -0,0 +1,42 @@ +defmodule Livebook.ZTA.BasicAuthTest do + use ExUnit.Case, async: true + use Plug.Test + + alias Livebook.ZTA.BasicAuth + + import Plug.BasicAuth, only: [encode_basic_auth: 2] + + @name Context.Test.BasicAuth + + setup do + username = "ChonkierCat" + password = Livebook.Utils.random_long_id() + options = [name: @name, identity_key: {username, password}] + + {:ok, username: username, password: password, options: options, conn: conn(:get, "/")} + end + + test "returns the user_identity when credentials are valid", context do + authorization = encode_basic_auth(context.username, context.password) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, %{id: "ChonkierCat", payload: %{}}} = BasicAuth.authenticate(@name, conn, []) + end + + test "returns nil when the username is invalid", context do + authorization = encode_basic_auth("foo", context.password) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, nil} = BasicAuth.authenticate(@name, conn, []) + end + + test "returns nil when the password is invalid", context do + authorization = encode_basic_auth(context.username, Livebook.Utils.random_long_id()) + conn = put_req_header(context.conn, "authorization", authorization) + start_supervised!({BasicAuth, context.options}) + + assert {_conn, nil} = BasicAuth.authenticate(@name, conn, []) + end +end From a5a3a1a1f497918b69c774b2715bf23fad95e625 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 12 Apr 2024 09:14:26 -0300 Subject: [PATCH 2/8] Apply review comments --- lib/livebook/zta/basic_auth.ex | 34 +++++++++++---------------- test/livebook/zta/basic_auth_test.exs | 2 +- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex index 57280575b8f..18c17abc33e 100644 --- a/lib/livebook/zta/basic_auth.ex +++ b/lib/livebook/zta/basic_auth.ex @@ -1,34 +1,28 @@ defmodule Livebook.ZTA.BasicAuth do - use GenServer - require Logger - - defstruct [:identity_key] + def child_spec(opts) do + %{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}} + end def start_link(options) do name = Keyword.fetch!(options, :name) - GenServer.start_link(__MODULE__, options, name: name) + identity_key = Keyword.fetch!(options, :identity_key) + + Livebook.ZTA.put(name, identity_key) + :ignore end def authenticate(name, conn, _options) do user_credentials = Plug.BasicAuth.parse_basic_auth(conn) - server_credentials = GenServer.call(name, :credentials, :infinity) - - {conn, authenticate_user(user_credentials, server_credentials)} - end - - @impl true - def init(options) do - identity_key = Keyword.fetch!(options, :identity_key) - {:ok, %__MODULE__{identity_key: identity_key}} - end + app_credentials = Livebook.ZTA.get(name) - @impl true - def handle_call(:credentials, _, state) do - {:reply, state.identity_key, state} + {conn, authenticate_user(user_credentials, app_credentials)} end - defp authenticate_user({username, password}, {username, password}) do - %{payload: %{}, id: username} + defp authenticate_user({username, password}, {app_username, app_password}) do + if Plug.Crypto.secure_compare(username, app_username) and + Plug.Crypto.secure_compare(password, app_password) do + %{payload: %{}} + end end defp authenticate_user(_, _), do: nil diff --git a/test/livebook/zta/basic_auth_test.exs b/test/livebook/zta/basic_auth_test.exs index fdc28ff4783..3fbd38a1c61 100644 --- a/test/livebook/zta/basic_auth_test.exs +++ b/test/livebook/zta/basic_auth_test.exs @@ -21,7 +21,7 @@ defmodule Livebook.ZTA.BasicAuthTest do conn = put_req_header(context.conn, "authorization", authorization) start_supervised!({BasicAuth, context.options}) - assert {_conn, %{id: "ChonkierCat", payload: %{}}} = BasicAuth.authenticate(@name, conn, []) + assert {_conn, %{payload: %{}}} = BasicAuth.authenticate(@name, conn, []) end test "returns nil when the username is invalid", context do From 6a6bc056b3fc500d3b2e51217830311e10a3cc56 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 12 Apr 2024 09:58:37 -0300 Subject: [PATCH 3/8] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2807a4948d3..b6bb7f1caaa 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ The following environment variables can be used to configure Livebook on boot: Livebook inside a cloud platform, such as Cloudflare and Google. Supported values are: + * "basic_auth:username:password" * "cloudflare:" * "google_iap:" * "tailscale:" From 867eced3b4db17a8ddaf648f200e51697e1d1721 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 12 Apr 2024 13:31:30 -0300 Subject: [PATCH 4/8] Apply review comments --- README.md | 2 +- lib/livebook/zta/basic_auth.ex | 3 ++- test/livebook/config_test.exs | 5 +++++ test/livebook/zta/basic_auth_test.exs | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b6bb7f1caaa..010f2bc033e 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ The following environment variables can be used to configure Livebook on boot: Livebook inside a cloud platform, such as Cloudflare and Google. Supported values are: - * "basic_auth:username:password" + * "basic_auth::" * "cloudflare:" * "google_iap:" * "tailscale:" diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex index 18c17abc33e..04d9a459816 100644 --- a/lib/livebook/zta/basic_auth.ex +++ b/lib/livebook/zta/basic_auth.ex @@ -6,8 +6,9 @@ defmodule Livebook.ZTA.BasicAuth do def start_link(options) do name = Keyword.fetch!(options, :name) identity_key = Keyword.fetch!(options, :identity_key) + [username, password] = String.split(identity_key, ":", parts: 2) - Livebook.ZTA.put(name, identity_key) + Livebook.ZTA.put(name, {username, password}) :ignore end diff --git a/test/livebook/config_test.exs b/test/livebook/config_test.exs index 5287c83b61d..ec65c007a48 100644 --- a/test/livebook/config_test.exs +++ b/test/livebook/config_test.exs @@ -48,6 +48,11 @@ defmodule Livebook.ConfigTest do assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == {:zta, Livebook.ZTA.Cloudflare, "123"} end) + + with_env([TEST_IDENTITY_PROVIDER: "basic_auth:user:pass"], fn -> + assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == + {:zta, Livebook.ZTA.BasicAuth, "user:pass"} + end) end end diff --git a/test/livebook/zta/basic_auth_test.exs b/test/livebook/zta/basic_auth_test.exs index 3fbd38a1c61..cc12526ceb4 100644 --- a/test/livebook/zta/basic_auth_test.exs +++ b/test/livebook/zta/basic_auth_test.exs @@ -11,7 +11,7 @@ defmodule Livebook.ZTA.BasicAuthTest do setup do username = "ChonkierCat" password = Livebook.Utils.random_long_id() - options = [name: @name, identity_key: {username, password}] + options = [name: @name, identity_key: "#{username}:#{password}"] {:ok, username: username, password: password, options: options, conn: conn(:get, "/")} end From 15379b3fa4c17340a210b9f36ab98032d0d9cec0 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 12 Apr 2024 21:17:50 -0300 Subject: [PATCH 5/8] Add Basic Auth docs --- docs/deployment/basic_auth.md | 22 ++++++++++++++++++++++ mix.exs | 1 + 2 files changed, 23 insertions(+) create mode 100644 docs/deployment/basic_auth.md diff --git a/docs/deployment/basic_auth.md b/docs/deployment/basic_auth.md new file mode 100644 index 00000000000..ce878fb7ae2 --- /dev/null +++ b/docs/deployment/basic_auth.md @@ -0,0 +1,22 @@ +# Authentication with Basic Auth + +Setting up Basic Authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Basic Authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks. + +## How to + +To integrate Basic Authentication with Livebook, set the `LIVEBOOK_IDENTITY_PROVIDER` environment variable to `basic_auth::`. + +To do it, run: + +```bash +LIVEBOOK_IDENTITY_PROVIDER=basic_auth:user:pass \ +livebook server +``` + +## Livebook Teams + +[Livebook Teams](https://livebook.dev/teams/) users have access to airgapped notebook deployment via Docker, with pre-configured Zero Trust Authentication, shared team secrets, and file storages. + +Furthermore, if you are deploying multi-session apps via [Livebook Teams](https://livebook.dev/teams/), you can programmatically access data from the authenticated user by calling [`Kino.Hub.app_info/0`](https://hexdocs.pm/kino/Kino.Hub.html#app_info/0). + +To get started, open up Livebook, click "Add Organization" on the sidebar, and visit the "Airgapped Deployment" section of your organization. diff --git a/mix.exs b/mix.exs index 0431252239c..06a69c4420a 100644 --- a/mix.exs +++ b/mix.exs @@ -219,6 +219,7 @@ defmodule Livebook.MixProject do {"README.md", title: "Welcome to Livebook"}, "docs/authentication.md", "docs/deployment/docker.md", + "docs/deployment/basic_auth.md", "docs/deployment/cloudflare.md", "docs/deployment/google_iap.md", "docs/deployment/tailscale.md", From 304c3d6f68da70d7006a9c9ef150ae4e83ef20a4 Mon Sep 17 00:00:00 2001 From: Alexandre de Souza Date: Fri, 12 Apr 2024 21:18:15 -0300 Subject: [PATCH 6/8] Fix Basic Auth browser implementation --- lib/livebook/zta/basic_auth.ex | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex index 04d9a459816..afa91c101b7 100644 --- a/lib/livebook/zta/basic_auth.ex +++ b/lib/livebook/zta/basic_auth.ex @@ -13,18 +13,9 @@ defmodule Livebook.ZTA.BasicAuth do end def authenticate(name, conn, _options) do - user_credentials = Plug.BasicAuth.parse_basic_auth(conn) - app_credentials = Livebook.ZTA.get(name) + {username, password} = Livebook.ZTA.get(name) + conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password) - {conn, authenticate_user(user_credentials, app_credentials)} + {conn, %{id: nil, payload: %{}}} end - - defp authenticate_user({username, password}, {app_username, app_password}) do - if Plug.Crypto.secure_compare(username, app_username) and - Plug.Crypto.secure_compare(password, app_password) do - %{payload: %{}} - end - end - - defp authenticate_user(_, _), do: nil end From 24f1424c938804beb2df9ed1cd6ea8d85587b5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 13 Apr 2024 09:01:58 +0200 Subject: [PATCH 7/8] Update lib/livebook/zta/basic_auth.ex --- lib/livebook/zta/basic_auth.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex index afa91c101b7..fc547e6111f 100644 --- a/lib/livebook/zta/basic_auth.ex +++ b/lib/livebook/zta/basic_auth.ex @@ -16,6 +16,10 @@ defmodule Livebook.ZTA.BasicAuth do {username, password} = Livebook.ZTA.get(name) conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password) - {conn, %{id: nil, payload: %{}}} + if conn.halted do + {conn, nil} + else + {conn, %{payload: %{}} + end end end From 55f8fb4fbe93d5682d5b6f9c911a761a71fb73c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Sat, 13 Apr 2024 09:04:24 +0200 Subject: [PATCH 8/8] Update lib/livebook/zta/basic_auth.ex --- lib/livebook/zta/basic_auth.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/livebook/zta/basic_auth.ex b/lib/livebook/zta/basic_auth.ex index fc547e6111f..fcbbac6bbf6 100644 --- a/lib/livebook/zta/basic_auth.ex +++ b/lib/livebook/zta/basic_auth.ex @@ -19,7 +19,7 @@ defmodule Livebook.ZTA.BasicAuth do if conn.halted do {conn, nil} else - {conn, %{payload: %{}} + {conn, %{payload: %{}}} end end end