Revise system behaviour for SSO users (#5506)
* Setup MFA properly in SSO tests
* Move `new_identity` test helper to common helpers
* Make standard login only allow Owner SSO users
* Implement `Plausible.Users.type/1` for determining user type
* Implement plug restricting action based on user type
* Restrict or adjust access to settings actions to SSO users
* Make a very small refactor to `Auth.SSO` helper
* Prevent SSO users from acceptig team invitations
* Prevent SSO users from adding websites under "My Presonal Sites"
* Prevent implicit team creation by SSO users
* Add workaround for compiler warning under CE
* Remove SSO user on removing membership
* Prevent changing role to owner when 2FA not enabled
* Prevent provisioning from standard user with active personal team
* Fix `Auth.lookup/1` to not break for standard users on multiple teams
* Use `Plausible.always/1` (h/t @aerosol)
* Revert "Use `Plausible.always/1` (h/t @aerosol)"
This reverts commit 0ee7dd84d3.
* Rename `RestrictType` -> `RestrictUserType`
* Make the configuration intent more explicit in `RestrictUserType` plug
* Rename plug file
This commit is contained in:
parent
6ade93bf86
commit
a2ed1e04b1
|
|
@ -8,11 +8,14 @@ defmodule Plausible.Auth.SSO do
|
|||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Auth.SSO
|
||||
alias Plausible.Billing.Subscription
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Teams
|
||||
|
||||
use Plausible.Auth.SSO.Domain.Status
|
||||
|
||||
require Plausible.Billing.Subscription.Status
|
||||
|
||||
@type policy_attr() ::
|
||||
{:sso_default_role, Teams.Policy.sso_member_role()}
|
||||
| {:sso_session_timeout_minutes, non_neg_integer()}
|
||||
|
|
@ -74,7 +77,7 @@ defmodule Plausible.Auth.SSO do
|
|||
@spec provision_user(SSO.Identity.t()) ::
|
||||
{:ok, :standard | :sso | :integration, Teams.Team.t(), Auth.User.t()}
|
||||
| {:error, :integration_not_found | :over_limit}
|
||||
| {:error, :multiple_memberships, Teams.Team.t(), Auth.User.t()}
|
||||
| {:error, :multiple_memberships | :active_personal_team, Teams.Team.t(), Auth.User.t()}
|
||||
def provision_user(identity) do
|
||||
case find_user(identity) do
|
||||
{:ok, :standard, user, integration, domain} ->
|
||||
|
|
@ -394,6 +397,7 @@ defmodule Plausible.Auth.SSO do
|
|||
|
||||
with :ok <- ensure_team_member(integration.team, user),
|
||||
:ok <- ensure_one_membership(user, integration.team),
|
||||
:ok <- ensure_empty_personal_team(user, integration.team),
|
||||
:ok <- Auth.UserSessions.revoke_all(user),
|
||||
{:ok, user} <- Repo.update(changeset) do
|
||||
{:ok, :standard, integration.team, user}
|
||||
|
|
@ -467,12 +471,31 @@ defmodule Plausible.Auth.SSO do
|
|||
end
|
||||
|
||||
defp ensure_one_membership(user, team) do
|
||||
query = Teams.Users.teams_query(user)
|
||||
|
||||
if Repo.aggregate(query, :count) > 1 do
|
||||
if Teams.Users.team_member?(user, except: [team.id], only_setup?: true) do
|
||||
{:error, :multiple_memberships, team, user}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_empty_personal_team(user, team) do
|
||||
case Teams.get_by_owner(user, only_not_setup?: true) do
|
||||
{:ok, personal_team} ->
|
||||
subscription = Teams.Billing.get_subscription(personal_team)
|
||||
|
||||
no_subscription? =
|
||||
is_nil(subscription) or subscription.status == Subscription.Status.deleted()
|
||||
|
||||
zero_sites? = Teams.owned_sites_count(personal_team) == 0
|
||||
|
||||
if no_subscription? and zero_sites? do
|
||||
:ok
|
||||
else
|
||||
{:error, :active_personal_team, team, user}
|
||||
end
|
||||
|
||||
{:error, :no_team} ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,9 +57,23 @@ defmodule Plausible.Auth do
|
|||
Repo.get_by(Auth.User, opts)
|
||||
end
|
||||
|
||||
@spec get_user_by(Keyword.t()) :: {:ok, Auth.User.t()} | {:error, :user_not_found}
|
||||
def get_user_by(opts) do
|
||||
case Repo.get_by(Auth.User, opts) do
|
||||
@spec lookup(String.t()) :: {:ok, Auth.User.t()} | {:error, :user_not_found}
|
||||
def lookup(email) do
|
||||
query =
|
||||
on_ee do
|
||||
from(
|
||||
u in Auth.User,
|
||||
left_join: tm in assoc(u, :team_memberships),
|
||||
on: u.type == :sso and tm.role == :owner,
|
||||
left_join: t in assoc(tm, :team),
|
||||
where: u.email == ^email,
|
||||
where: u.type == :standard or (u.type == :sso and t.setup_complete == true)
|
||||
)
|
||||
else
|
||||
from(u in Auth.User, where: u.email == ^email)
|
||||
end
|
||||
|
||||
case Repo.one(query) do
|
||||
%Auth.User{} = user -> {:ok, user}
|
||||
nil -> {:error, :user_not_found}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -141,13 +141,12 @@ defmodule Plausible.Teams do
|
|||
|
||||
If the user already has an owner membership in an existing team,
|
||||
that team is returned.
|
||||
|
||||
If the user has a non-guest membership other than owner, `:no_team` error
|
||||
is returned.
|
||||
"""
|
||||
@spec get_or_create(Auth.User.t()) :: {:ok, Teams.Team.t()} | {:error, :multiple_teams}
|
||||
@spec get_or_create(Auth.User.t()) ::
|
||||
{:ok, Teams.Team.t()} | {:error, :multiple_teams | :permission_denied}
|
||||
def get_or_create(user) do
|
||||
with {:error, :no_team} <- get_owned_team(user, only_not_setup?: true) do
|
||||
with :ok <- check_user_type(user),
|
||||
{:error, :no_team} <- get_owned_team(user, only_not_setup?: true) do
|
||||
case create_my_team(user) do
|
||||
{:ok, team} ->
|
||||
{:ok, team}
|
||||
|
|
@ -160,6 +159,14 @@ defmodule Plausible.Teams do
|
|||
|
||||
@spec force_create_my_team(Auth.User.t()) :: Teams.Team.t()
|
||||
def force_create_my_team(user) do
|
||||
# This is going to crash hard for SSO user. This shouldn't happen
|
||||
# under normal circumstances except in case of a _very_ unlucky timing.
|
||||
# Manual resolution is necessary anyway.
|
||||
case check_user_type(user) do
|
||||
:ok -> :pass
|
||||
_ -> raise "SSO user tried to force create a personal team"
|
||||
end
|
||||
|
||||
{:ok, team} =
|
||||
Repo.transaction(fn ->
|
||||
clear_autocreated(user)
|
||||
|
|
@ -212,10 +219,10 @@ defmodule Plausible.Teams do
|
|||
end
|
||||
end
|
||||
|
||||
@spec get_by_owner(Auth.User.t()) ::
|
||||
@spec get_by_owner(Auth.User.t(), Keyword.t()) ::
|
||||
{:ok, Teams.Team.t()} | {:error, :no_team | :multiple_teams}
|
||||
def get_by_owner(user) do
|
||||
get_owned_team(user)
|
||||
def get_by_owner(user, opts \\ []) do
|
||||
get_owned_team(user, opts)
|
||||
end
|
||||
|
||||
@spec update_accept_traffic_until(Teams.Team.t()) :: Teams.Team.t()
|
||||
|
|
@ -344,6 +351,14 @@ defmodule Plausible.Teams do
|
|||
end
|
||||
end
|
||||
|
||||
defp check_user_type(user) do
|
||||
if Plausible.Users.type(user) == :sso do
|
||||
{:error, :permission_denied}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_autocreated(user) do
|
||||
Repo.update_all(
|
||||
from(tm in Teams.Membership,
|
||||
|
|
|
|||
|
|
@ -252,7 +252,11 @@ defmodule Plausible.Teams.Invitations do
|
|||
team_invitation = Repo.preload(team_invitation, [:team, :inviter])
|
||||
now = NaiveDateTime.utc_now(:second)
|
||||
|
||||
do_accept(team_invitation, user, now, guest_invitations: [])
|
||||
if Plausible.Users.type(user) == :sso do
|
||||
{:error, :permission_denied}
|
||||
else
|
||||
do_accept(team_invitation, user, now, guest_invitations: [])
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
|
|
|||
|
|
@ -46,13 +46,20 @@ defmodule Plausible.Teams.Memberships do
|
|||
end
|
||||
end
|
||||
|
||||
@spec can_add_site?(Teams.Team.t(), Auth.User.t()) :: boolean()
|
||||
def can_add_site?(team, user) do
|
||||
case team_role(team, user) do
|
||||
{:ok, role} when role in [:owner, :admin, :editor] ->
|
||||
true
|
||||
user_type = Plausible.Users.type(user)
|
||||
|
||||
_ ->
|
||||
false
|
||||
role =
|
||||
case team_role(team, user) do
|
||||
{:ok, role} -> role
|
||||
{:error, _} -> :not_a_member
|
||||
end
|
||||
|
||||
case {user_type, role, team} do
|
||||
{:sso, :owner, %{setup_complete: false}} -> false
|
||||
{_, role, _} when role in [:owner, :admin, :editor] -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule Plausible.Teams.Memberships.Remove do
|
|||
|
||||
{:ok, _} =
|
||||
Repo.transaction(fn ->
|
||||
Repo.delete!(team_membership)
|
||||
delete_membership!(team_membership)
|
||||
|
||||
Plausible.Segments.after_user_removed_from_team(
|
||||
team_membership.team,
|
||||
|
|
@ -33,6 +33,18 @@ defmodule Plausible.Teams.Memberships.Remove do
|
|||
end
|
||||
end
|
||||
|
||||
defp delete_membership!(team_membership) do
|
||||
user = team_membership.user
|
||||
|
||||
Repo.delete!(team_membership)
|
||||
|
||||
if Plausible.Users.type(user) == :sso do
|
||||
{:ok, :deleted} = Plausible.Auth.delete_user(user)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp check_can_remove_membership(:owner, _), do: :ok
|
||||
defp check_can_remove_membership(:admin, role) when role != :owner, do: :ok
|
||||
defp check_can_remove_membership(_, _), do: {:error, :permission_denied}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
|
|||
Service for updating role of a team member.
|
||||
"""
|
||||
|
||||
use Plausible
|
||||
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Teams
|
||||
alias Plausible.Teams.Memberships
|
||||
|
|
@ -23,9 +25,9 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
|
|||
new_role,
|
||||
granting_to_self?
|
||||
),
|
||||
:ok <- check_owner_can_get_demoted(team, team_membership.role, new_role) do
|
||||
team_membership = Repo.preload(team_membership, :user)
|
||||
|
||||
:ok <- check_owner_can_get_demoted(team, team_membership.role, new_role),
|
||||
team_membership = Repo.preload(team_membership, :user),
|
||||
:ok <- check_can_promote_to_owner(team, team_membership.user, new_role) do
|
||||
if team_membership.role == :guest and new_role != :guest do
|
||||
team_membership.user.email
|
||||
|> PlausibleWeb.Email.guest_to_team_member_promotion(
|
||||
|
|
@ -46,6 +48,29 @@ defmodule Plausible.Teams.Memberships.UpdateRole do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
defp check_can_promote_to_owner(team, user, :owner) do
|
||||
if team.policy.force_sso == :all_but_owners and not Plausible.Auth.TOTP.enabled?(user) do
|
||||
{:error, :mfa_disabled}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp check_can_promote_to_owner(_team, _user, _new_role), do: :ok
|
||||
else
|
||||
defp check_can_promote_to_owner(_team, _user, _new_role) do
|
||||
# The `else` branch is not reachable.
|
||||
# This a workaround for Elixir 1.18+ compiler
|
||||
# being too smart.
|
||||
if :erlang.phash2(1, 1) == 0 do
|
||||
:ok
|
||||
else
|
||||
{:error, :mfa_disabled}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp check_valid_role(role) do
|
||||
if role in (Teams.Membership.roles() -- [:guest]) do
|
||||
:ok
|
||||
|
|
|
|||
|
|
@ -63,15 +63,26 @@ defmodule Plausible.Teams.Users do
|
|||
|
||||
def team_member?(user, opts \\ []) do
|
||||
excluded_team_ids = Keyword.get(opts, :except, [])
|
||||
only_setup? = Keyword.get(opts, :only_setup?, false)
|
||||
|
||||
Repo.exists?(
|
||||
query =
|
||||
from(
|
||||
tm in Teams.Membership,
|
||||
where: tm.user_id == ^user.id,
|
||||
where: tm.role != :guest,
|
||||
where: tm.team_id not in ^excluded_team_ids
|
||||
)
|
||||
)
|
||||
|
||||
query =
|
||||
if only_setup? do
|
||||
query
|
||||
|> join(:inner, [tm], t in assoc(tm, :team), as: :team)
|
||||
|> where([team: t], t.setup_complete == true)
|
||||
else
|
||||
query
|
||||
end
|
||||
|
||||
Repo.exists?(query)
|
||||
end
|
||||
|
||||
def has_sites?(user, opts \\ []) do
|
||||
|
|
|
|||
|
|
@ -2,11 +2,32 @@ defmodule Plausible.Users do
|
|||
@moduledoc """
|
||||
User context
|
||||
"""
|
||||
use Plausible
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Repo
|
||||
|
||||
on_ee do
|
||||
@spec type(Auth.User.t()) :: :standard | :sso
|
||||
def type(user) do
|
||||
user.type
|
||||
end
|
||||
else
|
||||
@spec type(Auth.User.t()) :: :standard
|
||||
def type(_user) do
|
||||
# The `else` branch is not reachable.
|
||||
# This a workaround for Elixir 1.18+ compiler
|
||||
# being too smart.
|
||||
if :erlang.phash2(1, 1) == 0 do
|
||||
:standard
|
||||
else
|
||||
:sso
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec bump_last_seen(Auth.User.t() | pos_integer(), NaiveDateTime.t()) :: :ok
|
||||
def bump_last_seen(%Auth.User{id: user_id}, now) do
|
||||
bump_last_seen(user_id, now)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ defmodule PlausibleWeb.AuthController do
|
|||
:activate_form,
|
||||
:activate,
|
||||
:request_activation_code,
|
||||
:initiate_2fa,
|
||||
:initiate_2fa_setup,
|
||||
:verify_2fa_setup_form,
|
||||
:verify_2fa_setup,
|
||||
:disable_2fa,
|
||||
|
|
@ -40,6 +40,9 @@ defmodule PlausibleWeb.AuthController do
|
|||
]
|
||||
)
|
||||
|
||||
plug Plausible.Plugs.RestrictUserType,
|
||||
[deny: :sso] when action in [:delete_me, :disable_2fa]
|
||||
|
||||
plug(
|
||||
:clear_2fa_user
|
||||
when action not in [
|
||||
|
|
@ -169,21 +172,21 @@ defmodule PlausibleWeb.AuthController do
|
|||
|
||||
def password_reset_request(conn, %{"email" => email} = params) do
|
||||
if PlausibleWeb.Captcha.verify(params["h-captcha-response"]) do
|
||||
user = Repo.get_by(Plausible.Auth.User, email: email)
|
||||
case Auth.lookup(email) do
|
||||
{:ok, _user} ->
|
||||
token = Auth.Token.sign_password_reset(email)
|
||||
url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
|
||||
email_template = PlausibleWeb.Email.password_reset_email(email, url)
|
||||
Plausible.Mailer.deliver_later(email_template)
|
||||
|
||||
if user do
|
||||
token = Auth.Token.sign_password_reset(email)
|
||||
url = PlausibleWeb.Endpoint.url() <> "/password/reset?token=#{token}"
|
||||
email_template = PlausibleWeb.Email.password_reset_email(email, url)
|
||||
Plausible.Mailer.deliver_later(email_template)
|
||||
Logger.debug(
|
||||
"Password reset e-mail sent. In dev environment GET /sent-emails for details."
|
||||
)
|
||||
|
||||
Logger.debug(
|
||||
"Password reset e-mail sent. In dev environment GET /sent-emails for details."
|
||||
)
|
||||
render(conn, "password_reset_request_success.html", email: email)
|
||||
|
||||
render(conn, "password_reset_request_success.html", email: email)
|
||||
else
|
||||
render(conn, "password_reset_request_success.html", email: email)
|
||||
{:error, _} ->
|
||||
render(conn, "password_reset_request_success.html", email: email)
|
||||
end
|
||||
else
|
||||
render(conn, "password_reset_request_form.html",
|
||||
|
|
@ -234,7 +237,7 @@ defmodule PlausibleWeb.AuthController do
|
|||
|
||||
def login(conn, %{"email" => email, "password" => password} = params) do
|
||||
with :ok <- Auth.rate_limit(:login_ip, conn),
|
||||
{:ok, user} <- Auth.get_user_by(email: email),
|
||||
{:ok, user} <- Auth.lookup(email),
|
||||
:ok <- Auth.rate_limit(:login_user, user),
|
||||
:ok <- Auth.check_password(user, password),
|
||||
:ok <- check_2fa_verified(conn, user) do
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ defmodule PlausibleWeb.SettingsController do
|
|||
plug Plausible.Plugs.AuthorizeTeamAccess,
|
||||
[:owner] when action in [:team_danger_zone, :delete_team]
|
||||
|
||||
plug Plausible.Plugs.RestrictUserType,
|
||||
[deny: :sso] when action in [:update_name, :update_email, :update_password]
|
||||
|
||||
def index(conn, _params) do
|
||||
redirect(conn, to: Routes.settings_path(conn, :preferences))
|
||||
end
|
||||
|
|
|
|||
|
|
@ -314,6 +314,13 @@ defmodule PlausibleWeb.Live.TeamManagement do
|
|||
"The team has to have at least one owner"
|
||||
)
|
||||
|
||||
{{:error, :mfa_disabled}, _} ->
|
||||
socket
|
||||
|> put_live_flash(
|
||||
:error,
|
||||
"User must have 2FA enabled to become an owner"
|
||||
)
|
||||
|
||||
{{:error, {:over_limit, limit}}, _} ->
|
||||
socket
|
||||
|> put_live_flash(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
defmodule Plausible.Plugs.RestrictUserType do
|
||||
@moduledoc """
|
||||
Plug for restricting user access by type.
|
||||
"""
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
|
||||
def init(opts) do
|
||||
Keyword.fetch!(opts, :deny)
|
||||
end
|
||||
|
||||
def call(conn, deny_type) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
if user && Plausible.Users.type(user) == deny_type do
|
||||
conn
|
||||
|> Phoenix.Controller.redirect(to: Routes.site_path(conn, :index))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<.settings_tiles>
|
||||
<.tile>
|
||||
<.tile :if={Plausible.Users.type(@current_user) == :standard}>
|
||||
<:title>
|
||||
<a id="update-name">Your Name</a>
|
||||
</:title>
|
||||
|
|
@ -20,6 +20,18 @@
|
|||
</.form>
|
||||
</.tile>
|
||||
|
||||
<.tile :if={Plausible.Users.type(@current_user) == :sso}>
|
||||
<:title>
|
||||
<a id="view-name">Your Name</a>
|
||||
</:title>
|
||||
<:subtitle>
|
||||
The name associated with your account
|
||||
</:subtitle>
|
||||
<.form :let={f} for={@name_changeset}>
|
||||
<.input type="text" field={f[:name]} disabled={true} label="Name" width="w-1/2" />
|
||||
</.form>
|
||||
</.tile>
|
||||
|
||||
<.tile docs="dashboard-appearance">
|
||||
<:title>
|
||||
<a id="update-theme">Dashboard Appearance</a>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<.settings_tiles>
|
||||
<.tile docs="change-email">
|
||||
<.tile :if={Plausible.Users.type(@current_user) == :standard} docs="change-email">
|
||||
<:title>
|
||||
<a id="update-email">Email Address</a>
|
||||
</:title>
|
||||
|
|
@ -31,7 +31,26 @@
|
|||
</.form>
|
||||
</.tile>
|
||||
|
||||
<.tile docs="reset-password">
|
||||
<.tile :if={Plausible.Users.type(@current_user) == :sso}>
|
||||
<:title>
|
||||
<a id="view-email">Email Address</a>
|
||||
</:title>
|
||||
<:subtitle>
|
||||
Address associated with your account
|
||||
</:subtitle>
|
||||
<.form :let={f} for={@email_changeset}>
|
||||
<.input
|
||||
type="text"
|
||||
name="user[current_email]"
|
||||
value={f.data.email}
|
||||
label="Current Email"
|
||||
width="w-1/2"
|
||||
disabled
|
||||
/>
|
||||
</.form>
|
||||
</.tile>
|
||||
|
||||
<.tile :if={Plausible.Users.type(@current_user) == :standard} docs="reset-password">
|
||||
<:title>
|
||||
<a id="update-password">Password</a>
|
||||
</:title>
|
||||
|
|
@ -97,6 +116,7 @@
|
|||
<div x-data="{disable2FAOpen: false, regenerate2FAOpen: false}">
|
||||
<div :if={@totp_enabled?}>
|
||||
<.button
|
||||
disabled={Plausible.Users.type(@current_user) == :sso}
|
||||
x-on:click="disable2FAOpen = true; $refs.disable2FAPassword.value = ''"
|
||||
theme="danger"
|
||||
mt?={false}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ defmodule PlausibleWeb.UserAuth do
|
|||
to: Routes.sso_path(conn, :login_form, error: error, return_to: redirect_path)
|
||||
)
|
||||
|
||||
{:error, :multiple_memberships, team, user} ->
|
||||
{:error, reason, team, user}
|
||||
when reason in [:multiple_memberships, :active_personal_team] ->
|
||||
redirect_path = Routes.site_path(conn, :index, __team: team.identifier)
|
||||
|
||||
log_in_user(conn, user, redirect_path)
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ defmodule PlausibleWeb.LayoutView do
|
|||
if(not Teams.setup?(current_team),
|
||||
do: %{key: "API Keys", value: "api-keys", icon: :key}
|
||||
),
|
||||
%{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
|
||||
if(Plausible.Users.type(conn.assigns.current_user) == :standard,
|
||||
do: %{key: "Danger Zone", value: "danger-zone", icon: :exclamation_triangle}
|
||||
)
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
use Plausible.Auth.SSO.Domain.Status
|
||||
use Oban.Testing, repo: Plausible.Repo
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Auth.SSO
|
||||
alias Plausible.Teams
|
||||
|
||||
setup do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
integration = SSO.initiate_saml_integration(team)
|
||||
|
|
@ -338,14 +342,5 @@ defmodule Plausible.Auth.SSO.DomainsTest do
|
|||
defp generate_domain() do
|
||||
"example-#{Enum.random(1..10_000)}.com"
|
||||
end
|
||||
|
||||
defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
|
||||
%SSO.Identity{
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -256,6 +256,36 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert sso_user.last_sso_login
|
||||
end
|
||||
|
||||
test "provisions SSO user from existing user with personal team", %{
|
||||
integration: integration,
|
||||
team: team,
|
||||
domain: domain,
|
||||
sso_domain: sso_domain
|
||||
} do
|
||||
user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
|
||||
{:ok, _} = Plausible.Teams.get_or_create(user)
|
||||
add_member(team, user: user, role: :editor)
|
||||
|
||||
# guest membership on a site on another team should not affect provisioning
|
||||
another_team_site = new_site()
|
||||
add_guest(another_team_site, user: user, role: :editor)
|
||||
|
||||
identity = new_identity(user.name, user.email)
|
||||
|
||||
assert {:ok, :standard, matched_team, sso_user} = SSO.provision_user(identity)
|
||||
|
||||
assert matched_team.id == team.id
|
||||
assert sso_user.id == user.id
|
||||
assert sso_user.email == identity.email
|
||||
assert sso_user.type == :sso
|
||||
assert sso_user.name == identity.name
|
||||
assert sso_user.sso_identity_id == identity.id
|
||||
assert sso_user.sso_integration_id == integration.id
|
||||
assert sso_user.sso_domain_id == sso_domain.id
|
||||
assert sso_user.email_verified
|
||||
assert sso_user.last_sso_login
|
||||
end
|
||||
|
||||
test "provisions existing SSO user", %{
|
||||
integration: integration,
|
||||
team: team,
|
||||
|
|
@ -318,7 +348,7 @@ defmodule Plausible.Auth.SSOTest do
|
|||
} do
|
||||
user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
|
||||
add_member(team, user: user, role: :editor)
|
||||
another_team = new_site().team
|
||||
another_team = new_site().team |> Plausible.Teams.complete_setup()
|
||||
add_member(another_team, user: user, role: :viewer)
|
||||
identity = new_identity(user.name, user.email)
|
||||
|
||||
|
|
@ -329,6 +359,43 @@ defmodule Plausible.Auth.SSOTest do
|
|||
assert matched_user.id == user.id
|
||||
end
|
||||
|
||||
test "does not provision from existing user with personal team with subscription", %{
|
||||
team: team,
|
||||
domain: domain
|
||||
} do
|
||||
user =
|
||||
new_user(email: "jane@" <> domain, name: "Jane Sculley") |> subscribe_to_growth_plan()
|
||||
|
||||
add_member(team, user: user, role: :editor)
|
||||
|
||||
identity = new_identity(user.name, user.email)
|
||||
|
||||
assert {:error, :active_personal_team, matched_team, matched_user} =
|
||||
SSO.provision_user(identity)
|
||||
|
||||
assert matched_team.id == team.id
|
||||
assert matched_user.id == user.id
|
||||
end
|
||||
|
||||
test "does not provision from existing user with personal team with site", %{
|
||||
team: team,
|
||||
domain: domain
|
||||
} do
|
||||
user = new_user(email: "jane@" <> domain, name: "Jane Sculley")
|
||||
|
||||
new_site(owner: user)
|
||||
|
||||
add_member(team, user: user, role: :editor)
|
||||
|
||||
identity = new_identity(user.name, user.email)
|
||||
|
||||
assert {:error, :active_personal_team, matched_team, matched_user} =
|
||||
SSO.provision_user(identity)
|
||||
|
||||
assert matched_team.id == team.id
|
||||
assert matched_user.id == user.id
|
||||
end
|
||||
|
||||
test "does not provision new SSO user from identity when team is over members limit", %{
|
||||
domain: domain,
|
||||
team: team
|
||||
|
|
@ -446,7 +513,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
describe "check_force_sso/2" do
|
||||
test "returns ok when conditions are met for setting all_but_owners" do
|
||||
# Owner with MFA enabled
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -465,7 +534,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "returns error when one owner does not have MFA configured" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Owner without MFA
|
||||
|
|
@ -489,7 +560,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
test "returns error when there's no provisioned SSO user present" do
|
||||
# Owner with MFA enabled
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -504,7 +577,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
test "returns error when there's no verified SSO domain present" do
|
||||
# Owner with MFA enabled
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -519,7 +594,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
test "returns error when there's no SSO domain present" do
|
||||
# Owner with MFA enabled
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -530,7 +607,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
test "returns error when there's no SSO integration present" do
|
||||
# Owner with MFA enabled
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
assert {:error, :no_integration} = SSO.check_force_sso(team, :all_but_owners)
|
||||
|
|
@ -545,7 +624,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
describe "set_enforce_sso/2" do
|
||||
test "sets enforce mode to all_but_owners when conditions met" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -573,7 +654,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "sets enforce mode to none" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -599,7 +682,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
describe "check_can_remove_integration/1" do
|
||||
test "returns ok if conditions to remove integration met" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -622,7 +707,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "returns error if force SSO enabled" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -648,7 +735,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "returns error if SSO user present" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -670,7 +759,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
|
||||
describe "remove_integration/1,2" do
|
||||
test "removes integration when conditions met" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -696,7 +787,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "returns error when conditions not met" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -719,7 +812,9 @@ defmodule Plausible.Auth.SSOTest do
|
|||
end
|
||||
|
||||
test "succeeds when SSO user present and force flag set" do
|
||||
owner = new_user(totp_enabled: true, totp_secret: "secret")
|
||||
owner = new_user()
|
||||
{:ok, owner, _} = Auth.TOTP.initiate(owner)
|
||||
{:ok, owner, _} = Auth.TOTP.enable(owner, :skip_verify)
|
||||
team = new_site(owner: owner).team
|
||||
|
||||
# Setup integration
|
||||
|
|
@ -792,14 +887,5 @@ defmodule Plausible.Auth.SSOTest do
|
|||
refute_enqueued(worker: SSO.Domain.Verification.Worker, args: %{domain: domain})
|
||||
end
|
||||
end
|
||||
|
||||
defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
|
||||
%SSO.Identity{
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Plausible.SitesTest do
|
||||
use Plausible.DataCase
|
||||
use Plausible
|
||||
use Plausible.Teams.Test
|
||||
|
||||
alias Plausible.Sites
|
||||
|
|
@ -117,6 +118,30 @@ defmodule Plausible.SitesTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "create a site - SSO user" do
|
||||
setup [:create_user, :create_team, :create_site, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "creates a site for SSO user in a setup team", %{user: user, team: team} do
|
||||
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
|
||||
|
||||
assert {:ok, %{site: %{domain: "example.com", timezone: "Europe/London"}}} =
|
||||
Sites.create(user, params, team)
|
||||
end
|
||||
|
||||
test "does not allow creating a site in SSO user's personal team", %{team: team} do
|
||||
user = add_member(team, role: :editor)
|
||||
{:ok, personal_team} = Plausible.Teams.get_or_create(user)
|
||||
identity = new_identity(user.name, user.email)
|
||||
{:ok, _, _, user} = Plausible.Auth.SSO.provision_user(identity)
|
||||
|
||||
params = %{"domain" => "example.com", "timezone" => "Europe/London"}
|
||||
|
||||
assert {:error, _, :permission_denied, _} = Sites.create(user, params, personal_team)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "stats_start_date" do
|
||||
test "is nil if site has no stats" do
|
||||
site = insert(:site)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,21 @@ defmodule Plausible.Teams.Invitations.AcceptTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "accept_invitation/3 - team invitations - SSO user" do
|
||||
setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "does not allow accepting invite by SSO user", %{user: invitee} do
|
||||
inviter = new_user()
|
||||
team = new_site(owner: inviter).team
|
||||
|
||||
invitation = invite_member(team, invitee, inviter: inviter, role: :editor)
|
||||
|
||||
assert {:error, :permission_denied} = Accept.accept(invitation.invitation_id, invitee)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "accept_invitation/3 - guest invitations" do
|
||||
test "converts an invitation into a membership" do
|
||||
inviter = new_user()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ defmodule Plausible.Teams.Memberships.RemoveTest do
|
|||
to: [nil: collaborator.email],
|
||||
subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked"
|
||||
)
|
||||
|
||||
assert Repo.reload(user)
|
||||
end
|
||||
|
||||
test "when member is removed, associated personal segment is deleted" do
|
||||
|
|
@ -123,4 +125,25 @@ defmodule Plausible.Teams.Memberships.RemoveTest do
|
|||
assert_team_membership(another_member, team, :viewer)
|
||||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "SSO user" do
|
||||
setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "removes SSO user along with membership", %{team: team, user: user} do
|
||||
owner = add_member(team, role: :owner)
|
||||
|
||||
assert {:ok, _} = Remove.remove(team, user.id, owner)
|
||||
|
||||
refute_team_member(user, team)
|
||||
|
||||
assert_email_delivered_with(
|
||||
to: [nil: user.email],
|
||||
subject: @subject_prefix <> "Your access to \"#{team.name}\" team has been revoked"
|
||||
)
|
||||
|
||||
refute Repo.reload(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -116,4 +116,75 @@ defmodule Plausible.Teams.Memberships.UpdateRoleTest do
|
|||
subject: @subject_prefix <> "Welcome to \"#{team.name}\" team"
|
||||
)
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "SSO user" do
|
||||
setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "updates an SSO member's role by user id", %{team: team, user: user} do
|
||||
collaborator = add_member(team, role: :viewer)
|
||||
|
||||
{:ok, _, _, collaborator} =
|
||||
new_identity(collaborator.name, collaborator.email)
|
||||
|> Plausible.Auth.SSO.provision_user()
|
||||
|
||||
assert {:ok, _} = UpdateRole.update(team, collaborator.id, "editor", user)
|
||||
|
||||
assert_team_membership(collaborator, team, :editor)
|
||||
end
|
||||
|
||||
test "updates an SSO member's role to owner when no Force SSO set", %{
|
||||
team: team,
|
||||
user: user
|
||||
} do
|
||||
collaborator = add_member(team, role: :viewer)
|
||||
|
||||
{:ok, _, _, collaborator} =
|
||||
new_identity(collaborator.name, collaborator.email)
|
||||
|> Plausible.Auth.SSO.provision_user()
|
||||
|
||||
assert {:ok, _} = UpdateRole.update(team, collaborator.id, "owner", user)
|
||||
|
||||
assert_team_membership(collaborator, team, :owner)
|
||||
end
|
||||
|
||||
test "updates an SSO member's role with Force SSO to Owner provided they have 2FA enabled",
|
||||
%{
|
||||
team: team,
|
||||
user: user
|
||||
} do
|
||||
{:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
|
||||
{:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
|
||||
{:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
|
||||
collaborator = add_member(team, role: :viewer)
|
||||
|
||||
{:ok, _, _, collaborator} =
|
||||
new_identity(collaborator.name, collaborator.email)
|
||||
|> Plausible.Auth.SSO.provision_user()
|
||||
|
||||
{:ok, collaborator, _} = Plausible.Auth.TOTP.initiate(collaborator)
|
||||
{:ok, collaborator, _} = Plausible.Auth.TOTP.enable(collaborator, :skip_verify)
|
||||
|
||||
assert {:ok, _} = UpdateRole.update(team, collaborator.id, "owner", user)
|
||||
|
||||
assert_team_membership(collaborator, team, :owner)
|
||||
end
|
||||
|
||||
test "does not update SSO member's role to Owner if they don't have 2FA enabled", %{
|
||||
team: team,
|
||||
user: user
|
||||
} do
|
||||
{:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
|
||||
{:ok, user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
|
||||
{:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
|
||||
collaborator = add_member(team, role: :viewer)
|
||||
|
||||
{:ok, _, _, collaborator} =
|
||||
new_identity(collaborator.name, collaborator.email)
|
||||
|> Plausible.Auth.SSO.provision_user()
|
||||
|
||||
assert {:error, :mfa_disabled} = UpdateRole.update(team, collaborator.id, "owner", user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -172,6 +172,26 @@ defmodule Plausible.TeamsTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "get_or_create/1 - SSO user" do
|
||||
setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "does not allow creating personal team to SSO user", %{user: user} do
|
||||
assert {:error, :permission_denied} = Teams.get_or_create(user)
|
||||
end
|
||||
end
|
||||
|
||||
describe "force_create_my_team/1 - SSO user" do
|
||||
setup [:create_user, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "crashes when trying to create a team for SSO user", %{user: user} do
|
||||
assert_raise RuntimeError, ~r/SSO user tried to force create a personal team/, fn ->
|
||||
Teams.force_create_my_team(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_by_owner/1" do
|
||||
test "returns error if user does not own any team" do
|
||||
user = new_user()
|
||||
|
|
|
|||
|
|
@ -501,6 +501,23 @@ defmodule PlausibleWeb.AuthControllerTest do
|
|||
assert redirected_to(conn) == "/sites"
|
||||
end
|
||||
|
||||
test "valid email and password, user on multiple teams - logs the user in", %{conn: conn} do
|
||||
user = insert(:user, password: "password")
|
||||
|
||||
# first team
|
||||
new_site(owner: user)
|
||||
|
||||
# another team
|
||||
another_team = new_site().team |> Plausible.Teams.complete_setup()
|
||||
add_member(another_team, user: user, role: :owner)
|
||||
|
||||
conn = post(conn, "/login", email: user.email, password: "password")
|
||||
|
||||
assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions)
|
||||
assert get_session(conn, :user_token) == token
|
||||
assert redirected_to(conn) == "/sites"
|
||||
end
|
||||
|
||||
test "valid email and password with return_to set - redirects properly", %{conn: conn} do
|
||||
user = insert(:user, password: "password")
|
||||
|
||||
|
|
@ -569,6 +586,76 @@ defmodule PlausibleWeb.AuthControllerTest do
|
|||
refute get_session(conn, :user_token)
|
||||
end
|
||||
|
||||
on_ee do
|
||||
test "SSO owner user - logs in", %{conn: conn} do
|
||||
owner = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
|
||||
team = new_site(owner: owner).team
|
||||
team = Plausible.Teams.complete_setup(team)
|
||||
|
||||
# Setup SSO
|
||||
integration = Auth.SSO.initiate_saml_integration(team)
|
||||
|
||||
{:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
|
||||
_sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
identity = new_identity(owner.name, owner.email)
|
||||
{:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
|
||||
|
||||
conn = post(conn, "/login", email: owner.email, password: "password")
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
assert conn.resp_cookies["session_2fa"].max_age == 0
|
||||
assert %{sessions: [%{token: token}]} = owner |> Repo.reload!() |> Repo.preload(:sessions)
|
||||
assert get_session(conn, :user_token) == token
|
||||
end
|
||||
|
||||
test "SSO user other than owner - renders login form again", %{conn: conn} do
|
||||
owner = new_user()
|
||||
team = new_site(owner: owner).team
|
||||
member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
|
||||
add_member(team, user: member, role: :viewer)
|
||||
|
||||
# Setup SSO
|
||||
integration = Auth.SSO.initiate_saml_integration(team)
|
||||
|
||||
{:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
|
||||
_sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
identity = new_identity(member.name, member.email)
|
||||
{:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
|
||||
|
||||
conn = post(conn, "/login", email: member.email, password: "password")
|
||||
|
||||
assert get_session(conn, :user_token) == nil
|
||||
assert html_response(conn, 200) =~ "Enter your account credentials"
|
||||
end
|
||||
|
||||
test "SSO user other than owner with personal team - renders login form again", %{
|
||||
conn: conn
|
||||
} do
|
||||
owner = new_user()
|
||||
team = new_site(owner: owner).team
|
||||
member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password")
|
||||
{:ok, _} = Plausible.Teams.get_or_create(member)
|
||||
add_member(team, user: member, role: :viewer)
|
||||
|
||||
# Setup SSO
|
||||
integration = Auth.SSO.initiate_saml_integration(team)
|
||||
|
||||
{:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com")
|
||||
_sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
identity = new_identity(member.name, member.email)
|
||||
{:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity)
|
||||
|
||||
conn = post(conn, "/login", email: member.email, password: "password")
|
||||
|
||||
assert get_session(conn, :user_token) == nil
|
||||
assert html_response(conn, 200) =~ "Enter your account credentials"
|
||||
end
|
||||
end
|
||||
|
||||
test "email does not exist - renders login form again", %{conn: conn} do
|
||||
conn = post(conn, "/login", email: "user@example.com", password: "password")
|
||||
|
||||
|
|
@ -639,6 +726,43 @@ defmodule PlausibleWeb.AuthControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "POST /password/request-reset - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user]
|
||||
|
||||
test "initiates reset for owner SSO user email", %{conn: conn, user: user} do
|
||||
mock_captcha_success()
|
||||
conn = post(conn, "/password/request-reset", %{email: user.email})
|
||||
|
||||
assert html_response(conn, 200)
|
||||
|
||||
assert_email_delivered_with(
|
||||
subject: "Plausible password reset",
|
||||
to: [nil: user.email]
|
||||
)
|
||||
end
|
||||
|
||||
test "does not initiate reset for non-owner SSO user", %{conn: conn, user: user, team: team} do
|
||||
add_member(team, role: :owner)
|
||||
|
||||
assert {:ok, _} =
|
||||
Plausible.Teams.Memberships.UpdateRole.update(team, user.id, "editor", user)
|
||||
|
||||
assert Plausible.Teams.Memberships.team_role(team, user) == {:ok, :editor}
|
||||
|
||||
mock_captcha_success()
|
||||
conn = post(conn, "/password/request-reset", %{email: user.email})
|
||||
|
||||
assert html_response(conn, 200)
|
||||
|
||||
refute_email_delivered_with(
|
||||
subject: "Plausible password reset",
|
||||
to: [nil: user.email]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /password/reset" do
|
||||
test "with valid token - shows form", %{conn: conn} do
|
||||
user = insert(:user)
|
||||
|
|
@ -703,6 +827,20 @@ defmodule PlausibleWeb.AuthControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "DELETE /me - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "refuses to delete SSO user", %{conn: conn, user: user} do
|
||||
conn = delete(conn, "/me")
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
assert Repo.reload(user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /me" do
|
||||
setup [:create_user, :log_in, :create_site]
|
||||
use Plausible.Repo
|
||||
|
|
@ -1079,6 +1217,23 @@ defmodule PlausibleWeb.AuthControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "POST /2fa/disable - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "refuses to disable for SSO user", %{conn: conn, user: user} do
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"})
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
assert user |> Repo.reload!() |> Auth.TOTP.enabled?()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /2fa/recovery_codes" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule PlausibleWeb.SettingsControllerTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
use Bamboo.Test
|
||||
use Plausible
|
||||
use Plausible.Repo
|
||||
use Plausible.Teams.Test
|
||||
|
||||
|
|
@ -672,6 +673,23 @@ defmodule PlausibleWeb.SettingsControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "POST /preferences/name - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "refuses to update for SSO user", %{conn: conn, user: user} do
|
||||
conn =
|
||||
post(conn, Routes.settings_path(conn, :update_name), %{
|
||||
"user" => %{"name" => "New name"}
|
||||
})
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
assert Repo.reload!(user).name == user.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /security/password" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
|
@ -843,6 +861,37 @@ defmodule PlausibleWeb.SettingsControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "POST /security/password - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "refuses to update for SSO user", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
new_password = "super-long-super-secret-999"
|
||||
|
||||
original =
|
||||
user
|
||||
|> Auth.User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
conn =
|
||||
post(conn, Routes.settings_path(conn, :update_password), %{
|
||||
"user" => %{
|
||||
"password" => new_password,
|
||||
"old_password" => password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
current_hash = Repo.reload!(user).password_hash
|
||||
assert current_hash == original.password_hash
|
||||
assert Plausible.Auth.Password.match?(password, current_hash)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /security/email" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
|
@ -948,6 +997,34 @@ defmodule PlausibleWeb.SettingsControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "POST /security/email - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "refuses to update for SSO user", %{conn: conn, user: user} do
|
||||
password = "very-long-very-secret-123"
|
||||
|
||||
user
|
||||
|> Auth.User.set_password(password)
|
||||
|> Repo.update!()
|
||||
|
||||
assert user.email_verified
|
||||
|
||||
conn =
|
||||
post(conn, Routes.settings_path(conn, :update_email), %{
|
||||
"user" => %{"email" => "new" <> user.email, "password" => password}
|
||||
})
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index)
|
||||
|
||||
updated_user = Repo.reload!(user)
|
||||
|
||||
assert updated_user.email == user.email
|
||||
assert updated_user.email_verified
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /security/email/cancel" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
|
@ -1249,6 +1326,45 @@ defmodule PlausibleWeb.SettingsControllerTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "Account Settings - SSO user" do
|
||||
setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in]
|
||||
|
||||
test "does not allow to update name in preferences", %{conn: conn} do
|
||||
conn = get(conn, Routes.settings_path(conn, :preferences))
|
||||
assert html = html_response(conn, 200)
|
||||
refute html =~ "Change Name"
|
||||
end
|
||||
|
||||
test "does not allow to update email in security settings", %{conn: conn} do
|
||||
conn = get(conn, Routes.settings_path(conn, :security))
|
||||
assert html = html_response(conn, 200)
|
||||
refute html =~ "Change Email"
|
||||
end
|
||||
|
||||
test "does not allow to change password in security settings", %{conn: conn} do
|
||||
conn = get(conn, Routes.settings_path(conn, :security))
|
||||
assert html = html_response(conn, 200)
|
||||
refute html =~ "Change Password"
|
||||
end
|
||||
|
||||
test "does not allow to disable 2FA in security settings", %{conn: conn, user: user} do
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, _, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
conn = get(conn, Routes.settings_path(conn, :security))
|
||||
assert html = html_response(conn, 200)
|
||||
assert text_of_element(html, "button[disabled]") =~ "Disable 2FA"
|
||||
end
|
||||
|
||||
test "does not show account danger zone", %{conn: conn} do
|
||||
conn = get(conn, Routes.settings_path(conn, :preferences))
|
||||
assert html = html_response(conn, 200)
|
||||
refute html =~ "/settings/danger-zone"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Team Settings" do
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
|
|||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Auth.SSO
|
||||
|
||||
@cert_pem """
|
||||
|
|
@ -241,9 +242,8 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
|
|||
|
||||
assert element_exists?(html, "button#enable-force-sso-toggle[disabled]")
|
||||
|
||||
user
|
||||
|> Ecto.Changeset.change(totp_enabled: true, totp_secret: "secret")
|
||||
|> Plausible.Repo.update!()
|
||||
{:ok, user, _} = Auth.TOTP.initiate(user)
|
||||
{:ok, _user, _} = Auth.TOTP.enable(user, :skip_verify)
|
||||
|
||||
identity = new_identity("Lance Wurst", "lance@org.example.com")
|
||||
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
|
||||
|
|
@ -277,15 +277,6 @@ defmodule PlausibleWeb.Live.SSOMangementTest do
|
|||
{:ok, lv, html} = live(conn, Routes.sso_path(conn, :sso_settings))
|
||||
{lv, html}
|
||||
end
|
||||
|
||||
defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
|
||||
%SSO.Identity{
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -50,6 +50,51 @@ defmodule PlausibleWeb.Live.TeamMangementTest do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
describe "live - SSO user" do
|
||||
setup [
|
||||
:create_user,
|
||||
:create_team,
|
||||
:setup_sso,
|
||||
:provision_sso_user,
|
||||
:log_in,
|
||||
:setup_team
|
||||
]
|
||||
|
||||
test "fails to save layout with SSO user updated to owner with Force SSO but without MFA",
|
||||
%{
|
||||
conn: conn,
|
||||
team: team,
|
||||
user: user
|
||||
} do
|
||||
{:ok, user, _} = Plausible.Auth.TOTP.initiate(user)
|
||||
{:ok, _user, _} = Plausible.Auth.TOTP.enable(user, :skip_verify)
|
||||
{:ok, team} = Plausible.Auth.SSO.set_force_sso(team, :all_but_owners)
|
||||
member = add_member(team, role: :viewer)
|
||||
|
||||
{:ok, _, _, _member} =
|
||||
new_identity(member.name, member.email)
|
||||
|> Plausible.Auth.SSO.provision_user()
|
||||
|
||||
lv = get_liveview(conn)
|
||||
|
||||
html = render(lv)
|
||||
|
||||
assert text_of_element(
|
||||
html,
|
||||
"#{member_el()}:nth-of-type(1) button"
|
||||
) == "Owner"
|
||||
|
||||
assert text_of_element(html, "#{member_el()}:nth-of-type(2) button") == "Viewer"
|
||||
|
||||
change_role(lv, 2, "owner")
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "User must have 2FA enabled to become an owner"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "live" do
|
||||
setup [:create_user, :log_in, :create_team, :setup_team]
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ defmodule PlausibleWeb.UserAuthTest do
|
|||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
team = new_site(owner: user).team
|
||||
team = new_site(owner: user).team |> Plausible.Teams.complete_setup()
|
||||
integration = SSO.initiate_saml_integration(team)
|
||||
domain = "example-#{Enum.random(1..10_000)}.com"
|
||||
user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
|
||||
|
|
@ -189,12 +189,12 @@ defmodule PlausibleWeb.UserAuthTest do
|
|||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
team = new_site().team
|
||||
team = new_site().team |> Plausible.Teams.complete_setup()
|
||||
integration = SSO.initiate_saml_integration(team)
|
||||
domain = "example-#{Enum.random(1..10_000)}.com"
|
||||
user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
|
||||
add_member(team, user: user, role: :editor)
|
||||
another_team = new_site().team
|
||||
another_team = new_site().team |> Plausible.Teams.complete_setup()
|
||||
add_member(another_team, user: user, role: :viewer)
|
||||
|
||||
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
|
||||
|
|
@ -211,13 +211,31 @@ defmodule PlausibleWeb.UserAuthTest do
|
|||
assert get_session(conn, :user_token)
|
||||
end
|
||||
|
||||
defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
|
||||
%SSO.Identity{
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
|
||||
}
|
||||
test "passes through for user matching SSO identity with active personal team, redirecting to team",
|
||||
%{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
team = new_site().team
|
||||
integration = SSO.initiate_saml_integration(team)
|
||||
domain = "example-#{Enum.random(1..10_000)}.com"
|
||||
user = user |> Ecto.Changeset.change(email: "jane@" <> domain) |> Repo.update!()
|
||||
add_member(team, user: user, role: :editor)
|
||||
# personal team with site created
|
||||
new_site(owner: user)
|
||||
|
||||
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
|
||||
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
identity = new_identity(user.name, user.email)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> init_session()
|
||||
|> UserAuth.log_in_user(identity)
|
||||
|
||||
assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier)
|
||||
assert get_session(conn, :user_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Plausible.TestUtils do
|
||||
use Plausible.Repo
|
||||
use Plausible
|
||||
alias Plausible.Factory
|
||||
|
||||
defmacro __using__(_) do
|
||||
|
|
@ -52,6 +53,7 @@ defmodule Plausible.TestUtils do
|
|||
|
||||
conn =
|
||||
conn
|
||||
|> Plug.Conn.fetch_session()
|
||||
|> Plug.Conn.put_session(:current_team_id, team.identifier)
|
||||
|
||||
{:ok, conn: conn, team: team}
|
||||
|
|
@ -99,6 +101,27 @@ defmodule Plausible.TestUtils do
|
|||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
on_ee do
|
||||
alias Plausible.Auth.SSO
|
||||
|
||||
def setup_sso(%{team: team}) do
|
||||
team = Plausible.Teams.complete_setup(team)
|
||||
integration = SSO.initiate_saml_integration(team)
|
||||
|
||||
{:ok, sso_domain} = SSO.Domains.add(integration, "example.com")
|
||||
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
|
||||
|
||||
{:ok, team: team, sso_integration: integration, sso_domain: sso_domain}
|
||||
end
|
||||
|
||||
def provision_sso_user(%{user: user}) do
|
||||
identity = new_identity(user.name, user.email)
|
||||
{:ok, _, _, sso_user} = SSO.provision_user(identity)
|
||||
|
||||
{:ok, user: sso_user}
|
||||
end
|
||||
end
|
||||
|
||||
def init_session(conn) do
|
||||
opts =
|
||||
Plug.Session.init(
|
||||
|
|
@ -328,4 +351,15 @@ defmodule Plausible.TestUtils do
|
|||
ExUnit.Callbacks.on_exit(fn -> File.rm_rf!(tmp_dir) end)
|
||||
tmp_dir
|
||||
end
|
||||
|
||||
on_ee do
|
||||
def new_identity(name, email, id \\ Ecto.UUID.generate()) do
|
||||
%Plausible.Auth.SSO.Identity{
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
expires_at: NaiveDateTime.add(NaiveDateTime.utc_now(:second), 6, :hour)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue