Unify env variables and change auth to use session (#195)

This commit is contained in:
José Valim 2021-04-15 15:50:29 +02:00 committed by GitHub
parent 3a19021983
commit 57047f9c7f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 210 additions and 218 deletions

View file

@ -2,41 +2,25 @@
Livebook is a web application for writing interactive and collaborative code notebooks. It features:
* A deployable web app built with [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view)
where users can create, fork, and run multiple notebooks.
* A deployable web app built with [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) where users can create, fork, and run multiple notebooks.
* Each notebook is made of multiple sections: each section is made of Markdown and Elixir
cells. Code in Elixir cells can be evaluated on demand. Mathematical formulas are also
supported via [KaTeX](https://katex.org/).
* Each notebook is made of multiple sections: each section is made of Markdown and Elixir cells. Code in Elixir cells can be evaluated on demand. Mathematical formulas are also supported via [KaTeX](https://katex.org/).
* Persistence: notebooks can be persisted to disk through the `.livemd` format, which is a
subset of Markdown. This means your notebooks can be saved for later, easily shared, and
they also play well with version control.
* Persistence: notebooks can be persisted to disk through the `.livemd` format, which is a subset of Markdown. This means your notebooks can be saved for later, easily shared, and they also play well with version control.
* Sequential evaluation: code cells run in a specific order, guaranteeing future users of
the same Livebook see the same output. If you re-execute a previous cell, following cells
are marked as stale to make it clear they depend on outdated notebook state.
* Sequential evaluation: code cells run in a specific order, guaranteeing future users of the same Livebook see the same output. If you re-execute a previous cell, following cells are marked as stale to make it clear they depend on outdated notebook state.
* Custom runtimes: when executing Elixir code, you can either start a fresh Elixir process,
connect to an existing node, or run it inside an existing Elixir project, with access to
all of its modules and dependencies. This means Livebook can be a great tool to provide
live documentation for existing projects.
* Custom runtimes: when executing Elixir code, you can either start a fresh Elixir process, connect to an existing node, or run it inside an existing Elixir project, with access to all of its modules and dependencies. This means Livebook can be a great tool to provide live documentation for existing projects.
* Explicit dependencies: if your notebook has dependencies, they are explicitly listed and
installed with the help of the `Mix.install/2` command in Elixir v1.12+.
* Explicit dependencies: if your notebook has dependencies, they are explicitly listed and installed with the help of the `Mix.install/2` command in Elixir v1.12+.
* Collaborative features allow multiple users to work on the same notebook at once.
Collaboration works either in single-node or multi-node deployments - without a
need for additional tooling.
* Collaborative features allow multiple users to work on the same notebook at once. Collaboration works either in single-node or multi-node deployments - without a need for additional tooling.
There is a [screencast by José Valim showing some of Livebook features](https://www.youtube.com/watch?v=RKvqc-UEe34).
Otherwise, here is a peek at the "Welcome to Livebook" introductory notebook:
There is a [screencast by José Valim showing some of Livebook features](https://www.youtube.com/watch?v=RKvqc-UEe34). Otherwise, here is a peek at the "Welcome to Livebook" introductory notebook:
![Screenshot](https://user-images.githubusercontent.com/9582/113567534-166f4980-960f-11eb-98df-c0b8b81f8a27.png)
The current version provides only the initial step of our Livebook vision. Our plan
is to continue focusing on visual, collaborative, and interactive features in the
upcoming releases.
The current version provides only the initial step of our Livebook vision. Our plan is to continue focusing on visual, collaborative, and interactive features in the upcoming releases.
## Usage
@ -49,21 +33,26 @@ For now, the best way to run Livebook is by cloning it and running it locally:
You will need [Elixir v1.11](https://elixir-lang.org/install.html) or later.
We will work on other distribution modes (escripts, Docker images, etc) once
we start distributing official releases.
We will work on other distribution modes (escripts, Docker images, etc) once we start distributing official releases.
## Security considerations
### Security considerations
Livebook is built to document and execute code. Anyone with access to a
Livebook instance will be able to access any file and execute any code
in the machine Livebook is running.
Livebook is built to document and execute code. Anyone with access to a Livebook instance will be able to access any file and execute any code in the machine Livebook is running.
For this reason, `Livebook` only binds to the 127.0.0.1, allowing access
to happen only within the current machine. When running `Livebook` in the
production environment - the recommended environment - we also generate a
token on initialization and we only allow access to the Livebook if said
token is supplied as part of the URL.
For this reason, `Livebook` only binds to the 127.0.0.1, allowing access to happen only within the current machine. When running `Livebook` in the production environment - the recommended environment - we also generate a token on initialization and we only allow access to the Livebook if said token is supplied as part of the URL.
### Environment variables
<!-- Environment variables -->
The following environment variables configure Livebook:
* LIVEBOOK_PASSWORD - sets a password that must be used to access Livebook. Must be at least 12 characters. Defaults to token authentication.
* LIVEBOOK_PORT - sets the port Livebook runs on. If you want multiple instances to run on the same domain but different ports, you also need to set `LIVEBOOK_SECRET_KEY_BASE`. Defaults to 8080.
* LIVEBOOK_SECRET_KEY_BASE - sets a secret key that is used to sign and encrypt the session and other payloads used by Livebook. Must be at least 64 characters long and it can be generated by commands such as: `openssl rand -base64 48`. Defaults to a random secret on every boot.
<!-- Environment variables -->
## License
Copyright (C) 2021 Dashbit

View file

@ -3,7 +3,6 @@ import Config
# Configures the endpoint
config :livebook, LivebookWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "9hHHeOiAA8wrivUfuS//jQMurHxoMYUtF788BQMx2KO7mYUE8rVrGGG09djBNQq7",
pubsub_server: Livebook.PubSub,
live_view: [signing_salt: "livebook"]
@ -15,7 +14,8 @@ config :logger, :console,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
config :livebook, :token_authentication, true
# Sets the default authentication mode to token
config :livebook, :authentication_mode, :token
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.

View file

@ -66,4 +66,5 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :livebook, authentication_mode: :disabled
# Disable authentication mode during dev
config :livebook, :authentication_mode, :disabled

View file

@ -1,15 +1,10 @@
import Config
# For production, don't forget to configure the url host
# to something meaningful, Phoenix uses this information
# when generating URLs.
# Default bind and port for production
config :livebook, LivebookWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 8080]
# The output is shown to the end user,
# so limit the amount of information we show.
config :logger, level: :info
config :livebook, authentication_mode: :token
# Start log-level in notice by default to reduce output
config :logger, level: :notice
# ## SSL Support
#

View file

@ -1,25 +1,17 @@
import Config
require Logger
# Configure the type of names used for distribution and the node name.
# By default a random short name is used.
# config :livebook, :node, {:shortnames, "livebook"}
# config :livebook, :node, {:longnames, :"livebook@127.0.0.1"}
config :livebook, LivebookWeb.Endpoint,
secret_key_base:
Livebook.Config.secret!("LIVEBOOK_SECRET_KEY_BASE") ||
Base.encode64(:crypto.strong_rand_bytes(48))
if password = System.get_env("LIVEBOOK_PASSWORD") do
config :livebook,
authentication_mode: :password,
password: password
if password = Livebook.Config.password!("LIVEBOOK_PASSWORD") do
config :livebook, authentication_mode: :password, password: password
else
config :livebook, token: Livebook.Utils.random_id()
end
if config_env() == :prod do
# In order to persist sessions between deployments (desirable when using password authentication mode)
# allow to customize secret_key_base. Otherwise the secret will change every time app starts.
secret_key_base =
if secret = System.get_env("SECRET_KEY_BASE") do
secret
else
:crypto.strong_rand_bytes(48) |> Base.encode64()
end
config :livebook, LivebookWeb.Endpoint, secret_key_base: secret_key_base
if port = Livebook.Config.port!("LIVEBOOK_PORT") do
config :livebook, LivebookWeb.Endpoint, http: [port: port]
end

View file

@ -9,6 +9,7 @@ config :livebook, LivebookWeb.Endpoint,
# Print only warnings and errors during test
config :logger, level: :warn
# Disable authentication mode during test
config :livebook, :authentication_mode, :disabled
# Use longnames when running tests in CI, so that no host resolution is required,

View file

@ -7,7 +7,6 @@ defmodule Livebook.Application do
def start(_type, _args) do
ensure_distribution!()
initialize_token()
children = [
# Start the Telemetry supervisor
@ -44,7 +43,7 @@ defmodule Livebook.Application do
:ok
_ ->
abort!("""
Livebook.Config.abort!("""
could not start epmd (Erlang Port Mapper Driver). Livebook uses epmd to \
talk to different runtimes. You may have to start epmd explicitly by calling:
@ -61,17 +60,15 @@ defmodule Livebook.Application do
{type, name} = get_node_type_and_name()
case Node.start(name, type) do
{:ok, _} -> :ok
{:error, reason} -> abort!("could not start distributed node: #{inspect(reason)}")
{:ok, _} ->
:ok
{:error, reason} ->
Livebook.Config.abort!("could not start distributed node: #{inspect(reason)}")
end
end
end
defp abort!(message) do
IO.puts("\nERROR!!! [Livebook] " <> message)
System.halt(1)
end
defp get_node_type_and_name() do
Application.get_env(:livebook, :node) || {:shortnames, random_short_name()}
end
@ -80,19 +77,9 @@ defmodule Livebook.Application do
:"livebook_#{Livebook.Utils.random_short_id()}"
end
# Generates and configures random token if token auth is enabled
defp initialize_token() do
token_auth? = Application.fetch_env!(:livebook, :token_authentication)
if token_auth? do
token = Livebook.Utils.random_id()
Application.put_env(:livebook, :token, token)
end
end
defp display_startup_info() do
if Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint) do
IO.ANSI.format([:blue, "Livebook running at #{access_url()}"]) |> IO.puts()
IO.puts("[Livebook] Application running at #{access_url()}")
end
end

View file

@ -1,6 +1,8 @@
defmodule Livebook.Config do
@moduledoc false
@type auth_mode() :: :token | :password | :disabled
@doc """
Checks if the distribution mode is configured to use short names.
"""
@ -13,6 +15,14 @@ defmodule Livebook.Config do
end
end
@doc """
Returns the authentication mode.
"""
@spec auth_mode() :: auth_mode()
def auth_mode() do
Application.fetch_env!(:livebook, :authentication_mode)
end
@doc """
Return the root path for persisting notebooks.
"""
@ -20,4 +30,55 @@ defmodule Livebook.Config do
def root_path() do
Application.get_env(:livebook, :root_path, File.cwd!())
end
## Parsing
@doc """
Parses a long secret.
"""
def secret!(env) do
if secret_key_base = System.get_env(env) do
if byte_size(secret_key_base) < 64 do
abort!(
"cannot start Livebook because #{env} must be at least 64 characters. " <>
"Invoke `openssl rand -base64 48` to generate an appropriately long secret."
)
end
secret_key_base
end
end
@doc """
Parses a port.
"""
def port!(env) do
if port = System.get_env(env) do
case Integer.parse(port) do
{port, ""} -> port
:error -> abort!("expected #{env} to be an integer, got: #{inspect(port)}")
end
end
end
@doc """
Parses a password.
"""
def password!(env) do
if password = System.get_env(env) do
if byte_size(password) < 12 do
abort!("cannot start Livebook because #{env} must be at least 12 characters")
end
password
end
end
@doc """
Aborts booting due to a configuration error.
"""
def abort!(message) do
IO.puts("\nERROR!!! [Livebook] " <> message)
System.halt(1)
end
end

View file

@ -2,6 +2,15 @@ defmodule LivebookCLI.Server do
@moduledoc false
@behaviour LivebookCLI.Task
@external_resource "README.md"
[_, environment_variables, _] =
"README.md"
|> File.read!()
|> String.split("<!-- Environment variables -->")
@external_resource "README.md"
@environment_variables String.trim(environment_variables)
@impl true
def usage() do
@ -10,12 +19,18 @@ defmodule LivebookCLI.Server do
Available options:
-p, --port The port to start the web application on, defaults to 8080
--no-token Disable token authentication, enabled by default
--sname Set a short name for the app distributed node
--name Set a name for the app distributed node
--no-token Disable token authentication, enabled by default
If LIVEBOOK_PASSWORD is set, it takes precedence over token auth
--sname Set a short name for the app distributed node
-p, --port The port to start the web application on, defaults to 8080
The --help option can be given to print this notice.
## Environment variables
#{@environment_variables}
The --help option can be given for usage information.
"""
end
@ -74,8 +89,12 @@ defmodule LivebookCLI.Server do
defp opts_to_config([], config), do: config
defp opts_to_config([{:token, token_auth?} | opts], config) do
opts_to_config(opts, [{:livebook, :token_authentication, token_auth?} | config])
defp opts_to_config([{:token, false} | opts], config) do
if Livebook.Config.auth_mode() == :token do
opts_to_config(opts, [{:livebook, :authentication_mode, :disabled} | config])
else
opts_to_config(opts, config)
end
end
defp opts_to_config([{:port, port} | opts], config) do

View file

@ -1,36 +1,37 @@
defmodule LivebookWeb.AuthController do
use LivebookWeb, :controller
alias LivebookWeb.Helpers
plug :require_unauthenticated_password
def index(conn, _assigns) do
conn
|> set_authenticated()
|> ensure_authenticated()
alias LivebookWeb.AuthPlug
defp require_unauthenticated_password(conn, _opts) do
if Livebook.Config.auth_mode() != :password or AuthPlug.authenticated?(conn, :password) do
redirect_home(conn)
else
conn
end
end
defp set_authenticated(conn) do
conn
|> assign(:authenticated, LivebookWeb.AuthPlug.authenticated?(conn))
end
defp ensure_authenticated(%Plug.Conn{assigns: %{authenticated: true}} = conn) do
conn
|> redirect(to: "/")
end
defp ensure_authenticated(conn) do
def index(conn, _params) do
conn
|> put_view(LivebookWeb.ErrorView)
|> render("401.html")
end
def authenticate(conn, %{"password" => password}) do
password = :crypto.hash(:sha256, password) |> Base.encode16()
cookie_key = Helpers.auth_cookie_key(conn, :password)
conn = AuthPlug.store(conn, :password, password)
if AuthPlug.authenticated?(conn, :password) do
redirect_home(conn)
else
index(conn, %{})
end
end
defp redirect_home(conn) do
conn
|> put_resp_cookie(cookie_key, password, Helpers.auth_cookie_opts())
|> redirect(to: "/")
|> halt()
end
end

View file

@ -68,24 +68,4 @@ defmodule LivebookWeb.Helpers do
|> String.split("\n")
|> Enum.map(&Phoenix.HTML.raw/1)
end
@doc """
Returns cookie key based on connection port and authentication type.
The user may run multiple Livebook instances on the same host
on different ports, so the cookie name should be scoped under port.
"""
@spec auth_cookie_key(Plug.Conn.t(), :token | :password) :: String.t()
def auth_cookie_key(conn, type) do
"#{conn.port}#{inspect(type)}"
end
@doc """
Returns cookie options that should be used to sign authentication cookies.
"""
@spec auth_cookie_opts() :: Keyword.t()
def auth_cookie_opts() do
# max_age is set to 30 days in seconds
[sign: true, max_age: 2_592_000]
end
end

View file

@ -7,8 +7,6 @@ defmodule LivebookWeb.AuthPlug do
@behaviour Plug
alias LivebookWeb.Helpers
import Plug.Conn
import Phoenix.Controller
@ -17,79 +15,58 @@ defmodule LivebookWeb.AuthPlug do
@impl true
def call(conn, _opts) do
case auth_mode() do
:password ->
password_authentication(conn)
mode = Livebook.Config.auth_mode()
:token ->
token_authentication(conn)
:disabled ->
conn
if authenticated?(conn, mode) do
conn
else
authenticate(conn, mode)
end
end
@doc """
Stores in the session the secret for the given mode.
"""
def store(conn, mode, value) do
put_session(conn, key(conn, mode), hash(value))
end
@doc """
Checks if given connection is already authenticated.
"""
@spec authenticated?(Plug.Conn.t()) :: boolean()
def authenticated?(conn, mode \\ auth_mode())
@spec authenticated?(Plug.Conn.t(), Livebook.Config.auth_mode()) :: boolean()
def authenticated?(conn, mode)
def authenticated?(conn, mode) when mode in [:token, :password] do
secret = prepare_secret(mode)
key = Helpers.auth_cookie_key(conn, mode)
conn = fetch_cookies(conn, signed: [key])
cookie = conn.cookies[key]
is_binary(cookie) and Plug.Crypto.secure_compare(cookie, secret)
secret = get_session(conn, key(conn, mode))
is_binary(secret) and Plug.Crypto.secure_compare(secret, expected(mode))
end
def authenticated?(_conn, _mode) do
true
end
defp password_authentication(conn) do
if authenticated?(conn) do
defp authenticate(conn, :password) do
conn
|> redirect(to: "/authenticate")
|> halt()
end
defp authenticate(conn, :token) do
token = Map.get(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
else
conn
|> redirect(to: "/authenticate")
|> store(:token, token)
|> redirect(to: conn.request_path)
|> halt()
else
raise LivebookWeb.InvalidTokenError
end
end
defp token_authentication(conn) do
token = prepare_secret(:token)
cookie_key = Helpers.auth_cookie_key(conn, :token)
token_param = Map.get(conn.query_params, "token")
cond do
is_binary(token_param) and Plug.Crypto.secure_compare(token_param, token) ->
conn
|> put_resp_cookie(cookie_key, token_param, Helpers.auth_cookie_opts())
# Redirect to the same path without query params
|> redirect(to: conn.request_path)
|> halt()
authenticated?(conn) ->
conn
true ->
raise LivebookWeb.InvalidTokenError
end
end
defp auth_mode() do
Application.fetch_env!(:livebook, :authentication_mode)
end
defp prepare_secret(mode) do
secret = Application.fetch_env!(:livebook, mode)
case mode do
:token -> secret
:password -> :crypto.hash(:sha256, secret) |> Base.encode16()
end
end
defp key(conn, mode), do: "#{conn.port}:#{mode}"
defp expected(mode), do: hash(Application.fetch_env!(:livebook, mode))
defp hash(value), do: :crypto.hash(:sha256, value)
end

View file

@ -16,8 +16,7 @@ defmodule LivebookWeb.Router do
end
scope "/", LivebookWeb do
pipe_through :browser
pipe_through :auth
pipe_through [:browser, :auth]
live "/", HomeLive, :page
live "/home/sessions/:session_id/close", HomeLive, :close_session

View file

@ -11,8 +11,7 @@ defmodule Livebook.MixProject do
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
escript: escript(),
preferred_cli_env: preferred_cli_env()
escript: escript()
]
end
@ -58,10 +57,4 @@ defmodule Livebook.MixProject do
app: nil
]
end
defp preferred_cli_env() do
[
build: :prod
]
end
end

View file

@ -59,33 +59,31 @@ defmodule LivebookWeb.AuthPlugTest do
end
@tag token: "grumpycat"
test "persists authentication across requests using cookies", %{conn: conn} do
test "persists authentication across requests", %{conn: conn} do
conn = get(conn, "/?token=grumpycat")
assert get_session(conn, "80:token")
assert Map.has_key?(conn.resp_cookies, "80:token")
conn =
build_conn()
|> Plug.Test.recycle_cookies(conn)
|> get("/")
conn = get(conn, "/")
assert conn.status == 200
assert conn.resp_body =~ "New notebook"
end
end
describe "password authentication" do
@tag password: "grumpycat"
test "redirects to '/authenticate' if not already authenticated", %{conn: conn} do
conn = get(conn, "/")
test "redirects to '/' if no authentication is required", %{conn: conn} do
conn = get(conn, "/authenticate")
assert redirected_to(conn) == "/"
end
@tag password: "grumpycat"
test "redirects to '/authenticate' if not authenticated", %{conn: conn} do
conn = get(conn, "/")
assert redirected_to(conn) == "/authenticate"
end
@tag password: "grumpycat"
test "redirects to '/' on valid authentication", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
assert redirected_to(conn) == "/"
conn = get(conn, "/")
@ -95,24 +93,23 @@ defmodule LivebookWeb.AuthPlugTest do
@tag password: "grumpycat"
test "redirects back to '/authenticate' on invalid password", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "invalid password")
assert html_response(conn, 200) =~ "Authentication required"
conn = get(conn, "/")
assert redirected_to(conn) == "/authenticate"
end
@tag password: "grumpycat"
test "persists authentication across requests using cookies", %{conn: conn} do
test "persists authentication across requests", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
assert get_session(conn, "80:password")
assert Map.has_key?(conn.resp_cookies, "80:password")
conn =
build_conn()
|> Plug.Test.recycle_cookies(conn)
|> get("/")
conn = get(conn, "/")
assert conn.status == 200
assert conn.resp_body =~ "New notebook"
conn = get(conn, "/authenticate")
assert redirected_to(conn) == "/"
end
end
end