diff --git a/config/dev.exs b/config/dev.exs index d881b7948..8c05c12f3 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -66,4 +66,4 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime -config :livebook, :token_authentication, false +config :livebook, authentication_mode: :disabled diff --git a/config/prod.exs b/config/prod.exs index 5d11799f7..0879e3eb5 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -9,6 +9,8 @@ config :livebook, LivebookWeb.Endpoint, http: [ip: {127, 0, 0, 1}, port: 8080] # so limit the amount of information we show. config :logger, level: :info +config :livebook, authentication_mode: :token + # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/config/runtime.exs b/config/runtime.exs index 0a90cab11..42c26b349 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -5,10 +5,21 @@ import Config # config :livebook, :node, {:shortnames, "livebook"} # config :livebook, :node, {:longnames, :"livebook@127.0.0.1"} +if password = System.get_env("LIVEBOOK_PASSWORD") do + config :livebook, + authentication_mode: :password, + password: password +end + if config_env() == :prod do - # We don't need persistent session, so it's fine to just - # generate a new key everytime the app starts - secret_key_base = :crypto.strong_rand_bytes(48) |> Base.encode64() + # 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 end diff --git a/config/test.exs b/config/test.exs index 8be5fca94..a8df587b9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,7 +9,7 @@ config :livebook, LivebookWeb.Endpoint, # Print only warnings and errors during test config :logger, level: :warn -config :livebook, :token_authentication, false +config :livebook, :authentication_mode, :disabled # Use longnames when running tests in CI, so that no host resolution is required, # see https://github.com/elixir-nx/livebook/pull/173#issuecomment-819468549 diff --git a/lib/livebook_web/controllers/auth_controller.ex b/lib/livebook_web/controllers/auth_controller.ex new file mode 100644 index 000000000..4f30e0bd8 --- /dev/null +++ b/lib/livebook_web/controllers/auth_controller.ex @@ -0,0 +1,36 @@ +defmodule LivebookWeb.AuthController do + use LivebookWeb, :controller + + alias LivebookWeb.Helpers + + def index(conn, _assigns) do + conn + |> set_authenticated() + |> ensure_authenticated() + 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 + 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 + |> put_resp_cookie(cookie_key, password, Helpers.auth_cookie_opts()) + |> redirect(to: "/") + end +end diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index 97bb4a8ae..f205d63f1 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -68,4 +68,24 @@ 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 diff --git a/lib/livebook_web/plugs/auth_plug.ex b/lib/livebook_web/plugs/auth_plug.ex index ea4d736ed..1addecc9d 100644 --- a/lib/livebook_web/plugs/auth_plug.ex +++ b/lib/livebook_web/plugs/auth_plug.ex @@ -7,45 +7,89 @@ defmodule LivebookWeb.AuthPlug do @behaviour Plug + alias LivebookWeb.Helpers + import Plug.Conn import Phoenix.Controller - @cookie_opts [sign: true, max_age: 2_592_000] - @impl true def init(opts), do: opts @impl true - def call(conn, _otps) do - case Application.get_env(:livebook, :token) do - nil -> conn - token -> token_authentication(conn, token) + def call(conn, _opts) do + case auth_mode() do + :password -> + password_authentication(conn) + + :token -> + token_authentication(conn) + + :disabled -> + conn end end - defp token_authentication(conn, token) do - # The user may run multiple Livebook instances on the same host - # on different ports, so we scope the cookie name under port - token_cookie = "#{conn.port}:token" + @doc """ + Checks if given connection is already authenticated. + """ + @spec authenticated?(Plug.Conn.t()) :: boolean() + def authenticated?(conn, mode \\ auth_mode()) - conn = fetch_cookies(conn, signed: [token_cookie]) + def authenticated?(conn, mode) when mode in [:token, :password] do + secret = prepare_secret(mode) - param_token = Map.get(conn.query_params, "token") - cookie_token = conn.cookies[token_cookie] + 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 + + def authenticated?(_conn, _mode) do + true + end + + defp password_authentication(conn) do + if authenticated?(conn) do + conn + else + conn + |> redirect(to: "/authenticate") + |> halt() + 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(param_token) and Plug.Crypto.secure_compare(param_token, token) -> + is_binary(token_param) and Plug.Crypto.secure_compare(token_param, token) -> conn - |> put_resp_cookie(token_cookie, param_token, @cookie_opts) + |> 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() - is_binary(cookie_token) and Plug.Crypto.secure_compare(cookie_token, token) -> + 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 diff --git a/lib/livebook_web/router.ex b/lib/livebook_web/router.ex index 251cef252..84af20287 100644 --- a/lib/livebook_web/router.ex +++ b/lib/livebook_web/router.ex @@ -9,11 +9,15 @@ defmodule LivebookWeb.Router do plug :put_root_layout, {LivebookWeb.LayoutView, :root} plug :protect_from_forgery plug :put_secure_browser_headers + end + + pipeline :auth do plug LivebookWeb.AuthPlug end scope "/", LivebookWeb do pipe_through :browser + pipe_through :auth live "/", HomeLive, :page live "/home/sessions/:session_id/close", HomeLive, :close_session @@ -26,4 +30,11 @@ defmodule LivebookWeb.Router do live_dashboard "/dashboard", metrics: LivebookWeb.Telemetry end + + scope "/authenticate", LivebookWeb do + pipe_through :browser + + get "/", AuthController, :index + post "/", AuthController, :authenticate + end end diff --git a/lib/livebook_web/templates/error/401.html.eex b/lib/livebook_web/templates/error/401.html.eex new file mode 100644 index 000000000..ce06b1c3c --- /dev/null +++ b/lib/livebook_web/templates/error/401.html.eex @@ -0,0 +1,53 @@ + + + + + + + <%= live_title_tag "Livebook" %> + "/> + + +
+
+ + livebook + +
+ Authentication required +
+ + <%= case Application.fetch_env!(:livebook, :authentication_mode) do %> + <% :token -> %> +
+ Please check out the console for authentication URL + or type the token directly here. +
+ +
+
+ + +
+
+ + <% :password -> %> +
+ Type password to access the Livebook. +
+
+
+ + + +
+
+ <% end %> +
+
+ + diff --git a/lib/livebook_web/templates/error/401_token.html.eex b/lib/livebook_web/templates/error/401_token.html.eex deleted file mode 100644 index 961b82796..000000000 --- a/lib/livebook_web/templates/error/401_token.html.eex +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - <%= live_title_tag "Livebook" %> - "/> - - -
-
- - livebook - -
- Authentication required -
-
- Please check out the console for authentication URL - or type the token directly here. -
-
-
- - -
-
-
-
- - diff --git a/lib/livebook_web/views/error_view.ex b/lib/livebook_web/views/error_view.ex index c5f0833db..787bed1d5 100644 --- a/lib/livebook_web/views/error_view.ex +++ b/lib/livebook_web/views/error_view.ex @@ -1,10 +1,6 @@ defmodule LivebookWeb.ErrorView do use LivebookWeb, :view - def render("401.html", %{reason: %LivebookWeb.InvalidTokenError{}} = assigns) do - render("401_token.html", assigns) - end - def template_not_found(_template, assigns) do render("500.html", assigns) end diff --git a/test/livebook_web/plugs/auth_plug_test.exs b/test/livebook_web/plugs/auth_plug_test.exs index 85aa3278d..30bf7b317 100644 --- a/test/livebook_web/plugs/auth_plug_test.exs +++ b/test/livebook_web/plugs/auth_plug_test.exs @@ -2,11 +2,20 @@ defmodule LivebookWeb.AuthPlugTest do use LivebookWeb.ConnCase, async: false setup context do - if context[:token] do - Application.put_env(:livebook, :token, context[:token]) + {type, value} = + cond do + token = context[:token] -> {:token, token} + password = context[:password] -> {:password, password} + true -> {:disabled, ""} + end + + unless type == :disabled do + Application.put_env(:livebook, :authentication_mode, type) + Application.put_env(:livebook, type, value) on_exit(fn -> - Application.delete_env(:livebook, :token) + Application.put_env(:livebook, :authentication_mode, :disabled) + Application.delete_env(:livebook, type) end) end @@ -64,4 +73,46 @@ defmodule LivebookWeb.AuthPlugTest do 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, "/") + + 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, "/") + assert html_response(conn, 200) =~ "New notebook" + end + + @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") + + conn = get(conn, "/") + assert redirected_to(conn) == "/authenticate" + end + + @tag password: "grumpycat" + test "persists authentication across requests using cookies", %{conn: conn} do + conn = post(conn, Routes.auth_path(conn, :authenticate), password: "grumpycat") + + assert Map.has_key?(conn.resp_cookies, "80:password") + + conn = + build_conn() + |> Plug.Test.recycle_cookies(conn) + |> get("/") + + assert conn.status == 200 + assert conn.resp_body =~ "New notebook" + end + end end