mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-09 13:07:37 +08:00
Unify env variables and change auth to use session (#195)
This commit is contained in:
parent
3a19021983
commit
57047f9c7f
15 changed files with 210 additions and 218 deletions
61
README.md
61
README.md
|
@ -2,41 +2,25 @@
|
||||||
|
|
||||||
Livebook is a web application for writing interactive and collaborative code notebooks. It features:
|
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)
|
* A deployable web app built with [Phoenix LiveView](https://github.com/phoenixframework/phoenix_live_view) where users can create, fork, and run multiple notebooks.
|
||||||
where users can create, fork, and run multiple notebooks.
|
|
||||||
|
|
||||||
* Each notebook is made of multiple sections: each section is made of Markdown and Elixir
|
* 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/).
|
||||||
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
|
* 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.
|
||||||
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
|
* 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.
|
||||||
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,
|
* 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.
|
||||||
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
|
* 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+.
|
||||||
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.
|
* 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.
|
||||||
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).
|
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:
|
||||||
Otherwise, here is a peek at the "Welcome to Livebook" introductory notebook:
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The current version provides only the initial step of our Livebook vision. Our plan
|
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.
|
||||||
is to continue focusing on visual, collaborative, and interactive features in the
|
|
||||||
upcoming releases.
|
|
||||||
|
|
||||||
## Usage
|
## 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.
|
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 will work on other distribution modes (escripts, Docker images, etc) once we start distributing official releases.
|
||||||
we start distributing official releases.
|
|
||||||
|
|
||||||
## Security considerations
|
### Security considerations
|
||||||
|
|
||||||
Livebook is built to document and execute code. Anyone with access to a
|
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 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
|
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.
|
||||||
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
|
## License
|
||||||
|
|
||||||
Copyright (C) 2021 Dashbit
|
Copyright (C) 2021 Dashbit
|
||||||
|
|
|
@ -3,7 +3,6 @@ import Config
|
||||||
# Configures the endpoint
|
# Configures the endpoint
|
||||||
config :livebook, LivebookWeb.Endpoint,
|
config :livebook, LivebookWeb.Endpoint,
|
||||||
url: [host: "localhost"],
|
url: [host: "localhost"],
|
||||||
secret_key_base: "9hHHeOiAA8wrivUfuS//jQMurHxoMYUtF788BQMx2KO7mYUE8rVrGGG09djBNQq7",
|
|
||||||
pubsub_server: Livebook.PubSub,
|
pubsub_server: Livebook.PubSub,
|
||||||
live_view: [signing_salt: "livebook"]
|
live_view: [signing_salt: "livebook"]
|
||||||
|
|
||||||
|
@ -15,7 +14,8 @@ config :logger, :console,
|
||||||
# Use Jason for JSON parsing in Phoenix
|
# Use Jason for JSON parsing in Phoenix
|
||||||
config :phoenix, :json_library, Jason
|
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
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
|
|
|
@ -66,4 +66,5 @@ config :phoenix, :stacktrace_depth, 20
|
||||||
# Initialize plugs at runtime for faster development compilation
|
# Initialize plugs at runtime for faster development compilation
|
||||||
config :phoenix, :plug_init_mode, :runtime
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
config :livebook, authentication_mode: :disabled
|
# Disable authentication mode during dev
|
||||||
|
config :livebook, :authentication_mode, :disabled
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import Config
|
import Config
|
||||||
|
|
||||||
# For production, don't forget to configure the url host
|
# Default bind and port for production
|
||||||
# to something meaningful, Phoenix uses this information
|
|
||||||
# when generating URLs.
|
|
||||||
config :livebook, LivebookWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 8080]
|
config :livebook, LivebookWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 8080]
|
||||||
|
|
||||||
# The output is shown to the end user,
|
# Start log-level in notice by default to reduce output
|
||||||
# so limit the amount of information we show.
|
config :logger, level: :notice
|
||||||
config :logger, level: :info
|
|
||||||
|
|
||||||
config :livebook, authentication_mode: :token
|
|
||||||
|
|
||||||
# ## SSL Support
|
# ## SSL Support
|
||||||
#
|
#
|
||||||
|
|
|
@ -1,25 +1,17 @@
|
||||||
import Config
|
import Config
|
||||||
|
require Logger
|
||||||
|
|
||||||
# Configure the type of names used for distribution and the node name.
|
config :livebook, LivebookWeb.Endpoint,
|
||||||
# By default a random short name is used.
|
secret_key_base:
|
||||||
# config :livebook, :node, {:shortnames, "livebook"}
|
Livebook.Config.secret!("LIVEBOOK_SECRET_KEY_BASE") ||
|
||||||
# config :livebook, :node, {:longnames, :"livebook@127.0.0.1"}
|
Base.encode64(:crypto.strong_rand_bytes(48))
|
||||||
|
|
||||||
if password = System.get_env("LIVEBOOK_PASSWORD") do
|
if password = Livebook.Config.password!("LIVEBOOK_PASSWORD") do
|
||||||
config :livebook,
|
config :livebook, authentication_mode: :password, password: password
|
||||||
authentication_mode: :password,
|
else
|
||||||
password: password
|
config :livebook, token: Livebook.Utils.random_id()
|
||||||
end
|
end
|
||||||
|
|
||||||
if config_env() == :prod do
|
if port = Livebook.Config.port!("LIVEBOOK_PORT") do
|
||||||
# In order to persist sessions between deployments (desirable when using password authentication mode)
|
config :livebook, LivebookWeb.Endpoint, http: [port: port]
|
||||||
# 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
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,6 +9,7 @@ config :livebook, LivebookWeb.Endpoint,
|
||||||
# Print only warnings and errors during test
|
# Print only warnings and errors during test
|
||||||
config :logger, level: :warn
|
config :logger, level: :warn
|
||||||
|
|
||||||
|
# Disable authentication mode during test
|
||||||
config :livebook, :authentication_mode, :disabled
|
config :livebook, :authentication_mode, :disabled
|
||||||
|
|
||||||
# Use longnames when running tests in CI, so that no host resolution is required,
|
# Use longnames when running tests in CI, so that no host resolution is required,
|
||||||
|
|
|
@ -7,7 +7,6 @@ defmodule Livebook.Application do
|
||||||
|
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
ensure_distribution!()
|
ensure_distribution!()
|
||||||
initialize_token()
|
|
||||||
|
|
||||||
children = [
|
children = [
|
||||||
# Start the Telemetry supervisor
|
# Start the Telemetry supervisor
|
||||||
|
@ -44,7 +43,7 @@ defmodule Livebook.Application do
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
abort!("""
|
Livebook.Config.abort!("""
|
||||||
could not start epmd (Erlang Port Mapper Driver). Livebook uses epmd to \
|
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:
|
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()
|
{type, name} = get_node_type_and_name()
|
||||||
|
|
||||||
case Node.start(name, type) do
|
case Node.start(name, type) do
|
||||||
{:ok, _} -> :ok
|
{:ok, _} ->
|
||||||
{:error, reason} -> abort!("could not start distributed node: #{inspect(reason)}")
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Livebook.Config.abort!("could not start distributed node: #{inspect(reason)}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp abort!(message) do
|
|
||||||
IO.puts("\nERROR!!! [Livebook] " <> message)
|
|
||||||
System.halt(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_node_type_and_name() do
|
defp get_node_type_and_name() do
|
||||||
Application.get_env(:livebook, :node) || {:shortnames, random_short_name()}
|
Application.get_env(:livebook, :node) || {:shortnames, random_short_name()}
|
||||||
end
|
end
|
||||||
|
@ -80,19 +77,9 @@ defmodule Livebook.Application do
|
||||||
:"livebook_#{Livebook.Utils.random_short_id()}"
|
:"livebook_#{Livebook.Utils.random_short_id()}"
|
||||||
end
|
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
|
defp display_startup_info() do
|
||||||
if Phoenix.Endpoint.server?(:livebook, LivebookWeb.Endpoint) 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule Livebook.Config do
|
defmodule Livebook.Config do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
@type auth_mode() :: :token | :password | :disabled
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Checks if the distribution mode is configured to use short names.
|
Checks if the distribution mode is configured to use short names.
|
||||||
"""
|
"""
|
||||||
|
@ -13,6 +15,14 @@ defmodule Livebook.Config do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the authentication mode.
|
||||||
|
"""
|
||||||
|
@spec auth_mode() :: auth_mode()
|
||||||
|
def auth_mode() do
|
||||||
|
Application.fetch_env!(:livebook, :authentication_mode)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Return the root path for persisting notebooks.
|
Return the root path for persisting notebooks.
|
||||||
"""
|
"""
|
||||||
|
@ -20,4 +30,55 @@ defmodule Livebook.Config do
|
||||||
def root_path() do
|
def root_path() do
|
||||||
Application.get_env(:livebook, :root_path, File.cwd!())
|
Application.get_env(:livebook, :root_path, File.cwd!())
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -2,6 +2,15 @@ defmodule LivebookCLI.Server do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
@behaviour LivebookCLI.Task
|
@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
|
@impl true
|
||||||
def usage() do
|
def usage() do
|
||||||
|
@ -10,12 +19,18 @@ defmodule LivebookCLI.Server do
|
||||||
|
|
||||||
Available options:
|
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
|
--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
|
end
|
||||||
|
|
||||||
|
@ -74,8 +89,12 @@ defmodule LivebookCLI.Server do
|
||||||
|
|
||||||
defp opts_to_config([], config), do: config
|
defp opts_to_config([], config), do: config
|
||||||
|
|
||||||
defp opts_to_config([{:token, token_auth?} | opts], config) do
|
defp opts_to_config([{:token, false} | opts], config) do
|
||||||
opts_to_config(opts, [{:livebook, :token_authentication, token_auth?} | config])
|
if Livebook.Config.auth_mode() == :token do
|
||||||
|
opts_to_config(opts, [{:livebook, :authentication_mode, :disabled} | config])
|
||||||
|
else
|
||||||
|
opts_to_config(opts, config)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp opts_to_config([{:port, port} | opts], config) do
|
defp opts_to_config([{:port, port} | opts], config) do
|
||||||
|
|
|
@ -1,36 +1,37 @@
|
||||||
defmodule LivebookWeb.AuthController do
|
defmodule LivebookWeb.AuthController do
|
||||||
use LivebookWeb, :controller
|
use LivebookWeb, :controller
|
||||||
|
|
||||||
alias LivebookWeb.Helpers
|
plug :require_unauthenticated_password
|
||||||
|
|
||||||
def index(conn, _assigns) do
|
alias LivebookWeb.AuthPlug
|
||||||
conn
|
|
||||||
|> set_authenticated()
|
defp require_unauthenticated_password(conn, _opts) do
|
||||||
|> ensure_authenticated()
|
if Livebook.Config.auth_mode() != :password or AuthPlug.authenticated?(conn, :password) do
|
||||||
|
redirect_home(conn)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp set_authenticated(conn) do
|
def index(conn, _params) 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
|
|
||||||
conn
|
conn
|
||||||
|> put_view(LivebookWeb.ErrorView)
|
|> put_view(LivebookWeb.ErrorView)
|
||||||
|> render("401.html")
|
|> render("401.html")
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticate(conn, %{"password" => password}) do
|
def authenticate(conn, %{"password" => password}) do
|
||||||
password = :crypto.hash(:sha256, password) |> Base.encode16()
|
conn = AuthPlug.store(conn, :password, password)
|
||||||
cookie_key = Helpers.auth_cookie_key(conn, :password)
|
|
||||||
|
|
||||||
|
if AuthPlug.authenticated?(conn, :password) do
|
||||||
|
redirect_home(conn)
|
||||||
|
else
|
||||||
|
index(conn, %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp redirect_home(conn) do
|
||||||
conn
|
conn
|
||||||
|> put_resp_cookie(cookie_key, password, Helpers.auth_cookie_opts())
|
|
||||||
|> redirect(to: "/")
|
|> redirect(to: "/")
|
||||||
|
|> halt()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -68,24 +68,4 @@ defmodule LivebookWeb.Helpers do
|
||||||
|> String.split("\n")
|
|> String.split("\n")
|
||||||
|> Enum.map(&Phoenix.HTML.raw/1)
|
|> Enum.map(&Phoenix.HTML.raw/1)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -7,8 +7,6 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
|
|
||||||
@behaviour Plug
|
@behaviour Plug
|
||||||
|
|
||||||
alias LivebookWeb.Helpers
|
|
||||||
|
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
import Phoenix.Controller
|
import Phoenix.Controller
|
||||||
|
|
||||||
|
@ -17,79 +15,58 @@ defmodule LivebookWeb.AuthPlug do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def call(conn, _opts) do
|
def call(conn, _opts) do
|
||||||
case auth_mode() do
|
mode = Livebook.Config.auth_mode()
|
||||||
:password ->
|
|
||||||
password_authentication(conn)
|
|
||||||
|
|
||||||
:token ->
|
if authenticated?(conn, mode) do
|
||||||
token_authentication(conn)
|
conn
|
||||||
|
else
|
||||||
:disabled ->
|
authenticate(conn, mode)
|
||||||
conn
|
|
||||||
end
|
end
|
||||||
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 """
|
@doc """
|
||||||
Checks if given connection is already authenticated.
|
Checks if given connection is already authenticated.
|
||||||
"""
|
"""
|
||||||
@spec authenticated?(Plug.Conn.t()) :: boolean()
|
@spec authenticated?(Plug.Conn.t(), Livebook.Config.auth_mode()) :: boolean()
|
||||||
def authenticated?(conn, mode \\ auth_mode())
|
def authenticated?(conn, mode)
|
||||||
|
|
||||||
def authenticated?(conn, mode) when mode in [:token, :password] do
|
def authenticated?(conn, mode) when mode in [:token, :password] do
|
||||||
secret = prepare_secret(mode)
|
secret = get_session(conn, key(conn, mode))
|
||||||
|
is_binary(secret) and Plug.Crypto.secure_compare(secret, expected(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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticated?(_conn, _mode) do
|
def authenticated?(_conn, _mode) do
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
defp password_authentication(conn) do
|
defp authenticate(conn, :password) do
|
||||||
if authenticated?(conn) 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
|
conn
|
||||||
else
|
|> store(:token, token)
|
||||||
conn
|
|> redirect(to: conn.request_path)
|
||||||
|> redirect(to: "/authenticate")
|
|
||||||
|> halt()
|
|> halt()
|
||||||
|
else
|
||||||
|
raise LivebookWeb.InvalidTokenError
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp token_authentication(conn) do
|
defp key(conn, mode), do: "#{conn.port}:#{mode}"
|
||||||
token = prepare_secret(:token)
|
defp expected(mode), do: hash(Application.fetch_env!(:livebook, mode))
|
||||||
cookie_key = Helpers.auth_cookie_key(conn, :token)
|
defp hash(value), do: :crypto.hash(:sha256, value)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,8 +16,7 @@ defmodule LivebookWeb.Router do
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", LivebookWeb do
|
scope "/", LivebookWeb do
|
||||||
pipe_through :browser
|
pipe_through [:browser, :auth]
|
||||||
pipe_through :auth
|
|
||||||
|
|
||||||
live "/", HomeLive, :page
|
live "/", HomeLive, :page
|
||||||
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
live "/home/sessions/:session_id/close", HomeLive, :close_session
|
||||||
|
|
9
mix.exs
9
mix.exs
|
@ -11,8 +11,7 @@ defmodule Livebook.MixProject do
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
aliases: aliases(),
|
aliases: aliases(),
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
escript: escript(),
|
escript: escript()
|
||||||
preferred_cli_env: preferred_cli_env()
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -58,10 +57,4 @@ defmodule Livebook.MixProject do
|
||||||
app: nil
|
app: nil
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp preferred_cli_env() do
|
|
||||||
[
|
|
||||||
build: :prod
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -59,33 +59,31 @@ defmodule LivebookWeb.AuthPlugTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag token: "grumpycat"
|
@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")
|
conn = get(conn, "/?token=grumpycat")
|
||||||
|
assert get_session(conn, "80:token")
|
||||||
|
|
||||||
assert Map.has_key?(conn.resp_cookies, "80:token")
|
conn = get(conn, "/")
|
||||||
|
|
||||||
conn =
|
|
||||||
build_conn()
|
|
||||||
|> Plug.Test.recycle_cookies(conn)
|
|
||||||
|> get("/")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert conn.resp_body =~ "New notebook"
|
assert conn.resp_body =~ "New notebook"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "password authentication" do
|
describe "password authentication" do
|
||||||
@tag password: "grumpycat"
|
test "redirects to '/' if no authentication is required", %{conn: conn} do
|
||||||
test "redirects to '/authenticate' if not already authenticated", %{conn: conn} do
|
conn = get(conn, "/authenticate")
|
||||||
conn = get(conn, "/")
|
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"
|
assert redirected_to(conn) == "/authenticate"
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag password: "grumpycat"
|
@tag password: "grumpycat"
|
||||||
test "redirects to '/' on valid authentication", %{conn: conn} do
|
test "redirects to '/' on valid authentication", %{conn: conn} do
|
||||||
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
|
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat")
|
||||||
|
|
||||||
assert redirected_to(conn) == "/"
|
assert redirected_to(conn) == "/"
|
||||||
|
|
||||||
conn = get(conn, "/")
|
conn = get(conn, "/")
|
||||||
|
@ -95,24 +93,23 @@ defmodule LivebookWeb.AuthPlugTest do
|
||||||
@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, Routes.auth_path(conn, :authenticate), password: "invalid password")
|
conn = post(conn, Routes.auth_path(conn, :authenticate), password: "invalid password")
|
||||||
|
assert html_response(conn, 200) =~ "Authentication required"
|
||||||
|
|
||||||
conn = get(conn, "/")
|
conn = get(conn, "/")
|
||||||
assert redirected_to(conn) == "/authenticate"
|
assert redirected_to(conn) == "/authenticate"
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag password: "grumpycat"
|
@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")
|
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 = get(conn, "/")
|
||||||
|
|
||||||
conn =
|
|
||||||
build_conn()
|
|
||||||
|> Plug.Test.recycle_cookies(conn)
|
|
||||||
|> get("/")
|
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert conn.resp_body =~ "New notebook"
|
assert conn.resp_body =~ "New notebook"
|
||||||
|
|
||||||
|
conn = get(conn, "/authenticate")
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Reference in a new issue