ZTA revamp

* Rename SessionIdentity to PassThrough and make it part of ZTA

* Compute the ID at the Plug level, rather than ZTA level and
  avoid storing it twice

* Stop the user "avatar" from flashing on initial render

* Do not duplicate identity data inside user data, rather keep
  them distinct
This commit is contained in:
José Valim 2024-04-13 10:29:22 +02:00
parent 50f9bb2420
commit 29c5cb1904
21 changed files with 132 additions and 86 deletions

View file

@ -1,20 +1,17 @@
# Authentication
## Introduction
Livebook has three levels of authentication:
Livebook's authentication covers all pages for creating, writing, and managing notebooks.
* Instance authentication: this authenticates the user on all routes of your Livebook instance, including deployed notebooks and the admin section. This is done via Zero Trust Authentication and typically used when deploying Livebook to production. See the "Deployment" section on the sidebar for more information.
Livebook's default authentication method is token authentication. A token is automatically generated at startup and printed to the logs.
* Admin authentication: this authenticates access to Livebook admin interface, where users can create, write, and manage notebooks. Both password and token authentication are provided.
* Deployed notebook authentication: additionally, when deploying notebooks as applications, each application may be password protected with a unique password. Only users authenticated as admin or with the password will be able to access them.
## Admin authentication
Livebook's default admin authentication method is token authentication. A token is automatically generated at startup and printed to the logs.
You may optionally enable password-based authentication by setting the environment variable `LIVEBOOK_PASSWORD` on startup or deployment. It must be at least 12 characters.
To disable authentication altogether, you may set the environment variable `LIVEBOOK_TOKEN_ENABLED` to `false`.
## Securing deployed notebooks
When you deploy a notebook as an application, the deployed application is not covered by Livebook's token/password authentication. In such cases, you have two options:
* You can set a password when deploying your notebook
* You can enable proxy authentication when deploying inside a cloud infrastructure.
See the "Deployment" section on the sidebar for more information

View file

@ -1,6 +1,6 @@
# 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.
Setting up Basic Authentication is a simple mechanism for protecting all routes of your Livebook instance with a single username-password combo. However, because this password is shared across all users, this authentication mechanism cannot be used to identity users and more robust authentication methods provided by Livebook should be preferred. Basic Authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins.
## How to
@ -15,7 +15,7 @@ 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.
[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported.
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).

View file

@ -1,6 +1,6 @@
# Authentication with Cloudflare
Setting up Cloudflare authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Cloudflare authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks.
Setting up Cloudflare authentication will protect all routes of your Livebook instance. It is particularly useful for adding authentication to Livebook instances with deployed notebooks. Cloudflare authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins.
Once Cloudflare is enabled, we recommend leaving the "/public" route of your instances still public. This route is used for integration with the [Livebook Badge](https://livebook.dev/badge/) and other conveniences.
@ -17,7 +17,7 @@ https://developers.cloudflare.com/cloudflare-one/.
## 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.
[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported.
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).

View file

@ -1,6 +1,6 @@
# Authentication with Google IAP
Setting up Google IAP authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Google IAP authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks.
Setting up Google IAP authentication will protect all routes of your Livebook instance. It is particularly useful for adding authentication to Livebook instances with deployed notebooks. Google IAP authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins.
Once Google IAP is enabled, we recommend leaving the "/public" route of your instances still public. This route is used for integration with the [Livebook Badge](https://livebook.dev/badge/) and other conveniences.
@ -17,7 +17,7 @@ For more details about how to find your JWT audience, see https://cloud.google.c
## 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.
[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported.
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).

View file

@ -1,6 +1,6 @@
# Authentication with Tailscale
Setting up Tailscale authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Tailscale authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks.
Setting up Tailscale authentication will protect all routes of your Livebook instance. It is particularly useful for adding authentication to Livebook instances with deployed notebooks. Tailscale authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins.
Once Tailscale is enabled, we recommend leaving the "/public" route of your instances still public. This route is used for integration with the [Livebook Badge](https://livebook.dev/badge/) and other conveniences.
@ -42,7 +42,7 @@ 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.
[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported.
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).

View file

@ -1,6 +1,6 @@
# Authentication with Teleport
Setting up Teleport authentication will protect all routes of your notebook. It is particularly useful for adding authentication to deployed notebooks. Teleport authentication is provided in addition to [Livebook's authentication](../authentication.md) for authoring notebooks.
Setting up Teleport authentication will protect all routes of your Livebook instance. It is particularly useful for adding authentication to Livebook instances with deployed notebooks. Teleport authentication occurs in addition to [Livebook's authentication](../authentication.md) for deployed notebooks and admins.
## How to
@ -17,7 +17,7 @@ on how Teleport authentication works.
## 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.
[Livebook Teams](https://livebook.dev/teams/) users can deploy notebooks with the click of a button with pre-configured Zero Trust Authentication, shared team secrets, and file storages. Both online and airgapped deployment mechanisms are supported.
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).

View file

@ -45,6 +45,8 @@ defmodule Livebook.Config do
}
]
@identity_provider_no_id [Livebook.ZTA.BasicAuth, Livebook.ZTA.PassThrough]
@identity_provider_type_to_module Map.new(@identity_providers, fn provider ->
{Atom.to_string(provider.type), provider.module}
end)
@ -296,8 +298,8 @@ defmodule Livebook.Config do
"""
@spec identity_provider_read_only?() :: boolean()
def identity_provider_read_only?() do
{type, _module, _key} = Livebook.Config.identity_provider()
Map.has_key?(identity_provider_type_to_module(), type)
{_type, module, _key} = Livebook.Config.identity_provider()
module not in @identity_provider_no_id
end
@doc """
@ -703,7 +705,7 @@ defmodule Livebook.Config do
def identity_provider!(env) do
case System.get_env(env) do
nil ->
{:session, LivebookWeb.ZTA.SessionIdentity, :unused}
{:session, Livebook.ZTA.PassThrough, :unused}
"custom:" <> module_key ->
destructure [module, key], String.split(module_key, ":", parts: 2)

View file

@ -45,7 +45,7 @@ defmodule Livebook.Users.User do
def changeset(user, attrs \\ %{}) do
user
|> cast(attrs, [:id, :name, :email, :hex_color])
|> validate_required([:id, :name, :hex_color])
|> cast(attrs, [:name, :email, :hex_color])
|> validate_required([:hex_color])
end
end

View file

@ -1,4 +1,50 @@
defmodule Livebook.ZTA do
@type name :: atom()
@typedoc """
A metadata of keys returned by zero-trust authentication provider.
The following keys are supported:
* `:id` - a string that uniquely identifies the user
* `:name` - the user name
* `:email` - the user email
* `:payload` - the provider payload
Note that none of the keys are required. The metadata returned depends
on the provider.
"""
@type metadata :: %{
optional(:id) => String.t(),
optional(:name) => String.t(),
optional(:email) => String.t(),
optional(:payload) => map()
}
@doc """
Each provider must specify a child specification for its processes.
The `:name` and `:identity_key` keys are expected.
"""
@callback child_spec(name: name(), identity_key: String.t()) :: Supervisor.child_spec()
@doc """
Authenticates against the given name.
It will return one of:
* `{non_halted_conn, nil}` - the authentication failed and you must
halt the connection and render the appropriate report
* `{halted_conn, nil}` - the authentication failed and the connection
was modified accordingly to request the credentials
* `{non_halted_conn, metadata}` - the authentication succeed and the
following metadata about the user is available
"""
@callback authenticate(name(), Plug.Conn.t(), keyword()) :: {Plug.Conn.t(), metadata() | nil}
@doc false
def init do
:ets.new(__MODULE__, [:named_table, :public, :set, read_concurrency: true])

View file

@ -1,4 +1,7 @@
defmodule Livebook.ZTA.BasicAuth do
@behaviour Livebook.ZTA
@impl true
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
@ -12,6 +15,7 @@ defmodule Livebook.ZTA.BasicAuth do
:ignore
end
@impl true
def authenticate(name, conn, _options) do
{username, password} = Livebook.ZTA.get(name)
conn = Plug.BasicAuth.basic_auth(conn, username: username, password: password)
@ -19,7 +23,7 @@ defmodule Livebook.ZTA.BasicAuth do
if conn.halted do
{conn, nil}
else
{conn, %{payload: %{}}}
{conn, %{}}
end
end
end

View file

@ -1,4 +1,6 @@
defmodule Livebook.ZTA.Cloudflare do
@behaviour Livebook.ZTA
use GenServer
require Logger
import Plug.Conn
@ -16,6 +18,7 @@ defmodule Livebook.ZTA.Cloudflare do
GenServer.start_link(__MODULE__, options, name: name)
end
@impl true
def authenticate(name, conn, _opts) do
token = get_req_header(conn, @assertion)
{identity, keys} = Livebook.ZTA.get(name)

View file

@ -1,4 +1,6 @@
defmodule Livebook.ZTA.GoogleIAP do
@behaviour Livebook.ZTA
use GenServer
require Logger
import Plug.Conn
@ -16,6 +18,7 @@ defmodule Livebook.ZTA.GoogleIAP do
GenServer.start_link(__MODULE__, options, name: name)
end
@impl true
def authenticate(name, conn, _opts) do
token = get_req_header(conn, @assertion)
{identity, keys} = Livebook.ZTA.get(name)

View file

@ -0,0 +1,13 @@
defmodule Livebook.ZTA.PassThrough do
@behaviour Livebook.ZTA
@impl true
def child_spec(_opts) do
%{id: __MODULE__, start: {Function, :identity, [:ignore]}}
end
@impl true
def authenticate(_, conn, _) do
{conn, %{}}
end
end

View file

@ -1,6 +1,8 @@
defmodule Livebook.ZTA.Tailscale do
@behaviour Livebook.ZTA
require Logger
@impl true
def child_spec(opts) do
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
end
@ -19,6 +21,7 @@ defmodule Livebook.ZTA.Tailscale do
:ignore
end
@impl true
def authenticate(name, conn, _opts) do
remote_ip = to_string(:inet_parse.ntoa(conn.remote_ip))
tailscale_address = Livebook.ZTA.get(name)

View file

@ -1,4 +1,6 @@
defmodule Livebook.ZTA.Teleport do
@behaviour Livebook.ZTA
use GenServer
require Logger
@ -21,6 +23,7 @@ defmodule Livebook.ZTA.Teleport do
GenServer.start_link(__MODULE__, options, name: name)
end
@impl true
def authenticate(name, conn, _opts) do
token = Plug.Conn.get_req_header(conn, @assertion)
jwks = Livebook.ZTA.get(name)

View file

@ -16,8 +16,6 @@ defmodule LivebookWeb.UserPlug do
import Plug.Conn
import Phoenix.Controller
alias Livebook.Users.User
@impl true
def init(opts), do: opts
@ -31,17 +29,26 @@ defmodule LivebookWeb.UserPlug do
defp ensure_user_identity(conn) do
{_type, module, _key} = Livebook.Config.identity_provider()
{conn, identity_data} = module.authenticate(LivebookWeb.ZTA, conn, [])
if identity_data do
put_session(conn, :identity_data, identity_data)
else
conn
|> put_status(:forbidden)
|> put_view(LivebookWeb.ErrorHTML)
|> render("403.html", %{status: 403})
|> halt()
cond do
identity_data ->
# Ensure we have a unique ID to identify this user/session.
id =
identity_data[:id] || get_session(conn, :identity_data)[:id] ||
Livebook.Utils.random_long_id()
put_session(conn, :identity_data, Map.put(identity_data, :id, id))
conn.halted ->
conn
true ->
conn
|> put_status(:forbidden)
|> put_view(LivebookWeb.ErrorHTML)
|> render("403.html", %{status: 403})
|> halt()
end
end
@ -51,9 +58,10 @@ defmodule LivebookWeb.UserPlug do
if Map.has_key?(conn.req_cookies, "lb_user_data") do
conn
else
identity_data = get_session(conn, :identity_data)
user_data = User.new() |> client_user_data() |> Map.merge(identity_data)
encoded = user_data |> Jason.encode!() |> Base.encode64()
encoded =
%{"name" => nil, "hex_color" => Livebook.EctoTypes.HexColor.random()}
|> Jason.encode!()
|> Base.encode64()
# We disable HttpOnly, so that it can be accessed on the client
# and set expiration to 5 years
@ -62,13 +70,6 @@ defmodule LivebookWeb.UserPlug do
end
end
defp client_user_data(user) do
user
|> Map.from_struct()
|> Map.delete(:id)
|> Map.delete(:payload)
end
# Copies user_data from cookie to session, so that it's
# accessible to LiveViews
defp mirror_user_data_in_session(conn) when conn.halted, do: conn

View file

@ -1,18 +0,0 @@
defmodule LivebookWeb.ZTA.SessionIdentity do
# This module implements the ZTA contract specific to Livebook cookies
import Plug.Conn
def authenticate(_, conn, _) do
if id = get_session(conn, :current_user_id) do
{conn, %{id: id}}
else
user_id = Livebook.Utils.random_long_id()
{put_session(conn, :current_user_id, user_id), %{id: user_id}}
end
end
def child_spec(_opts) do
%{id: __MODULE__, start: {Function, :identity, [:ignore]}}
end
end

View file

@ -39,9 +39,9 @@ defmodule Livebook.ConfigTest do
assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") == {:custom, Module, nil}
end)
with_env([TEST_IDENTITY_PROVIDER: "custom:LivebookWeb.ZTA.SessionIdentity:extra"], fn ->
with_env([TEST_IDENTITY_PROVIDER: "custom:Livebook.ZTA.PassThrough:extra"], fn ->
assert Config.identity_provider!("TEST_IDENTITY_PROVIDER") ==
{:custom, LivebookWeb.ZTA.SessionIdentity, "extra"}
{:custom, Livebook.ZTA.PassThrough, "extra"}
end)
with_env([TEST_IDENTITY_PROVIDER: "cloudflare:123"], fn ->

View file

@ -14,15 +14,6 @@ defmodule Livebook.Users.UserTest do
assert get_field(changeset, :hex_color) == "#000000"
end
test "given empty name returns an error" do
user = build(:user)
attrs = %{"name" => ""}
changeset = User.changeset(user, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "given invalid color returns an error" do
user = build(:user)
attrs = %{"hex_color" => "#invalid"}

View file

@ -21,7 +21,7 @@ defmodule Livebook.ZTA.BasicAuthTest do
conn = put_req_header(context.conn, "authorization", authorization)
start_supervised!({BasicAuth, context.options})
assert {_conn, %{payload: %{}}} = BasicAuth.authenticate(@name, conn, [])
assert {_conn, %{}} = BasicAuth.authenticate(@name, conn, [])
end
test "returns nil when the username is invalid", context do

View file

@ -13,17 +13,17 @@ defmodule LivebookWeb.UserPlugTest do
|> fetch_cookies()
|> call()
assert get_session(conn, :current_user_id) != nil
assert get_session(conn, :identity_data)[:id] != nil
end
test "keeps user id in the session if present" do
conn =
conn(:get, "/")
|> init_test_session(%{current_user_id: "valid_user_id"})
|> init_test_session(%{identity_data: %{id: "valid_user_id"}})
|> fetch_cookies()
|> call()
assert get_session(conn, :current_user_id) != nil
assert get_session(conn, :identity_data)[:id] != nil
end
test "given no user_data cookie, generates and stores new data" do
@ -34,10 +34,8 @@ defmodule LivebookWeb.UserPlugTest do
|> call()
assert %{
"email" => nil,
"hex_color" => <<_::binary>>,
"id" => <<_::binary>>,
"name" => nil
"name" => nil,
"hex_color" => <<_::binary>>
} = conn.cookies["lb_user_data"] |> Base.decode64!() |> Jason.decode!()
end