Refactor auth config (#2650)

This commit is contained in:
Jonatan Kłosko 2024-06-14 18:59:54 +02:00 committed by GitHub
parent a6bbed2440
commit 81f6744a71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 129 additions and 109 deletions

View file

@ -28,7 +28,7 @@ config :livebook,
allowed_uri_schemes: [], allowed_uri_schemes: [],
app_service_name: nil, app_service_name: nil,
app_service_url: nil, app_service_url: nil,
authentication_mode: :token, authentication: :token,
aws_credentials: false, aws_credentials: false,
epmdless: false, epmdless: false,
feature_flags: [], feature_flags: [],

View file

@ -74,7 +74,7 @@ config :phoenix,
plug_init_mode: :runtime plug_init_mode: :runtime
config :livebook, config :livebook,
authentication_mode: :disabled, authentication: :disabled,
data_path: Path.expand("tmp/livebook_data/dev") data_path: Path.expand("tmp/livebook_data/dev")
config :livebook, :feature_flags, deployment_groups: true config :livebook, :feature_flags, deployment_groups: true

View file

@ -9,9 +9,9 @@ config :livebook, LivebookWeb.Endpoint,
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warning
# Disable authentication mode during test # Disable authentication in tests
config :livebook, config :livebook,
authentication_mode: :disabled, authentication: :disabled,
check_completion_data_interval: 300, check_completion_data_interval: 300,
iframe_port: 4003 iframe_port: 4003

View file

@ -114,15 +114,15 @@ defmodule Livebook do
config :livebook, LivebookWeb.Endpoint, url: [path: base_url_path] config :livebook, LivebookWeb.Endpoint, url: [path: base_url_path]
end end
cond do if password = Livebook.Config.password!("LIVEBOOK_PASSWORD") do
password = Livebook.Config.password!("LIVEBOOK_PASSWORD") -> config :livebook, :authentication, {:password, password}
config :livebook, authentication_mode: :password, password: password else
case Livebook.Config.boolean!("LIVEBOOK_TOKEN_ENABLED", nil) do
Livebook.Config.boolean!("LIVEBOOK_TOKEN_ENABLED", true) -> true -> config :livebook, :authentication, :token
config :livebook, token: Livebook.Utils.random_long_id() false -> config :livebook, :authentication, :disabled
# Keep the environment-specific default
true -> nil -> :ok
config :livebook, authentication_mode: :disabled end
end end
if port = Livebook.Config.port!("LIVEBOOK_IFRAME_PORT") do if port = Livebook.Config.port!("LIVEBOOK_IFRAME_PORT") do

View file

@ -1,7 +1,12 @@
defmodule Livebook.Config do defmodule Livebook.Config do
alias Livebook.FileSystem alias Livebook.FileSystem
@type auth_mode() :: :token | :password | :disabled @type authentication_mode :: :token | :password | :disabled
@type authentication ::
%{mode: :password, secret: String.t()}
| %{mode: :token, secret: String.t()}
| %{mode: :disabled}
# Those are the public identity providers. # Those are the public identity providers.
# #
@ -90,11 +95,27 @@ defmodule Livebook.Config do
end end
@doc """ @doc """
Returns the authentication mode. Returns the authentication configuration.
""" """
@spec auth_mode() :: auth_mode() @spec authentication() :: authentication_mode()
def auth_mode() do def authentication() do
Application.fetch_env!(:livebook, :authentication_mode) case Application.fetch_env!(:livebook, :authentication) do
{:password, password} -> %{mode: :password, secret: password}
:token -> %{mode: :token, secret: auth_token()}
:disabled -> %{mode: :disabled}
end
end
@auth_token_key {__MODULE__, :auth_token}
defp auth_token() do
if token = :persistent_term.get(@auth_token_key, nil) do
token
else
token = Livebook.Utils.random_long_id()
:persistent_term.put(@auth_token_key, token)
token
end
end end
@doc """ @doc """
@ -561,7 +582,7 @@ defmodule Livebook.Config do
end end
@doc """ @doc """
Parses token auth setting from env. Parses boolean setting from env.
""" """
def boolean!(env, default \\ false) do def boolean!(env, default \\ false) do
case System.get_env(env) do case System.get_env(env) do

View file

@ -6,9 +6,7 @@ defmodule LivebookWeb.AuthController do
alias LivebookWeb.AuthPlug alias LivebookWeb.AuthPlug
defp require_unauthenticated(conn, _opts) do defp require_unauthenticated(conn, _opts) do
auth_mode = Livebook.Config.auth_mode() if AuthPlug.authenticated?(conn) do
if auth_mode not in [:password, :token] or AuthPlug.authenticated?(conn, auth_mode) do
redirect_to(conn) redirect_to(conn)
else else
conn conn
@ -24,14 +22,14 @@ defmodule LivebookWeb.AuthController do
def index(conn, _params) do def index(conn, _params) do
render(conn, "index.html", render(conn, "index.html",
errors: [], errors: [],
auth_mode: Livebook.Config.auth_mode() authentication_mode: Livebook.Config.authentication().mode
) )
end end
def authenticate(conn, %{"password" => password}) do def authenticate(conn, %{"password" => password}) do
conn = AuthPlug.store(conn, :password, password) conn = AuthPlug.store(conn, :password, password)
if AuthPlug.authenticated?(conn, :password) do if AuthPlug.authenticated?(conn) do
redirect_to(conn) redirect_to(conn)
else else
render_form_error(conn, :password) render_form_error(conn, :password)
@ -41,19 +39,19 @@ defmodule LivebookWeb.AuthController do
def authenticate(conn, %{"token" => token}) do def authenticate(conn, %{"token" => token}) do
conn = AuthPlug.store(conn, :token, token) conn = AuthPlug.store(conn, :token, token)
if AuthPlug.authenticated?(conn, :token) do if AuthPlug.authenticated?(conn) do
redirect_to(conn) redirect_to(conn)
else else
render_form_error(conn, :token) render_form_error(conn, :token)
end end
end end
defp render_form_error(conn, auth_mode) do defp render_form_error(conn, authentication_mode) do
errors = [{"%{auth_mode} is invalid", [auth_mode: auth_mode]}] errors = [{"%{authentication_mode} is invalid", [authentication_mode: authentication_mode]}]
render(conn, "index.html", render(conn, "index.html",
errors: errors, errors: errors,
auth_mode: auth_mode authentication_mode: authentication_mode
) )
end end

View file

@ -8,14 +8,14 @@
</div> </div>
<div class="mb-8 text-sm text-gray-200 space-y-2"> <div class="mb-8 text-sm text-gray-200 space-y-2">
<p :if={@auth_mode == :password}> <p :if={@authentication_mode == :password}>
Type password to access the Livebook. Type password to access the Livebook.
</p> </p>
<p :if={@auth_mode == :token}> <p :if={@authentication_mode == :token}>
Please check out the console for authentication URL or type the token directly Please check out the console for authentication URL or type the token directly
here. here.
</p> </p>
<p :if={@auth_mode == :token}> <p :if={@authentication_mode == :token}>
To use password authentication, set the <code>LIVEBOOK_PASSWORD</code> To use password authentication, set the <code>LIVEBOOK_PASSWORD</code>
environment variable. environment variable.
</p> </p>
@ -26,7 +26,7 @@
<input type="hidden" value={Phoenix.Controller.get_csrf_token()} name="_csrf_token" /> <input type="hidden" value={Phoenix.Controller.get_csrf_token()} name="_csrf_token" />
<div> <div>
<input <input
:if={@auth_mode == :password} :if={@authentication_mode == :password}
type="password" type="password"
name="password" name="password"
class={[ class={[
@ -41,7 +41,7 @@
autofocus autofocus
/> />
<input <input
:if={@auth_mode == :token} :if={@authentication_mode == :token}
type="text" type="text"
name="token" name="token"
class={[ class={[

View file

@ -155,11 +155,12 @@ defmodule LivebookWeb.Endpoint do
base = update_in(base.path, &(&1 || "/")) base = update_in(base.path, &(&1 || "/"))
if Livebook.Config.auth_mode() == :token do case Livebook.Config.authentication() do
token = Application.fetch_env!(:livebook, :token) %{mode: token, secret: token} ->
%{base | query: "token=" <> token} %{base | query: "token=" <> token}
else
base _ ->
base
end end
end end

View file

@ -67,7 +67,7 @@ defmodule LivebookWeb.AppAuthHook do
defp livebook_authenticated?(session, socket) do defp livebook_authenticated?(session, socket) do
uri = get_connect_info(socket, :uri) uri = get_connect_info(socket, :uri)
LivebookWeb.AuthPlug.authenticated?(session, uri.port, Livebook.Config.auth_mode()) LivebookWeb.AuthPlug.authenticated?(session, uri.port)
end end
defp has_valid_token?(socket, app_settings) do defp has_valid_token?(socket, app_settings) do

View file

@ -5,9 +5,8 @@ defmodule LivebookWeb.AuthHook do
def on_mount(:default, _params, session, socket) do def on_mount(:default, _params, session, socket) do
uri = get_connect_info(socket, :uri) uri = get_connect_info(socket, :uri)
auth_mode = Livebook.Config.auth_mode()
if LivebookWeb.AuthPlug.authenticated?(session || %{}, uri.port, auth_mode) do if LivebookWeb.AuthPlug.authenticated?(session || %{}, uri.port) do
{:cont, socket} {:cont, socket}
else else
{:halt, redirect(socket, to: ~p"/")} {:halt, redirect(socket, to: ~p"/")}

View file

@ -11,19 +11,17 @@ defmodule LivebookWeb.AuthPlug do
@impl true @impl true
def call(conn, _opts) do def call(conn, _opts) do
mode = Livebook.Config.auth_mode() if authenticated?(conn) do
if authenticated?(conn, mode) do
conn conn
else else
authenticate(conn, mode) authenticate(conn)
end end
end end
@doc """ @doc """
Stores in the session the secret for the given mode. Stores in the session the secret for the given mode.
""" """
@spec store(Plug.Conn.t(), Livebook.Config.auth_mode(), String.t()) :: Plug.Conn.t() @spec store(Plug.Conn.t(), Livebook.Config.authentication_mode(), String.t()) :: Plug.Conn.t()
def store(conn, mode, value) do def store(conn, mode, value) do
conn conn
|> put_session(key(conn.port, mode), hash(value)) |> put_session(key(conn.port, mode), hash(value))
@ -33,46 +31,50 @@ defmodule LivebookWeb.AuthPlug do
@doc """ @doc """
Checks if given connection is already authenticated. Checks if given connection is already authenticated.
""" """
@spec authenticated?(Plug.Conn.t(), Livebook.Config.auth_mode()) :: boolean() @spec authenticated?(Plug.Conn.t()) :: boolean()
def authenticated?(conn, mode) do def authenticated?(conn) do
authenticated?(get_session(conn), conn.port, mode) authenticated?(get_session(conn), conn.port)
end end
@doc """ @doc """
Checks if the given session is authenticated. Checks if the given session is authenticated.
""" """
@spec authenticated?(map(), non_neg_integer(), Livebook.Config.auth_mode()) :: boolean() @spec authenticated?(map(), non_neg_integer()) :: boolean()
def authenticated?(session, port, mode) def authenticated?(session, port) do
case Livebook.Config.authentication() do
%{mode: :disabled} ->
true
def authenticated?(_session, _port, :disabled) do %{mode: mode, secret: secret} when mode in [:token, :password] ->
true secret_hash = session[key(port, mode)]
end is_binary(secret_hash) and matches_secret?(secret_hash, secret)
def authenticated?(session, port, mode) when mode in [:token, :password] do
secret = session[key(port, mode)]
is_binary(secret) and mode == Livebook.Config.auth_mode() and
Plug.Crypto.secure_compare(secret, expected(mode))
end
defp authenticate(conn, :password) do
redirect_to_authenticate(conn)
end
defp authenticate(conn, :token) do
{token, query_params} = Map.pop(conn.query_params, "token")
if is_binary(token) and Plug.Crypto.secure_compare(hash(token), expected(:token)) do
# Redirect to the same path without query params
conn
|> store(:token, token)
|> redirect(to: path_with_query(conn.request_path, query_params))
|> halt()
else
redirect_to_authenticate(conn)
end end
end end
defp authenticate(conn) do
case Livebook.Config.authentication() do
%{mode: :password} ->
redirect_to_authenticate(conn)
%{mode: :token, secret: secret} ->
{token, query_params} = Map.pop(conn.query_params, "token")
if is_binary(token) and matches_secret?(hash(token), secret) do
# Redirect to the same path without query params
conn
|> store(:token, token)
|> redirect(to: path_with_query(conn.request_path, query_params))
|> halt()
else
redirect_to_authenticate(conn)
end
end
end
defp matches_secret?(hash, secret) do
Plug.Crypto.secure_compare(hash, hash(secret))
end
defp redirect_to_authenticate(%{path_info: []} = conn) do defp redirect_to_authenticate(%{path_info: []} = conn) do
path = path =
if Livebook.Apps.list_apps() != [] or Livebook.Apps.empty_apps_path?() do if Livebook.Apps.list_apps() != [] or Livebook.Apps.empty_apps_path?() do
@ -100,6 +102,5 @@ defmodule LivebookWeb.AuthPlug do
defp path_with_query(path, params), do: path <> "?" <> URI.encode_query(params) defp path_with_query(path, params), do: path <> "?" <> URI.encode_query(params)
defp key(port, mode), do: "#{port}:#{mode}" defp key(port, mode), do: "#{port}:#{mode}"
defp expected(mode), do: hash(Application.fetch_env!(:livebook, mode))
defp hash(value), do: :crypto.hash(:sha256, value) defp hash(value), do: :crypto.hash(:sha256, value)
end end

View file

@ -11,12 +11,10 @@ defmodule LivebookWeb.AppAuthLiveTest do
Livebook.App.close(app_pid) Livebook.App.close(app_pid)
end) end)
Application.put_env(:livebook, :authentication_mode, :password) Application.put_env(:livebook, :authentication, {:password, ctx[:livebook_password]})
Application.put_env(:livebook, :password, ctx[:livebook_password])
on_exit(fn -> on_exit(fn ->
Application.put_env(:livebook, :authentication_mode, :disabled) Application.put_env(:livebook, :authentication, :disabled)
Application.delete_env(:livebook, :password)
end) end)
%{slug: slug} %{slug: slug}

View file

@ -3,21 +3,18 @@ defmodule LivebookWeb.AuthPlugTest do
use LivebookWeb.ConnCase, async: false use LivebookWeb.ConnCase, async: false
setup context do setup context do
{type, other_type, value} = authentication =
cond do cond do
token = context[:token] -> {:token, :password, token} context[:token] -> :token
password = context[:password] -> {:password, :token, password} password = context[:password] -> {:password, password}
true -> {:disabled, :disabled, ""} true -> :disabled
end end
unless type == :disabled do unless authentication == :disabled do
Application.delete_env(:livebook, other_type) Application.put_env(:livebook, :authentication, authentication)
Application.put_env(:livebook, :authentication_mode, type)
Application.put_env(:livebook, type, value)
on_exit(fn -> on_exit(fn ->
Application.put_env(:livebook, :authentication_mode, :disabled) Application.put_env(:livebook, :authentication, :disabled)
Application.delete_env(:livebook, type)
end) end)
end end
@ -32,29 +29,29 @@ defmodule LivebookWeb.AuthPlugTest do
assert conn.resp_body =~ "New notebook" assert conn.resp_body =~ "New notebook"
end end
@tag token: "grumpycat" @tag :token
test "redirects to '/authenticate' if not authenticated", %{conn: conn} do test "redirects to /authenticate if not authenticated", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert redirected_to(conn) == ~p"/authenticate" assert redirected_to(conn) == ~p"/authenticate"
end end
@tag token: "grumpycat" @tag :token
test "redirects to the same path when valid token is provided in query params", %{conn: conn} do test "redirects to the same path when valid token is provided in query params", %{conn: conn} do
conn = get(conn, ~p"/?token=grumpycat") conn = get(conn, ~p"/?token=#{auth_token()}")
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
@tag token: "grumpycat" @tag :token
test "redirects to '/authenticate' when invalid token is provided in query params", test "redirects to /authenticate when invalid token is provided in query params",
%{conn: conn} do %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/?token=invalid")
assert redirected_to(conn) == ~p"/authenticate" assert redirected_to(conn) == ~p"/authenticate"
end end
@tag token: "grumpycat" @tag :token
test "persists authentication across requests", %{conn: conn} do test "persists authentication across requests", %{conn: conn} do
conn = get(conn, ~p"/?token=grumpycat") conn = get(conn, ~p"/?token=#{auth_token()}")
assert get_session(conn, "80:token") assert get_session(conn, "80:token")
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
@ -62,19 +59,19 @@ defmodule LivebookWeb.AuthPlugTest do
assert conn.resp_body =~ "New notebook" assert conn.resp_body =~ "New notebook"
end end
@tag token: "grumpycat" @tag :token
test "redirects to referer on valid authentication", %{conn: conn} do test "redirects to referer on valid authentication", %{conn: conn} do
referer = "/import?url=example.com" referer = "/import?url=example.com"
conn = get(conn, referer) conn = get(conn, referer)
assert redirected_to(conn) == ~p"/authenticate" assert redirected_to(conn) == ~p"/authenticate"
conn = post(conn, ~p"/authenticate", token: "grumpycat") conn = post(conn, ~p"/authenticate", token: auth_token())
assert redirected_to(conn) == referer assert redirected_to(conn) == referer
end end
@tag token: "grumpycat" @tag :token
test "redirects back to '/authenticate' on invalid token", %{conn: conn} do test "redirects back to /authenticate on invalid token", %{conn: conn} do
conn = post(conn, ~p"/authenticate?token=invalid_token") conn = post(conn, ~p"/authenticate?token=invalid_token")
assert html_response(conn, 200) =~ "Authentication required" assert html_response(conn, 200) =~ "Authentication required"
@ -82,9 +79,9 @@ defmodule LivebookWeb.AuthPlugTest do
assert redirected_to(conn) == ~p"/authenticate" assert redirected_to(conn) == ~p"/authenticate"
end end
@tag token: "grumpycat" @tag :token
test "persists authentication across requests (via /authenticate)", %{conn: conn} do test "persists authentication across requests (via /authenticate)", %{conn: conn} do
conn = post(conn, ~p"/authenticate?token=grumpycat") conn = post(conn, ~p"/authenticate?token=#{auth_token()}")
assert get_session(conn, "80:token") assert get_session(conn, "80:token")
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
@ -109,7 +106,7 @@ defmodule LivebookWeb.AuthPlugTest do
end end
@tag password: "grumpycat" @tag password: "grumpycat"
test "redirects to '/authenticate' if not authenticated", %{conn: conn} do test "redirects to /authenticate if not authenticated", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert redirected_to(conn) == ~p"/authenticate" assert redirected_to(conn) == ~p"/authenticate"
end end
@ -135,7 +132,7 @@ defmodule LivebookWeb.AuthPlugTest do
end end
@tag password: "grumpycat" @tag password: "grumpycat"
test "redirects back to '/authenticate' on invalid password", %{conn: conn} do test "redirects back to /authenticate on invalid password", %{conn: conn} do
conn = post(conn, ~p"/authenticate?password=invalid_password") conn = post(conn, ~p"/authenticate?password=invalid_password")
assert html_response(conn, 200) =~ "Authentication required" assert html_response(conn, 200) =~ "Authentication required"
@ -156,4 +153,9 @@ defmodule LivebookWeb.AuthPlugTest do
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
end end
defp auth_token() do
%{mode: :token, secret: token} = Livebook.Config.authentication()
token
end
end end