From 2c7f45cc99ea5d659b1ddc3c7e28dd800de47129 Mon Sep 17 00:00:00 2001 From: Brian Manifold Date: Sun, 17 Mar 2024 20:12:25 -0400 Subject: [PATCH] feat(portal): Add sign up override in portal (#3739) Why: * In order to allow easy testing of billing / Stripe integration, the staging environment needs to allow members of the Firezone team access to create new accounts, while disallowing the general public to create accounts. The account creation override functionality allows for multiple domains to be set by ENV variable by passing a comma separated string of domains. --------- Co-authored-by: Andrew Dryga --- .../domain/lib/domain/config/definitions.ex | 12 ++ .../apps/domain/lib/domain/repo/changeset.ex | 59 +++++++++ .../apps/domain/test/support/fixtures/auth.ex | 2 +- elixir/apps/web/lib/web/live/sign_up.ex | 116 +++++++++++------- .../apps/web/test/web/live/sign_up_test.exs | 75 +++++++++++ elixir/config/config.exs | 2 + elixir/config/runtime.exs | 2 + terraform/environments/staging/portal.tf | 9 +- 8 files changed, 230 insertions(+), 47 deletions(-) diff --git a/elixir/apps/domain/lib/domain/config/definitions.ex b/elixir/apps/domain/lib/domain/config/definitions.ex index 2dc43177d..0a21c3c8c 100644 --- a/elixir/apps/domain/lib/domain/config/definitions.ex +++ b/elixir/apps/domain/lib/domain/config/definitions.ex @@ -562,6 +562,18 @@ defmodule Domain.Config.Definitions do """ defconfig(:feature_sign_up_enabled, :boolean, default: true) + @doc """ + List of email domains allowed to signup from. Leave empty to allow signing up from any domain. + """ + defconfig(:sign_up_whitelisted_domains, {:array, ",", :string}, + default: [], + changeset: fn changeset, key -> + changeset + |> Ecto.Changeset.validate_required(key) + |> Domain.Repo.Changeset.validate_fqdn(key) + end + ) + @doc """ Boolean flag to turn IdP sync on/off for all accounts. """ diff --git a/elixir/apps/domain/lib/domain/repo/changeset.ex b/elixir/apps/domain/lib/domain/repo/changeset.ex index 2b3cb2e78..6932bd129 100644 --- a/elixir/apps/domain/lib/domain/repo/changeset.ex +++ b/elixir/apps/domain/lib/domain/repo/changeset.ex @@ -314,6 +314,65 @@ defmodule Domain.Repo.Changeset do end) end + def validate_fqdn(changeset, field, opts \\ []) do + allow_port = Keyword.get(opts, :allow_port, false) + + validate_change(changeset, field, fn _current_field, value -> + {fqdn, port} = split_port(value) + fqdn_validation_errors = fqdn_validation_errors(field, fqdn) + port_validation_errors = port_validation_errors(field, port, allow_port) + fqdn_validation_errors ++ port_validation_errors + end) + end + + defp fqdn_validation_errors(field, fqdn) do + if Regex.match?(~r/^([a-zA-Z0-9._-])+$/i, fqdn) do + [] + else + [{field, "#{fqdn} is not a valid FQDN"}] + end + end + + defp split_port(value) do + case String.split(value, ":", parts: 2) do + [prefix, port] -> + case Integer.parse(port) do + {port, ""} -> + {prefix, port} + + _ -> + {value, nil} + end + + [value] -> + {value, nil} + end + end + + defp port_validation_errors(_field, nil, _allow?), + do: [] + + defp port_validation_errors(field, _port, false), + do: [{field, "setting port is not allowed"}] + + defp port_validation_errors(_field, port, _allow?) when 0 < port and port <= 65_535, + do: [] + + defp port_validation_errors(field, _port, _allow?), + do: [{field, "port is not a number between 0 and 65535"}] + + def validate_ip_type_inclusion(changeset, field, types) do + validate_change(changeset, field, fn _current_field, %{address: address} -> + type = if tuple_size(address) == 4, do: :ipv4, else: :ipv6 + + if type in types do + [] + else + [{field, "is not a supported IP type"}] + end + end) + end + # Polymorphic embeds @doc """ diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index 1693d4930..956cd58fe 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -5,7 +5,7 @@ defmodule Domain.Fixtures.Auth do def user_password, do: "Hello w0rld!" def remote_ip, do: {100, 64, 100, 58} def user_agent, do: "iOS/12.5 (iPhone) connlib/0.7.412" - def email, do: "user-#{unique_integer()}@example.com" + def email(domain \\ "example.com"), do: "user-#{unique_integer()}@#{domain}" def random_provider_identifier(%Domain.Auth.Provider{adapter: :email, name: name}) do "user-#{unique_integer()}@#{String.downcase(name)}.com" diff --git a/elixir/apps/web/lib/web/live/sign_up.ex b/elixir/apps/web/lib/web/live/sign_up.ex index 08e8cf6a4..6a0c843f7 100644 --- a/elixir/apps/web/lib/web/live/sign_up.ex +++ b/elixir/apps/web/lib/web/live/sign_up.ex @@ -17,10 +17,13 @@ defmodule Web.SignUp do end def changeset(attrs) do + whitelisted_domains = Domain.Config.get_env(:domain, :sign_up_whitelisted_domains) + %Registration{} |> Ecto.Changeset.cast(attrs, [:email]) |> Ecto.Changeset.validate_required([:email]) |> Ecto.Changeset.validate_format(:email, ~r/.+@.+/) + |> validate_email_allowed(whitelisted_domains) |> Ecto.Changeset.validate_confirmation(:email, required: true, message: "email does not match" @@ -32,11 +35,32 @@ defmodule Web.SignUp do with: fn _account, attrs -> Actors.Actor.Changeset.create(attrs) end ) end + + defp validate_email_allowed(changeset, []) do + changeset + end + + defp validate_email_allowed(changeset, whitelisted_domains) do + Ecto.Changeset.validate_change(changeset, :email, fn :email, email -> + if email_allowed?(email, whitelisted_domains), + do: [], + else: [email: "email domain is not allowed at this time"] + end) + end + + defp email_allowed?(email, whitelisted_domains) do + with [_, domain] <- String.split(email, "@", parts: 2) do + Enum.member?(whitelisted_domains, domain) + else + _ -> false + end + end end def mount(_params, _session, socket) do user_agent = Phoenix.LiveView.get_connect_info(socket, :user_agent) real_ip = Web.Auth.real_ip(socket) + sign_up_enabled? = Config.sign_up_enabled?() changeset = Registration.changeset(%{ @@ -46,15 +70,15 @@ defmodule Web.SignUp do socket = assign(socket, + page_title: "Sign Up", form: to_form(changeset), account: nil, provider: nil, user_agent: user_agent, real_ip: real_ip, - sign_up_enabled?: Config.sign_up_enabled?(), + sign_up_enabled?: sign_up_enabled?, account_name_changed?: false, - actor_name_changed?: false, - page_title: "Sign Up" + actor_name_changed?: false ) {:ok, socket, temporary_assigns: [form: %Phoenix.HTML.Form{}]} @@ -290,49 +314,10 @@ defmodule Web.SignUp do |> Registration.changeset() |> Map.put(:action, :insert) - if changeset.valid? && socket.assigns.sign_up_enabled? do + if changeset.valid? and socket.assigns.sign_up_enabled? do registration = Ecto.Changeset.apply_changes(changeset) - multi = - Ecto.Multi.new() - |> Ecto.Multi.run( - :account, - fn _repo, _changes -> - Accounts.create_account(%{ - name: registration.account.name - }) - end - ) - |> Ecto.Multi.run( - :provider, - fn _repo, %{account: account} -> - Auth.create_provider(account, %{ - name: "Email", - adapter: :email, - adapter_config: %{} - }) - end - ) - |> Ecto.Multi.run( - :actor, - fn _repo, %{account: account} -> - Actors.create_actor(account, %{ - type: :account_admin_user, - name: registration.actor.name - }) - end - ) - |> Ecto.Multi.run( - :identity, - fn _repo, %{actor: actor, provider: provider} -> - Auth.create_identity(actor, provider, %{ - provider_identifier: registration.email, - provider_identifier_confirmation: registration.email - }) - end - ) - - case Domain.Repo.transaction(multi) do + case register_account(registration) do {:ok, %{account: account, provider: provider, identity: identity}} -> {:ok, account} = Domain.Billing.provision_account(account) @@ -385,4 +370,47 @@ defmodule Web.SignUp do [default_name | _] = String.split(attrs["email"], "@", parts: 2) put_in(attrs, ["actor", "name"], default_name) end + + defp register_account(registration) do + multi = + Ecto.Multi.new() + |> Ecto.Multi.run( + :account, + fn _repo, _changes -> + Accounts.create_account(%{ + name: registration.account.name + }) + end + ) + |> Ecto.Multi.run( + :provider, + fn _repo, %{account: account} -> + Auth.create_provider(account, %{ + name: "Email", + adapter: :email, + adapter_config: %{} + }) + end + ) + |> Ecto.Multi.run( + :actor, + fn _repo, %{account: account} -> + Actors.create_actor(account, %{ + type: :account_admin_user, + name: registration.actor.name + }) + end + ) + |> Ecto.Multi.run( + :identity, + fn _repo, %{actor: actor, provider: provider} -> + Auth.create_identity(actor, provider, %{ + provider_identifier: registration.email, + provider_identifier_confirmation: registration.email + }) + end + ) + + Domain.Repo.transaction(multi) + end end diff --git a/elixir/apps/web/test/web/live/sign_up_test.exs b/elixir/apps/web/test/web/live/sign_up_test.exs index dd989e6b3..4d2b74e7e 100644 --- a/elixir/apps/web/test/web/live/sign_up_test.exs +++ b/elixir/apps/web/test/web/live/sign_up_test.exs @@ -68,6 +68,70 @@ defmodule Web.Live.SignUpTest do end) end + test "allows whitelisted domains to create new account", %{conn: conn} do + whitelisted_domain = "example.com" + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + Domain.Config.put_env_override(:sign_up_whitelisted_domains, [whitelisted_domain]) + + account_name = "FooBar" + + attrs = %{ + account: %{name: account_name}, + actor: %{name: "John Doe"}, + email: Fixtures.Auth.email(whitelisted_domain) + } + + Bypass.open() + |> Domain.Mocks.Stripe.mock_create_customer_endpoint(%{ + id: Ecto.UUID.generate(), + name: account_name + }) + |> Domain.Mocks.Stripe.mock_create_subscription_endpoint() + + {:ok, lv, _html} = live(conn, ~p"/sign_up") + + html = + lv + |> form("form", registration: attrs) + |> render_submit() + + assert html =~ "Your account has been created!" + assert html =~ account_name + end + + test "does not show account creation form when sign ups are disabled", %{conn: conn} do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + Domain.Config.feature_flag_override(:sign_up, false) + + {:ok, lv, _html} = live(conn, ~p"/sign_up") + refute has_element?(lv, "form") + end + + test "does not allow to create account from not whitelisted domain", %{conn: conn} do + Domain.Config.put_env_override(:outbound_email_adapter_configured?, true) + Domain.Config.feature_flag_override(:sign_up, true) + Domain.Config.put_env_override(:sign_up_whitelisted_domains, ["firezone.dev"]) + + account_name = "FooBar" + + {:ok, lv, _html} = live(conn, ~p"/sign_up") + + email = Fixtures.Auth.email() + + attrs = %{ + account: %{name: account_name}, + actor: %{name: "John Doe"}, + email: email + } + + assert html = + lv + |> form("form", registration: attrs) + |> render_submit() + + assert html =~ "email domain is not allowed at this time" + end + test "renders changeset errors on input change", %{conn: conn} do {:ok, lv, _html} = live(conn, ~p"/sign_up") @@ -108,8 +172,19 @@ defmodule Web.Live.SignUpTest do } end + test "renders changeset errors on submit when sign up disabled", %{ + conn: conn + } do + Domain.Config.feature_flag_override(:sign_up, false) + Domain.Config.put_env_override(:sign_up_whitelisted_domains, ["foo.com"]) + + {:ok, _lv, html} = live(conn, ~p"/sign_up") + assert html =~ "Sign-ups are currently disabled." + end + test "renders sign up disabled message", %{conn: conn} do Domain.Config.feature_flag_override(:sign_up, false) + Domain.Config.put_env_override(:sign_up_whitelisted_domains, []) {:ok, _lv, html} = live(conn, ~p"/sign_up") diff --git a/elixir/config/config.exs b/elixir/config/config.exs index 86f372996..004a53d65 100644 --- a/elixir/config/config.exs +++ b/elixir/config/config.exs @@ -87,6 +87,8 @@ config :domain, :enabled_features, self_hosted_relays: true, multi_site_resources: true +config :domain, sign_up_whitelisted_domains: [] + config :domain, docker_registry: "us-east1-docker.pkg.dev/firezone-staging/firezone" config :domain, outbound_email_adapter_configured?: false diff --git a/elixir/config/runtime.exs b/elixir/config/runtime.exs index d523f841f..34b0d2a2c 100644 --- a/elixir/config/runtime.exs +++ b/elixir/config/runtime.exs @@ -61,6 +61,8 @@ if config_env() == :prod do self_hosted_relays: compile_config!(:feature_self_hosted_relays_enabled), multi_site_resources: compile_config!(:feature_multi_site_resources_enabled) + config :domain, sign_up_whitelisted_domains: compile_config!(:sign_up_whitelisted_domains) + config :domain, docker_registry: compile_config!(:docker_registry) config :domain, outbound_email_adapter_configured?: !!compile_config!(:outbound_email_adapter) diff --git a/terraform/environments/staging/portal.tf b/terraform/environments/staging/portal.tf index c8ed9fe36..5a2c5bf58 100644 --- a/terraform/environments/staging/portal.tf +++ b/terraform/environments/staging/portal.tf @@ -331,8 +331,13 @@ locals { }, { name = "FEATURE_SIGN_UP_ENABLED" - value = false - } + value = true + }, + # Sign Up + { + name = "SIGN_UP_WHITELISTED_DOMAINS" + value = "firezone.dev,firez.one" + }, ] }