diff --git a/elixir/apps/domain/lib/domain/auth.ex b/elixir/apps/domain/lib/domain/auth.ex index 431495a44..bc2da4bac 100644 --- a/elixir/apps/domain/lib/domain/auth.ex +++ b/elixir/apps/domain/lib/domain/auth.ex @@ -72,7 +72,7 @@ defmodule Domain.Auth do alias Domain.Auth.Identity # This session duration is used when IdP doesn't return the token expiration date, - # or no IdP is used (eg. sign in via email or userpass). + # or no IdP is used (eg. sign in via email, userpass, or temp_account). @default_session_duration_hours [ browser: [ account_admin_user: 10, @@ -143,7 +143,7 @@ defmodule Domain.Auth do This functions allows to fetch singleton providers like `email` or `token`. """ def fetch_active_provider_by_adapter(adapter, %Subject{} = subject, opts \\ []) - when adapter in [:email, :userpass] do + when adapter in [:email, :userpass, :temp_account] do with :ok <- ensure_has_permissions(subject, Authorizer.manage_providers_permission()) do Provider.Query.not_disabled() |> Provider.Query.by_adapter(adapter) @@ -170,7 +170,7 @@ defmodule Domain.Auth do def all_third_party_providers!(%Subject{} = subject) do Provider.Query.not_deleted() |> Provider.Query.by_account_id(subject.account.id) - |> Provider.Query.by_adapter({:not_in, [:email, :userpass]}) + |> Provider.Query.by_adapter({:not_in, [:email, :userpass, :temp_account]}) |> Authorizer.for_subject(Provider, subject) |> Repo.all() end diff --git a/elixir/apps/domain/lib/domain/auth/adapter.ex b/elixir/apps/domain/lib/domain/auth/adapter.ex index 42721a74f..4349b009f 100644 --- a/elixir/apps/domain/lib/domain/auth/adapter.ex +++ b/elixir/apps/domain/lib/domain/auth/adapter.ex @@ -72,7 +72,7 @@ defmodule Domain.Auth.Adapter do A callback invoked during sign-in, should verify the secret and return the identity if it's valid, or an error otherwise. - Used by secret-based providers, eg.: UserPass, Email. + Used by secret-based providers, eg.: UserPass, Email, TempAccount. """ @callback verify_secret(%Identity{}, %Context{}, secret :: term()) :: {:ok, %Identity{}, expires_at :: %DateTime{} | nil} diff --git a/elixir/apps/domain/lib/domain/auth/adapters.ex b/elixir/apps/domain/lib/domain/auth/adapters.ex index 23ff288be..61c29c3df 100644 --- a/elixir/apps/domain/lib/domain/auth/adapters.ex +++ b/elixir/apps/domain/lib/domain/auth/adapters.ex @@ -9,7 +9,8 @@ defmodule Domain.Auth.Adapters do microsoft_entra: Domain.Auth.Adapters.MicrosoftEntra, okta: Domain.Auth.Adapters.Okta, jumpcloud: Domain.Auth.Adapters.JumpCloud, - userpass: Domain.Auth.Adapters.UserPass + userpass: Domain.Auth.Adapters.UserPass, + temp_account: Domain.Auth.Adapters.TempAccount } @adapter_names Map.keys(@adapters) @@ -29,7 +30,7 @@ defmodule Domain.Auth.Adapters do def list_user_provisioned_adapters! do enabled_adapters = Domain.Config.compile_config!(:auth_provider_adapters) - enabled_idp_adapters = enabled_adapters -- ~w[email userpass]a + enabled_idp_adapters = enabled_adapters -- ~w[email userpass temp_account]a Map.take(@adapters, enabled_idp_adapters) end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/temp_account.ex b/elixir/apps/domain/lib/domain/auth/adapters/temp_account.ex new file mode 100644 index 000000000..f235db85b --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/temp_account.ex @@ -0,0 +1,119 @@ +defmodule Domain.Auth.Adapters.TempAccount do + @moduledoc """ + This is only being used for Launch HN and will be removed + shortly after. + """ + use Supervisor + alias Domain.Repo + alias Domain.Auth.{Identity, Provider, Adapter, Context} + alias Domain.Auth.Adapters.TempAccount.Password + + @behaviour Adapter + @behaviour Adapter.Local + + def start_link(_init_arg) do + Supervisor.start_link(__MODULE__, nil, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + children = [] + + Supervisor.init(children, strategy: :one_for_one) + end + + @impl true + def capabilities do + [ + provisioners: [:manual], + default_provisioner: :manual, + parent_adapter: nil + ] + end + + @impl true + def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do + changeset + |> Domain.Repo.Changeset.trim_change(:provider_identifier) + |> validate_password() + end + + defp validate_password(changeset) do + data = Map.get(changeset.data, :provider_virtual_state) || %{} + attrs = Ecto.Changeset.get_change(changeset, :provider_virtual_state) || %{} + + Ecto.embedded_load(Password, data, :json) + |> Password.Changeset.changeset(attrs) + |> case do + %{valid?: false} = nested_changeset -> + {changeset, _original_type} = + Repo.Changeset.inject_embedded_changeset( + changeset, + :provider_virtual_state, + nested_changeset + ) + + changeset + + %{valid?: true} = nested_changeset -> + password_hash = Ecto.Changeset.fetch_change!(nested_changeset, :password_hash) + + {changeset, _original_type} = + changeset + |> Ecto.Changeset.put_change(:provider_state, %{"password_hash" => password_hash}) + |> Repo.Changeset.inject_embedded_changeset(:provider_virtual_state, nested_changeset) + + changeset + end + end + + @impl true + def provider_changeset(%Ecto.Changeset{} = changeset) do + changeset + end + + @impl true + def ensure_provisioned(%Provider{} = provider) do + {:ok, provider} + end + + @impl true + def ensure_deprovisioned(%Provider{} = provider) do + {:ok, provider} + end + + @impl true + def sign_out(%Provider{} = _provider, %Identity{} = identity, redirect_url) do + {:ok, identity, redirect_url} + end + + @impl true + def verify_secret(%Identity{} = identity, %Context{} = _context, password) + when is_binary(password) do + Identity.Query.not_disabled() + |> Identity.Query.by_id(identity.id) + |> Repo.fetch_and_update(Identity.Query, + with: fn identity -> + password_hash = identity.provider_state["password_hash"] + + cond do + is_nil(password_hash) -> + :invalid_secret + + not Domain.Crypto.equal?(:argon2, password, password_hash) -> + :invalid_secret + + true -> + Ecto.Changeset.change(identity) + end + end + ) + |> case do + {:ok, identity} -> + {:ok, identity, nil} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password.ex b/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password.ex new file mode 100644 index 000000000..b7ac7879b --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password.ex @@ -0,0 +1,10 @@ +defmodule Domain.Auth.Adapters.TempAccount.Password do + use Domain, :schema + + @primary_key false + embedded_schema do + field :password, :string, virtual: true, redact: true + field :password_hash, :string + field :password_confirmation, :string, virtual: true, redact: true + end +end diff --git a/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password/changeset.ex b/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password/changeset.ex new file mode 100644 index 000000000..47f9b2ef2 --- /dev/null +++ b/elixir/apps/domain/lib/domain/auth/adapters/temp_account/password/changeset.ex @@ -0,0 +1,29 @@ +defmodule Domain.Auth.Adapters.TempAccount.Password.Changeset do + use Domain, :changeset + alias Domain.Auth.Adapters.TempAccount.Password + + @fields ~w[password password_confirmation]a + @min_password_length 12 + @max_password_length 72 + + def changeset(%Password{} = struct, attrs) do + struct + |> cast(attrs, @fields) + |> validate_required(@fields) + |> validate_confirmation(:password, required: true) + |> validate_length(:password, + min: @min_password_length, + max: @max_password_length, + count: :bytes + ) + # We can improve password strength checks later if we decide to run this provider in production. + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + # |> validate_no_repetitive_characters(:password) + # |> validate_no_sequential_characters(:password) + # |> validate_no_public_context(:password) + |> put_hash(:password, :argon2, to: :password_hash) + |> validate_required([:password_hash]) + end +end diff --git a/elixir/apps/domain/lib/domain/auth/provider.ex b/elixir/apps/domain/lib/domain/auth/provider.ex index 514638b2a..11dd97288 100644 --- a/elixir/apps/domain/lib/domain/auth/provider.ex +++ b/elixir/apps/domain/lib/domain/auth/provider.ex @@ -5,7 +5,8 @@ defmodule Domain.Auth.Provider do field :name, :string field :adapter, Ecto.Enum, - values: ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud userpass]a + values: + ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud userpass temp_account]a field :provisioner, Ecto.Enum, values: ~w[manual just_in_time custom]a field :adapter_config, :map, redact: true diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 96fee049d..e914b8330 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -100,6 +100,7 @@ defmodule Domain.Config.Definitions do * `openid_connect` is used to authenticate users via OpenID Connect, this is recommended for production use; * `email` is used to authenticate users via sign in tokens sent to the email; * `token` is used to authenticate service accounts using an API token; + * `temp_account` is used to authenticate users with a password, only used for Launch HN; * `userpass` is used to authenticate users with username and password, should be used with extreme care and is not recommended for production use. """, @@ -442,9 +443,11 @@ defmodule Domain.Config.Definitions do okta jumpcloud userpass + temp_account token ]a)}}, - default: ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud token]a + default: + ~w[email openid_connect google_workspace microsoft_entra okta jumpcloud token temp_account]a ) ############################################## @@ -669,6 +672,11 @@ defmodule Domain.Config.Definitions do """ defconfig(:feature_rest_api_enabled, :boolean, default: false) + @doc """ + Boolean flag to turn 'Try Firezone' / temporary accounts functionality on/off + """ + defconfig(:feature_temp_accounts, :boolean, default: false) + ############################################## ## Analytics ############################################## diff --git a/elixir/apps/web/lib/web/components/core_components.ex b/elixir/apps/web/lib/web/components/core_components.ex index aa041a292..4fa25bd2d 100644 --- a/elixir/apps/web/lib/web/components/core_components.ex +++ b/elixir/apps/web/lib/web/components/core_components.ex @@ -285,10 +285,10 @@ defmodule Web.CoreComponents do id={@id} class={[ "p-4 text-sm flash-#{@kind}", - @kind == :success && "text-green-800 bg-green-50", - @kind == :info && "text-blue-800 bg-blue-50", - @kind == :warning && "text-yellow-800 bg-yellow-50", - @kind == :error && "text-red-800 bg-red-50", + @kind == :success && "text-green-800 bg-green-100", + @kind == :info && "text-blue-800 bg-blue-100", + @kind == :warning && "text-yellow-800 bg-yellow-100", + @kind == :error && "text-red-800 bg-red-100", @style != "wide" && "mb-4 rounded" ]} role="alert" @@ -1251,6 +1251,12 @@ defmodule Web.CoreComponents do """ end + def provider_icon(%{adapter: :temp_account} = assigns) do + ~H""" + <.icon name="hero-key" {@rest} /> + """ + end + def provider_icon(assigns), do: ~H"" def feature_name(%{feature: :idp_sync} = assigns) do diff --git a/elixir/apps/web/lib/web/components/layouts/app.html.heex b/elixir/apps/web/lib/web/components/layouts/app.html.heex index 5fd597446..36e61f29c 100644 --- a/elixir/apps/web/lib/web/components/layouts/app.html.heex +++ b/elixir/apps/web/lib/web/components/layouts/app.html.heex @@ -125,4 +125,5 @@ <%= @inner_content %> + <.bottom_banner :if={String.starts_with?(@account.slug, "temp_")} /> diff --git a/elixir/apps/web/lib/web/components/navigation_components.ex b/elixir/apps/web/lib/web/components/navigation_components.ex index fb5b61d6f..6900834eb 100644 --- a/elixir/apps/web/lib/web/components/navigation_components.ex +++ b/elixir/apps/web/lib/web/components/navigation_components.ex @@ -3,6 +3,30 @@ defmodule Web.NavigationComponents do use Web, :verified_routes import Web.CoreComponents + def bottom_banner(assigns) do + ~H""" +
+ """ + end + attr :subject, :any, required: true def topbar(assigns) do diff --git a/elixir/apps/web/lib/web/controllers/auth_controller.ex b/elixir/apps/web/lib/web/controllers/auth_controller.ex index c351846fb..d791fb7c6 100644 --- a/elixir/apps/web/lib/web/controllers/auth_controller.ex +++ b/elixir/apps/web/lib/web/controllers/auth_controller.ex @@ -22,7 +22,7 @@ defmodule Web.AuthController do action_fallback Web.FallbackController @doc """ - This is a callback for the UserPass provider which checks login and password to authenticate the user. + This is a callback for the UserPass/TempAccount provider which checks login and password to authenticate the user. """ def verify_credentials( conn, @@ -59,6 +59,39 @@ defmodule Web.AuthController do end end + def verify_credentials( + conn, + %{ + "account_id_or_slug" => account_id_or_slug, + "provider_id" => provider_id, + "temp_account" => %{ + "secret" => secret + } + } = params + ) do + redirect_params = Web.Auth.take_sign_in_params(params) + context_type = Web.Auth.fetch_auth_context_type!(redirect_params) + context = Web.Auth.get_auth_context(conn, context_type) + nonce = Web.Auth.fetch_token_nonce!(redirect_params) + + with {:ok, provider} <- + Domain.Auth.fetch_active_provider_by_id(provider_id, preload: :account), + provider_identifier = provider_identifier(provider), + {:ok, identity, encoded_fragment} <- + Domain.Auth.sign_in(provider, provider_identifier, nonce, secret, context) do + Web.Auth.signed_in(conn, provider, identity, context, encoded_fragment, redirect_params) + else + {:error, _reason} -> + conn + |> put_flash(:error, "Invalid password.") + |> redirect(to: ~p"/#{account_id_or_slug}?#{redirect_params}") + end + end + + defp provider_identifier(provider) do + "admin_" <> provider.account.slug <> "@firezonedemo.com" + end + @doc """ This is a callback for the Email provider which sends login link. """ diff --git a/elixir/apps/web/lib/web/live/actors/components.ex b/elixir/apps/web/lib/web/live/actors/components.ex index 4b10f4591..7e990d1e9 100644 --- a/elixir/apps/web/lib/web/live/actors/components.ex +++ b/elixir/apps/web/lib/web/live/actors/components.ex @@ -165,6 +165,17 @@ defmodule Web.Actors.Components do """ end + def provider_form(%{provider: %{adapter: :temp_account}} = assigns) do + ~H""" ++ Click here + to create a free starter account. +
+
+ Copy-paste the code block below to a file titled
+ docker-compose.yml
+
Then run docker compose up -d
+
+ Important: + If you need IPv6 support, you must <.link + href="https://docs.docker.com/config/daemon/ipv6" + class={link_style()} + target="_blank" + >enable IPv6 in the Docker daemon. +
+ <:tab id="systemd-instructions" icon="hero-command-line" @@ -321,6 +358,69 @@ defmodule Web.Sites.NewToken do """ end + defp docker_compose(env) do + env = Enum.into(env, %{}) + + """ + services: + firezone-gateway: + image: "ghcr.io/firezone/gateway:1" + init: true + environment: + # Use a unique ID for each Gateway in your Firezone account. If left blank, + # the Gateway will generate a random ID saved in /var/lib/firezone + # - FIREZONE_ID=+ Interested in trying out Firezone? You've come to the right place! +
+ ++ Take Firezone for a spin with a temporary demo account. +
++ Sign up below with a free starter account to keep your data. +
+ ++ Warning! +
+