defmodule LivebookWeb.AuthPlug do @behaviour Plug import Plug.Conn import Phoenix.Controller use LivebookWeb, :verified_routes @impl true def init(opts), do: opts @impl true def call(conn, _opts) do if authenticated?(conn) do if not authorized?(conn) do render_unauthorized(conn) else conn end else authenticate(conn) end end @doc """ Stores in the session the secret for the given mode. """ @spec store(Plug.Conn.t(), Livebook.Config.authentication_mode(), String.t()) :: Plug.Conn.t() def store(conn, mode, value) do conn |> put_session(key(conn.port, mode), hash(value)) |> configure_session(renew: true) end @doc """ Checks if given connection is already authenticated. """ @spec authenticated?(Plug.Conn.t()) :: boolean() def authenticated?(conn) do authenticated?(get_session(conn), conn.port) end defp authenticated?(session, port) do case authentication(session) do %{mode: :disabled} -> true %{mode: mode, secret: secret} when mode in [:token, :password] -> secret_hash = session[key(port, mode)] is_binary(secret_hash) and matches_secret?(secret_hash, secret) end end @doc """ Checks if given connection or session is authorized. """ @spec authorized?(Plug.Conn.t()) :: boolean() def authorized?(%Plug.Conn{} = conn) do # Note that if the user has access restricted to specific app pages, # they are not authorized and have no access to any pages guarded # by this plug. authenticated?(conn) and LivebookWeb.UserPlug.build_current_user( get_session(conn), conn.assigns.identity_data, conn.assigns.user_data ).access_type == :full end @doc """ Checks if the given session is authorized. """ @spec authorized?(map(), non_neg_integer()) :: boolean() def authorized?(%{} = session, port) do authenticated?(session, port) and LivebookWeb.UserPlug.build_current_user( session, session["identity_data"], session["user_data"] ).access_type == :full end defp authenticate(conn) do case authentication(conn) 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 path = if Livebook.Apps.list_apps() != [] or Livebook.Config.apps_path() != nil or teams_auth(conn) != nil do ~p"/apps" else ~p"/authenticate" end conn |> redirect(to: path) |> halt() end defp redirect_to_authenticate(conn) do conn |> then(fn %{method: "GET"} -> put_session(conn, :redirect_to, current_path(conn)) conn -> conn end) |> redirect(to: ~p"/authenticate") |> halt() end defp render_unauthorized(%{path_info: []} = conn) do conn |> redirect(to: ~p"/apps") |> halt() end defp render_unauthorized(conn) do conn |> put_status(:unauthorized) |> put_view(LivebookWeb.ErrorHTML) |> put_root_layout(false) |> render("401.html", %{details: "You don't have permission to access this server"}) |> halt() end defp path_with_query(path, params) when params == %{}, do: path defp path_with_query(path, params), do: path <> "?" <> URI.encode_query(params) defp key(port, mode), do: "#{port}:#{mode}" defp hash(value), do: :crypto.hash(:sha256, value) @doc """ Returns the authentication configuration for the given `conn` or `session`. This mirrors `Livebook.Config.authentication/0`, except the it can be overridden in tests, for each connection. """ @spec authentication(Plug.Conn.t() | map()) :: Livebook.Config.authentication() if Mix.env() == :test do def authentication(%Plug.Conn{} = conn), do: authentication(get_session(conn)) def authentication(%{} = session) do session["authentication_test_override"] || Livebook.Config.authentication() end else def authentication(_), do: Livebook.Config.authentication() end @doc """ Returns the kind of Teams authentication configuration for the given `conn` or `session`. This mirrors `Livebook.Config.teams_auth/0`, except the it can be overridden in tests, for each connection. """ @spec teams_auth(Plug.Conn.t() | map()) :: :online | :offline | nil if Mix.env() == :test do def teams_auth(%Plug.Conn{} = conn) do conn |> get_session() |> teams_auth() end def teams_auth(%{} = session) do session["teams_auth_test_override"] || Livebook.Config.teams_auth() end else def teams_auth(_) do Livebook.Config.teams_auth() end end end