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 <andrew@dryga.com>
This commit is contained in:
Brian Manifold
2024-03-17 20:12:25 -04:00
committed by GitHub
parent cd7cb8f2a9
commit 2c7f45cc99
8 changed files with 230 additions and 47 deletions

View File

@@ -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.
"""

View File

@@ -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 """

View File

@@ -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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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)