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" + }, ] }