defmodule SyntropyWeb.OperatorAuth do @moduledoc """ Session-based operator authentication for the mission control UI. Credentials come from the environment (`SYNTROPY_OPERATOR_PASSWORD`, plus an optional `SYNTROPY_OPERATOR_USER`, default `"operator"`). Enforcement is required outside dev/test, mirroring `SyntropyWeb.ApiAuth.required?/0`. """ import Phoenix.Component, only: [assign: 3] @session_key "operator_authenticated" @spec required?() :: boolean() def required? do config() |> Keyword.get(:required, false) end @spec username() :: String.t() def username do config() |> Keyword.get(:username, "operator") end @doc "Validates a login attempt in constant time." @spec validate_credentials(String.t() | nil, String.t() | nil) :: :ok | {:error, :invalid} def validate_credentials(provided_username, provided_password) when is_binary(provided_username) and is_binary(provided_password) do expected_password = Keyword.get(config(), :password) with true <- is_binary(expected_password) and expected_password != "", true <- secure_equal?(provided_username, username()), true <- secure_equal?(provided_password, expected_password) do :ok else _mismatch -> {:error, :invalid} end end def validate_credentials(_username, _password), do: {:error, :invalid} @spec session_key() :: String.t() def session_key, do: @session_key @spec sign_in(Plug.Conn.t()) :: Plug.Conn.t() def sign_in(conn) do conn |> Plug.Conn.configure_session(renew: true) |> Plug.Conn.put_session(@session_key, true) end @spec sign_out(Plug.Conn.t()) :: Plug.Conn.t() def sign_out(conn) do Plug.Conn.configure_session(conn, drop: true) end @spec authenticated?(Plug.Conn.t() | map()) :: boolean() def authenticated?(%Plug.Conn{} = conn) do Plug.Conn.get_session(conn, @session_key) == true end def authenticated?(session) when is_map(session) do Map.get(session, @session_key) == true end @doc """ LiveView mount guard: `live_session ..., on_mount: {SyntropyWeb.OperatorAuth, :default}`. """ @spec on_mount(:default, map(), map(), Phoenix.LiveView.Socket.t()) :: {:cont, Phoenix.LiveView.Socket.t()} | {:halt, Phoenix.LiveView.Socket.t()} def on_mount(:default, _params, session, socket) do if not required?() or authenticated?(session) do {:cont, assign(socket, :operator_auth_required, required?())} else {:halt, Phoenix.LiveView.redirect(socket, to: "/login")} end end defp secure_equal?(left, right) when is_binary(left) and is_binary(right) do byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right) end defp secure_equal?(_left, _right), do: false defp config do Application.get_env(:syntropy, __MODULE__, []) end end