Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3f1bc40
Introduce `force_2fa` team policy and add API for toggling it
zoldar Oct 29, 2025
2cb7afc
Implement 2FA enforcement
zoldar Oct 30, 2025
26ecc8b
Make team policy available to CE too
zoldar Oct 30, 2025
b9ac5ef
Improve copy (h/t @metmarkosaric)
zoldar Oct 30, 2025
d09e6ee
Send email to all team members when enforcing 2FA is enabled
zoldar Nov 3, 2025
556c7b1
Only owners can enable and disable enforcing 2FA
zoldar Nov 3, 2025
1e786b9
Don't send email to the user who enabled enforcing 2FA
zoldar Nov 3, 2025
c0a12f8
Add team selection screen to 2FA enforce exceptions
zoldar Nov 3, 2025
60b1c4c
Fix email URL
zoldar Nov 3, 2025
9493e5d
Add tests
zoldar Nov 3, 2025
12923b1
Hide the Force 2FA section for non-owners completely
zoldar Nov 3, 2025
1245137
Improve e-mail formatting
zoldar Nov 3, 2025
88d4604
Point at 2FA docs page for now
zoldar Nov 3, 2025
04c7d9b
Add changelog entry
zoldar Nov 3, 2025
ce7574a
Reverse the exception to make dialyzer happy
zoldar Nov 4, 2025
e532997
Fix a typo
zoldar Nov 4, 2025
6486d13
Fix typespec :clown_face: (h/t @ukutaht)
zoldar Nov 4, 2025
bfb5b09
Deliver force 2FA email notifications asynchronously
zoldar Nov 4, 2025
edc6774
Fix a typo in AlpineJS variable name (h/t @ukutaht)
zoldar Nov 4, 2025
d45de07
Put parameter in path helper instead of concatenating it as a string
zoldar Nov 4, 2025
550d3c8
Improve and test audit logging of toggling 2FA enforcement
zoldar Nov 4, 2025
f98c1a9
Introduce 3 second delay before redirect
zoldar Nov 4, 2025
34e9836
Test audit logging only on EE
zoldar Nov 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 48 additions & 0 deletions lib/plausible/teams.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion lib/plausible/teams/memberships.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@ 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),
where: tm.team_id == ^team.id,
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

Expand Down
12 changes: 11 additions & 1 deletion lib/plausible/teams/policy.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()]
Expand All @@ -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
6 changes: 2 additions & 4 deletions lib/plausible/teams/team.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions lib/plausible_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions lib/plausible_web/controllers/settings_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
)
Expand All @@ -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, _} ->
Expand Down
7 changes: 7 additions & 0 deletions lib/plausible_web/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions lib/plausible_web/plugs/require_account.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
3 changes: 3 additions & 0 deletions lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script>
document.addEventListener("DOMContentLoaded", function () {
setInterval(function() {
document.getElementById("initiate-2fa-form").submit();
}, 3000)
});
</script>

<.focus_box>
<:title>
Setup Two-Factor Authentication
</:title>

<:subtitle>
Redirecting to 2FA setup. Click below if it doesn't happen automatically.
</:subtitle>

<div class="w-full">
<.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
</.button>
</.form>
</div>
</.focus_box>
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@
</:title>

<: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.
</.notice>
Link your Plausible account to the authenticator app you have installed either on your phone or computer.
</:subtitle>

<:footer>
<:footer :if={not @forced?}>
<.focus_list>
<:item>
Changed your mind?
Expand Down
10 changes: 10 additions & 0 deletions lib/plausible_web/templates/email/force_2fa_enabled.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{@enabling_user.email} has enabled a 2FA requirement for the "{@team.name}" team.<br /><br />
Please
<a href={
Routes.auth_url(PlausibleWeb.Endpoint, :force_initiate_2fa_setup, __team: @team.identifier)
}>
set up 2FA now
</a>
to keep access to your team on Plausible. The setup takes about a minute with any authenticator app.<br /><br />
Need help? Read our <a href="https://plausible.io/docs/2fa">2FA guide</a>
or reply to this email.
Loading
Loading