diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d8f7f03efc7..0d52136969aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ All notable changes to this project will be documented in this file.
- The new tracker script automatically updates to respect the following configuration options available in "New site" flows and "Review installation" flows: whether to track outbound links, file downloads, form submissions
- The new tracker script allows overriding almost all options by changing the snippet on the website, with the function `plausible.init({ ...your overrides... })` - this can be unique page-by-page
- A new `@plausible-analytics/tracker` ESM module is available on NPM - it has near-identical configuration API and identical tracking logic as the script and it receives bugfixes and updates concurrently with the new tracker script
+- Ability to enforce enabling 2FA by all team members
### Removed
diff --git a/lib/plausible/teams.ex b/lib/plausible/teams.ex
index 45c671e1755d..853dbdc877e9 100644
--- a/lib/plausible/teams.ex
+++ b/lib/plausible/teams.ex
@@ -320,6 +320,54 @@ defmodule Plausible.Teams do
)
end
+ @spec force_2fa_enabled?(Teams.Team.t() | nil) :: boolean()
+ def force_2fa_enabled?(nil), do: false
+
+ def force_2fa_enabled?(team) do
+ team.policy.force_2fa
+ end
+
+ @spec enable_force_2fa(Teams.Team.t(), Auth.User.t()) ::
+ {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()}
+ def enable_force_2fa(team, user) do
+ with {:ok, team} <- set_force_2fa(team, true) do
+ team
+ |> Teams.Memberships.all(exclude_guests?: true)
+ |> Enum.each(fn membership ->
+ if membership.user.id != user.id do
+ team
+ |> PlausibleWeb.Email.force_2fa_enabled(membership.user, user)
+ |> Plausible.Mailer.deliver_later()
+ end
+ end)
+
+ {:ok, team}
+ end
+ end
+
+ @spec disable_force_2fa(Teams.Team.t(), Auth.User.t(), String.t()) ::
+ {:ok, Teams.Team.t()} | {:error, :invalid_password | Ecto.Changeset.t()}
+ def disable_force_2fa(team, user, password) do
+ if Auth.Password.match?(password, user.password_hash) do
+ set_force_2fa(team, false)
+ else
+ {:error, :invalid_password}
+ end
+ end
+
+ defp set_force_2fa(team, enabled?) do
+ params = %{policy: %{force_2fa: enabled?}}
+
+ audit_entry_name = if(enabled?, do: "force_2fa_enabled", else: "force_2fa_disabled")
+
+ team
+ |> Ecto.Changeset.cast(params, [])
+ |> Ecto.Changeset.cast_embed(:policy,
+ with: &Teams.Policy.force_2fa_changeset(&1, &2.force_2fa)
+ )
+ |> Repo.update_with_audit(audit_entry_name, %{team_id: team.id})
+ end
+
# Exposed for use in tests
@doc false
def get_owned_team(user, opts \\ []) do
diff --git a/lib/plausible/teams/memberships.ex b/lib/plausible/teams/memberships.ex
index 501993c50e51..00d84f3de013 100644
--- a/lib/plausible/teams/memberships.ex
+++ b/lib/plausible/teams/memberships.ex
@@ -7,7 +7,10 @@ defmodule Plausible.Teams.Memberships do
alias Plausible.Repo
alias Plausible.Teams
- def all(team) do
+ @spec all(Teams.Team.t(), Keyword.t()) :: [Teams.Membership.t()]
+ def all(team, opts \\ []) do
+ exclude_guests? = Keyword.get(opts, :exclude_guests?, false)
+
query =
from tm in Teams.Membership,
inner_join: u in assoc(tm, :user),
@@ -15,6 +18,13 @@ defmodule Plausible.Teams.Memberships do
order_by: [asc: u.id],
preload: [user: u]
+ query =
+ if exclude_guests? do
+ from tm in query, where: tm.role != :guest
+ else
+ query
+ end
+
Repo.all(query)
end
diff --git a/lib/plausible/teams/policy.ex b/lib/plausible/teams/policy.ex
index e726e7bfa0ef..e0e16dc6de1f 100644
--- a/lib/plausible/teams/policy.ex
+++ b/lib/plausible/teams/policy.ex
@@ -25,7 +25,7 @@ defmodule Plausible.Teams.Policy do
on_ee do
@derive {Plausible.Audit.Encoder,
- only: [:force_sso, :sso_default_role, :sso_session_timeout_minutes]}
+ only: [:force_2fa, :force_sso, :sso_default_role, :sso_session_timeout_minutes]}
end
@primary_key false
@@ -47,6 +47,9 @@ defmodule Plausible.Teams.Policy do
# Default session timeout for SSO-enabled accounts. We might also
# consider accepting session timeout from assertion, if present.
field :sso_session_timeout_minutes, :integer, default: @default_timeout_minutes
+
+ # Enforce enabling 2FA for all users
+ field :force_2fa, :boolean, default: false
end
@spec sso_member_roles() :: [sso_member_role()]
@@ -69,4 +72,11 @@ defmodule Plausible.Teams.Policy do
|> cast(%{force_sso: mode}, [:force_sso])
|> validate_required(:force_sso)
end
+
+ @spec force_2fa_changeset(t(), boolean()) :: Ecto.Changeset.t()
+ def force_2fa_changeset(policy, enabled?) when is_boolean(enabled?) do
+ policy
+ |> cast(%{force_2fa: enabled?}, [:force_2fa])
+ |> validate_required(:force_2fa)
+ end
end
diff --git a/lib/plausible/teams/team.ex b/lib/plausible/teams/team.ex
index 921c39e1af6c..5f0591624d9c 100644
--- a/lib/plausible/teams/team.ex
+++ b/lib/plausible/teams/team.ex
@@ -55,10 +55,8 @@ defmodule Plausible.Teams.Team do
# Field for purely informational purposes in CRM context
field :notes, :string
- on_ee do
- # Embed for storing team-wide policies
- embeds_one :policy, Plausible.Teams.Policy, on_replace: :update, defaults_to_struct: true
- end
+ # Embed for storing team-wide policies
+ embeds_one :policy, Plausible.Teams.Policy, on_replace: :update, defaults_to_struct: true
embeds_one :grace_period, Plausible.Teams.GracePeriod, on_replace: :update
diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex
index 650be1e0ba65..9fbd6237677d 100644
--- a/lib/plausible_web/controllers/auth_controller.ex
+++ b/lib/plausible_web/controllers/auth_controller.ex
@@ -32,6 +32,7 @@ defmodule PlausibleWeb.AuthController do
:activate_form,
:activate,
:request_activation_code,
+ :force_initiate_2fa_setup,
:initiate_2fa_setup,
:verify_2fa_setup_form,
:verify_2fa_setup,
@@ -365,10 +366,19 @@ defmodule PlausibleWeb.AuthController do
end
end
- def initiate_2fa_setup(conn, _params) do
+ def force_initiate_2fa_setup(conn, _params) do
+ render(conn, "force_initiate_2fa_setup.html")
+ end
+
+ def initiate_2fa_setup(conn, params) do
case Auth.TOTP.initiate(conn.assigns.current_user) do
{:ok, user, %{totp_uri: totp_uri, secret: secret}} ->
- render(conn, "initiate_2fa_setup.html", user: user, totp_uri: totp_uri, secret: secret)
+ render(conn, "initiate_2fa_setup.html",
+ user: user,
+ totp_uri: totp_uri,
+ secret: secret,
+ forced?: params["force"] == "true"
+ )
{:error, :already_setup} ->
conn
diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex
index 2d077b80b7ab..87c7503690a0 100644
--- a/lib/plausible_web/controllers/settings_controller.ex
+++ b/lib/plausible_web/controllers/settings_controller.ex
@@ -9,13 +9,20 @@ defmodule PlausibleWeb.SettingsController do
require Logger
plug Plausible.Plugs.AuthorizeTeamAccess,
- [:owner, :admin] when action in [:update_team_name]
+ [:owner, :admin]
+ when action in [:update_team_name]
plug Plausible.Plugs.AuthorizeTeamAccess,
[:owner, :billing] when action in [:subscription, :invoices]
plug Plausible.Plugs.AuthorizeTeamAccess,
- [:owner] when action in [:team_danger_zone, :delete_team]
+ [:owner]
+ when action in [
+ :team_danger_zone,
+ :delete_team,
+ :enable_team_force_2fa,
+ :disable_team_force_2fa
+ ]
plug Plausible.Plugs.RestrictUserType,
[deny: :sso] when action in [:update_name, :update_email, :update_password]
@@ -48,11 +55,12 @@ defmodule PlausibleWeb.SettingsController do
Keyword.get(
opts,
:team_name_changeset,
- Plausible.Teams.Team.name_changeset(conn.assigns.current_team)
+ Teams.Team.name_changeset(conn.assigns.current_team)
)
render(conn, :team_general,
team_name_changeset: name_changeset,
+ force_2fa_enabled?: Teams.force_2fa_enabled?(conn.assigns.current_team),
layout: {PlausibleWeb.LayoutView, :settings},
connect_live_socket: true
)
@@ -62,6 +70,45 @@ defmodule PlausibleWeb.SettingsController do
end
end
+ def enable_team_force_2fa(conn, _params) do
+ team = conn.assigns.current_team
+ user = conn.assigns.current_user
+
+ case Teams.enable_force_2fa(team, user) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:success, "2FA is now required for all team members.")
+ |> redirect(to: Routes.settings_path(conn, :team_general))
+
+ {:error, _} ->
+ conn
+ |> put_flash(:error, "Failed to enforce 2FA for all team members.")
+ |> redirect(to: Routes.settings_path(conn, :team_general))
+ end
+ end
+
+ def disable_team_force_2fa(conn, %{"password" => password}) do
+ team = conn.assigns.current_team
+ user = conn.assigns.current_user
+
+ case Teams.disable_force_2fa(team, user, password) do
+ {:ok, _} ->
+ conn
+ |> put_flash(:success, "2FA is no longer enforced for team members.")
+ |> redirect(to: Routes.settings_path(conn, :team_general))
+
+ {:error, :invalid_password} ->
+ conn
+ |> put_flash(:error, "Incorrect password provided.")
+ |> redirect(to: Routes.settings_path(conn, :team_general))
+
+ {:error, _} ->
+ conn
+ |> put_flash(:error, "Failed to disable enforcing 2FA for all team members.")
+ |> redirect(to: Routes.settings_path(conn, :team_general))
+ end
+ end
+
def leave_team(conn, _params) do
case Teams.Memberships.Leave.leave(conn.assigns.current_team, conn.assigns.current_user) do
{:ok, _} ->
diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex
index b6359b66124e..90769b7b0900 100644
--- a/lib/plausible_web/email.ex
+++ b/lib/plausible_web/email.ex
@@ -571,6 +571,13 @@ defmodule PlausibleWeb.Email do
end
end
+ def force_2fa_enabled(team, user, enabling_user) do
+ priority_email()
+ |> to(user.email)
+ |> subject("Your team now requires 2FA")
+ |> render("force_2fa_enabled.html", team: team, enabling_user: enabling_user)
+ end
+
@doc """
Unlike the default 'base' emails, priority emails cannot be unsubscribed from. This is achieved
by sending them through a dedicated 'priority' message stream in Postmark.
diff --git a/lib/plausible_web/plugs/require_account.ex b/lib/plausible_web/plugs/require_account.ex
index e69eb2904b49..53b7a88cbadc 100644
--- a/lib/plausible_web/plugs/require_account.ex
+++ b/lib/plausible_web/plugs/require_account.ex
@@ -9,11 +9,24 @@ defmodule PlausibleWeb.RequireAccountPlug do
["me"]
]
+ @force_2fa_exceptions [
+ ["2fa", "setup", "force-initiate"],
+ ["2fa", "setup", "initiate"],
+ ["2fa", "setup", "verify"],
+ ["team", "select"]
+ ]
+
def init(options) do
options
end
def call(conn, _opts) do
+ conn
+ |> require_verified_user()
+ |> maybe_force_2fa()
+ end
+
+ defp require_verified_user(conn) do
user = conn.assigns[:current_user]
cond do
@@ -33,6 +46,21 @@ defmodule PlausibleWeb.RequireAccountPlug do
end
end
+ defp maybe_force_2fa(%{halted: true} = conn), do: conn
+
+ defp maybe_force_2fa(conn) do
+ user = conn.assigns[:current_user]
+ team = conn.assigns[:current_team]
+
+ if conn.path_info not in @force_2fa_exceptions and must_enable_2fa?(user, team) do
+ conn
+ |> Phoenix.Controller.redirect(to: Routes.auth_path(conn, :force_initiate_2fa_setup))
+ |> halt()
+ else
+ conn
+ end
+ end
+
defp redirect_to(%Plug.Conn{method: "GET"} = conn) do
return_to =
if conn.query_string && String.length(conn.query_string) > 0 do
@@ -45,4 +73,10 @@ defmodule PlausibleWeb.RequireAccountPlug do
end
defp redirect_to(conn), do: Routes.auth_path(conn, :login_form)
+
+ defp must_enable_2fa?(user, team) when is_nil(user) or is_nil(team), do: false
+
+ defp must_enable_2fa?(user, team) do
+ not Plausible.Auth.TOTP.enabled?(user) and Plausible.Teams.force_2fa_enabled?(team)
+ end
end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index 386e849f0b9e..dadd328576f6 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -439,6 +439,7 @@ defmodule PlausibleWeb.Router do
post "/login", AuthController, :login
get "/password/request-reset", AuthController, :password_reset_request_form
post "/password/request-reset", AuthController, :password_reset_request
+ get "/2fa/setup/force-initiate", AuthController, :force_initiate_2fa_setup
post "/2fa/setup/initiate", AuthController, :initiate_2fa_setup
get "/2fa/setup/verify", AuthController, :verify_2fa_setup_form
post "/2fa/setup/verify", AuthController, :verify_2fa_setup
@@ -490,6 +491,8 @@ defmodule PlausibleWeb.Router do
get "/team/general", SettingsController, :team_general
post "/team/general/name", SettingsController, :update_team_name
post "/team/leave", SettingsController, :leave_team
+ post "/team/force_2fa/enable", SettingsController, :enable_team_force_2fa
+ post "/team/force_2fa/disable", SettingsController, :disable_team_force_2fa
on_ee do
get "/sso/info", SSOController, :cta
diff --git a/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex b/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex
new file mode 100644
index 000000000000..562f84b97cbe
--- /dev/null
+++ b/lib/plausible_web/templates/auth/force_initiate_2fa_setup.html.heex
@@ -0,0 +1,30 @@
+
+
+<.focus_box>
+ <:title>
+ Setup Two-Factor Authentication
+
+
+ <:subtitle>
+ Redirecting to 2FA setup. Click below if it doesn't happen automatically.
+
+
+
+ <.form
+ id="initiate-2fa-form"
+ action={Routes.auth_path(@conn, :initiate_2fa_setup, force: "true")}
+ for={@conn.params}
+ method="post"
+ >
+ <.button type="submit" class="w-full" mt?={false}>
+ Proceed
+
+
+
+
diff --git a/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex b/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex
index 753102c3f9fd..1b67b37db29b 100644
--- a/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex
+++ b/lib/plausible_web/templates/auth/initiate_2fa_setup.html.heex
@@ -4,10 +4,13 @@
<:subtitle>
+ <.notice :if={@forced?} class="mb-2" theme={:gray} title="Team requires 2FA setup">
+ You've been redirected here because your team enforces 2FA. Please complete the setup or switch to another team.
+
Link your Plausible account to the authenticator app you have installed either on your phone or computer.
- <:footer>
+ <:footer :if={not @forced?}>
<.focus_list>
<:item>
Changed your mind?
diff --git a/lib/plausible_web/templates/email/force_2fa_enabled.html.heex b/lib/plausible_web/templates/email/force_2fa_enabled.html.heex
new file mode 100644
index 000000000000..87da4c6c6c68
--- /dev/null
+++ b/lib/plausible_web/templates/email/force_2fa_enabled.html.heex
@@ -0,0 +1,10 @@
+{@enabling_user.email} has enabled a 2FA requirement for the "{@team.name}" team.
+Please
+
+ set up 2FA now
+
+to keep access to your team on Plausible. The setup takes about a minute with any authenticator app.
+Need help? Read our 2FA guide
+or reply to this email.
diff --git a/lib/plausible_web/templates/settings/team_general.html.heex b/lib/plausible_web/templates/settings/team_general.html.heex
index 3a291fed4ecb..f084bb19a2df 100644
--- a/lib/plausible_web/templates/settings/team_general.html.heex
+++ b/lib/plausible_web/templates/settings/team_general.html.heex
@@ -43,6 +43,86 @@
session: %{"mode" => "team-management"}
)}
+ <.tile :if={@current_team_role == :owner} docs="2fa">
+ <:title>
+ Force Two-Factor Authentication (2FA)
+
+ <:subtitle>
+ Increase account security by requiring all team members to enable 2FA.
+
+
+
+ <.form
+ action={Routes.settings_path(@conn, :disable_team_force_2fa)}
+ for={@conn.params}
+ method="post"
+ >
+ <.button
+ theme="danger"
+ x-on:click="disableTeamForce2FAOpen = true; $refs.disableTeamForce2FAPassword.value = ''"
+ mt?={false}
+ >
+ Stop Forcing 2FA
+
+
+
+ <:icon>
+
+
+ <:buttons>
+ <.button type="submit" class="w-full sm:w-auto">
+ Stop enforcing 2FA
+
+
+
+
+ This will remove the 2FA requirement for all team members.
+
+
+
+ Enter your password to stop enforcing 2FA.
+
+
+
+ <.input
+ type="password"
+ id="disable_team_force_2fa_password"
+ name="password"
+ value=""
+ placeholder="Enter password"
+ x-ref="disableTeamForce2FAPassword"
+ />
+
+
+
+
+
+
+ <.form
+ action={Routes.settings_path(@conn, :enable_team_force_2fa)}
+ for={@conn.params}
+ method="post"
+ >
+ <.button
+ data-confirm="All team members, including you, will need to set up 2FA. Are you sure you want to enforce it?"
+ type="submit"
+ mt?={false}
+ >
+ Enforce 2FA
+
+
+
+
<.tile docs="users-roles#leaving-team">
<:title>Leave team
<:subtitle>Remove yourself from this team as a member.
diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs
index 7b274133b62e..34427cf00dc6 100644
--- a/test/plausible_web/controllers/auth_controller_test.exs
+++ b/test/plausible_web/controllers/auth_controller_test.exs
@@ -1147,6 +1147,14 @@ defmodule PlausibleWeb.AuthControllerTest do
assert element_exists?(html, "svg")
assert html =~ secret
+ refute html =~ "You've been redirected here because your team enforces 2FA."
+ end
+
+ test "shows additional notice when `force` parameter set", %{conn: conn} do
+ conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup, force: "true"))
+
+ assert html = html_response(conn, 200)
+ assert html =~ "You've been redirected here because your team enforces 2FA."
end
test "redirects back to settings if 2FA is already setup", %{conn: conn, user: user} do
diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs
index 1d97a5fe7f8a..1e305c4be71f 100644
--- a/test/plausible_web/controllers/settings_controller_test.exs
+++ b/test/plausible_web/controllers/settings_controller_test.exs
@@ -1703,6 +1703,285 @@ defmodule PlausibleWeb.SettingsControllerTest do
end
end
+ describe "POST /team/force_2fa/enable" do
+ setup [:create_user, :log_in, :create_team, :setup_team]
+
+ test "enables enforcing 2FA", %{conn: conn, team: team} do
+ refute team.policy.force_2fa
+
+ conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa))
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :success) =~
+ "2FA is now required for all team members"
+
+ assert Repo.reload!(team).policy.force_2fa
+ end
+
+ on_ee do
+ test "adds entry to audit log", %{conn: conn, team: team, user: user} do
+ conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa))
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+
+ assert_matches [
+ %{
+ name: "force_2fa_enabled",
+ user_id: ^user.id,
+ team_id: ^team.id,
+ actor_type: :user,
+ change: %{
+ "before" => %{"policy" => %{"force_2fa" => false}},
+ "after" => %{"policy" => %{"after" => %{"force_2fa" => true}}}
+ }
+ }
+ ] =
+ Plausible.Audit.list_entries(
+ entity: "Plausible.Teams.Team",
+ entity_id: "#{team.id}"
+ )
+ end
+ end
+
+ test "sends e-mail to all other team members", %{conn: conn, team: team, user: user} do
+ site = new_site(team: team)
+
+ member1 = add_member(team, role: :viewer)
+ member2 = add_member(team, role: :owner)
+ guest = add_guest(site, role: :viewer)
+
+ conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa))
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+
+ assert_email_delivered_with(
+ subject: "Your team now requires 2FA",
+ to: [nil: member1.email]
+ )
+
+ assert_email_delivered_with(
+ subject: "Your team now requires 2FA",
+ to: [nil: member2.email]
+ )
+
+ # guests are not notified because they are not affected
+ refute_email_delivered_with(
+ subject: "Your team now requires 2FA",
+ to: [nil: guest.email]
+ )
+
+ # the user enabling the enforcement is not notified
+ refute_email_delivered_with(
+ subject: "Your team now requires 2FA",
+ to: [nil: user.email]
+ )
+ end
+
+ test "is idempotent", %{conn: conn, user: user, team: team} do
+ {:ok, team} = Plausible.Teams.disable_force_2fa(team, user, "password")
+
+ conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa))
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+ assert Repo.reload!(team).policy.force_2fa
+ end
+
+ test "can't be enabled by anyone other than owner", %{conn: conn, team: team} do
+ admin = add_member(team, role: :admin)
+ {:ok, ctx} = log_in(%{conn: conn, user: admin})
+
+ conn =
+ ctx
+ |> Keyword.fetch!(:conn)
+ |> set_current_team(team)
+
+ conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa))
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+ refute Repo.reload!(team).policy.force_2fa
+ end
+ end
+
+ describe "POST /team/force_2fa/disable" do
+ setup [:create_user, :log_in, :create_team, :setup_team]
+
+ setup %{user: user} do
+ # enable 2FA
+ {:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
+ code = NimbleTOTP.verification_code(user.totp_secret)
+ {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code)
+
+ {:ok, user: user}
+ end
+
+ test "disables enforcing 2FA", %{conn: conn, team: team, user: user} do
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ conn =
+ post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{
+ "password" => "password"
+ })
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+
+ assert Phoenix.Flash.get(conn.assigns.flash, :success) =~
+ "2FA is no longer enforced for team members"
+
+ refute Repo.reload!(team).policy.force_2fa
+ end
+
+ on_ee do
+ test "adds entry to audit log", %{conn: conn, user: user, team: team} do
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ conn =
+ post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{
+ "password" => "password"
+ })
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+
+ assert_matches [
+ %{
+ name: "force_2fa_disabled",
+ user_id: ^user.id,
+ team_id: ^team.id,
+ actor_type: :user,
+ change: %{
+ "before" => %{"policy" => %{"force_2fa" => true}},
+ "after" => %{"policy" => %{"after" => %{"force_2fa" => false}}}
+ }
+ },
+ _
+ ] =
+ Plausible.Audit.list_entries(
+ entity: "Plausible.Teams.Team",
+ entity_id: "#{team.id}"
+ )
+ end
+ end
+
+ test "is idempotent", %{conn: conn, team: team} do
+ conn =
+ post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{
+ "password" => "password"
+ })
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+ refute Repo.reload!(team).policy.force_2fa
+ end
+
+ test "returns error on invalid password", %{conn: conn} do
+ conn =
+ post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{"password" => "invalid"})
+
+ assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general)
+ assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided"
+ end
+
+ test "can't be disabled by anyone other than owner", %{conn: conn, team: team, user: user} do
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ admin = add_member(team, role: :admin)
+
+ # enable TOTP for admin
+ {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin)
+ code = NimbleTOTP.verification_code(admin.totp_secret)
+ {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code)
+
+ {:ok, ctx} = log_in(%{conn: conn, user: admin})
+
+ conn =
+ ctx
+ |> Keyword.fetch!(:conn)
+ |> set_current_team(team)
+
+ conn =
+ post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{
+ "password" => "password"
+ })
+
+ assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
+ assert Repo.reload!(team).policy.force_2fa
+ end
+ end
+
+ describe "GET /settings/team/general - enforce 2FA disabled" do
+ setup [:create_user, :log_in, :create_team, :setup_team]
+
+ test "is visible to owner", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :team_general))
+ html = html_response(conn, 200)
+
+ assert element_exists?(html, "div#enable-force-2fa")
+ refute element_exists?(html, "div#disable-force-2fa")
+ end
+
+ test "is not visible to anyone other than owner", %{conn: conn, team: team} do
+ admin = add_member(team, role: :admin)
+ {:ok, ctx} = log_in(%{conn: conn, user: admin})
+
+ conn =
+ ctx
+ |> Keyword.fetch!(:conn)
+ |> set_current_team(team)
+
+ conn = get(conn, Routes.settings_path(conn, :team_general))
+ html = html_response(conn, 200)
+
+ refute element_exists?(html, "div#force-2fa")
+ refute element_exists?(html, "div#enable-force-2fa")
+ refute element_exists?(html, "div#disable-force-2fa")
+ end
+ end
+
+ describe "GET /settings/team/general - enforce 2FA enabled" do
+ setup [:create_user, :log_in, :create_team, :setup_team]
+
+ setup %{user: user, team: team} do
+ # enable 2FA
+ {:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
+ code = NimbleTOTP.verification_code(user.totp_secret)
+ {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code)
+
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ {:ok, user: user, team: team}
+ end
+
+ test "is visible to owner", %{conn: conn} do
+ conn = get(conn, Routes.settings_path(conn, :team_general))
+ html = html_response(conn, 200)
+
+ refute element_exists?(html, "div#enable-force-2fa")
+ assert element_exists?(html, "div#disable-force-2fa")
+ end
+
+ test "is not visible to anyone other than owner", %{conn: conn, team: team} do
+ admin = add_member(team, role: :admin)
+
+ # enable TOTP for admin
+ {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin)
+ code = NimbleTOTP.verification_code(admin.totp_secret)
+ {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code)
+
+ {:ok, ctx} = log_in(%{conn: conn, user: admin})
+
+ conn =
+ ctx
+ |> Keyword.fetch!(:conn)
+ |> set_current_team(team)
+
+ conn = get(conn, Routes.settings_path(conn, :team_general))
+ html = html_response(conn, 200)
+
+ refute element_exists?(html, "div#force-2fa")
+ refute element_exists?(html, "div#enable-force-2fa")
+ refute element_exists?(html, "div#disable-force-2fa")
+ end
+ end
+
describe "account dropdown menu (_header.html)" do
setup [:create_user, :log_in]
diff --git a/test/plausible_web/plugs/require_account_plug_test.exs b/test/plausible_web/plugs/require_account_plug_test.exs
new file mode 100644
index 000000000000..eb5e775b44bd
--- /dev/null
+++ b/test/plausible_web/plugs/require_account_plug_test.exs
@@ -0,0 +1,81 @@
+defmodule PlausibleWeb.RequireAccountPlugTest do
+ use PlausibleWeb.ConnCase, async: true
+ use Plausible.Teams.Test
+
+ import Plug.Conn
+
+ alias PlausibleWeb.RequireAccountPlug
+ alias PlausibleWeb.Router.Helpers, as: Routes
+
+ describe "enforcing 2FA" do
+ test "passes when 2FA enforcement is disabled" do
+ user = new_user()
+ team = new_site(owner: user).team
+ team = Plausible.Teams.complete_setup(team)
+
+ conn =
+ build_conn()
+ |> assign(:current_user, user)
+ |> assign(:current_team, team)
+ |> RequireAccountPlug.call(nil)
+
+ refute conn.halted
+ end
+
+ test "redirects when 2FA enforcement is enabled" do
+ user = new_user()
+ team = new_site(owner: user).team
+ team = Plausible.Teams.complete_setup(team)
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ conn =
+ build_conn()
+ |> assign(:current_user, user)
+ |> assign(:current_team, team)
+ |> RequireAccountPlug.call(nil)
+
+ assert conn.halted
+ assert redirected_to(conn, 302) == Routes.auth_path(conn, :force_initiate_2fa_setup)
+ end
+
+ test "does not override for unverified account" do
+ user = new_user(email_verified: false)
+ team = new_site(owner: user).team
+ team = Plausible.Teams.complete_setup(team)
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ conn =
+ build_conn()
+ |> assign(:current_user, user)
+ |> assign(:current_team, team)
+ |> RequireAccountPlug.call(nil)
+
+ assert conn.halted
+ assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate_form)
+ end
+
+ @force_2fa_exceptions [
+ "/2fa/setup/force-initiate",
+ "/2fa/setup/initiate",
+ "/2fa/setup/verify",
+ "/team/select"
+ ]
+
+ for path <- @force_2fa_exceptions do
+ test "does not redirect if the path is #{path}" do
+ user = new_user()
+ team = new_site(owner: user).team
+ team = Plausible.Teams.complete_setup(team)
+ {:ok, team} = Plausible.Teams.enable_force_2fa(team, user)
+
+ conn =
+ build_conn(:get, unquote(path))
+ |> assign(:current_user, user)
+ |> assign(:current_team, team)
+ |> RequireAccountPlug.call(nil)
+
+ refute conn.halted
+ end
+ end
+ end
+end