Implement remaining `SSO` functions needed for setup (#5444)

* Move data mgmt logic from `UserAuth` to `Auth.UserSessions`

* Implement remaining SSO code API needed for setup

* Change `deprovision_user` -> `deprovision_user!`

* Change `UserSessions.create` -> `UserSessions.create!`

* Change `any_verified_domain?` -> `no_verified_domains?` (h/t @aerosol)
This commit is contained in:
Adrian Gruntkowski 2025-06-03 08:21:51 +02:00 committed by GitHub
parent f86ef2a4c1
commit 4a587e2a6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 953 additions and 345 deletions

View File

@ -11,6 +11,10 @@ defmodule Plausible.Auth.SSO do
alias Plausible.Repo
alias Plausible.Teams
@type policy_attr() ::
{:sso_default_role, Teams.Policy.sso_member_role()}
| {:sso_session_timeout_minutes, non_neg_integer()}
@spec get_integration(String.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found}
def get_integration(identifier) when is_binary(identifier) do
query =
@ -69,6 +73,199 @@ defmodule Plausible.Auth.SSO do
end
end
@spec deprovision_user!(Auth.User.t()) :: Auth.User.t()
def deprovision_user!(%{type: :standard} = user), do: user
def deprovision_user!(user) do
user = Repo.preload(user, :sso_integration)
:ok = Auth.UserSessions.revoke_all(user)
user
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_change(:type, :standard)
|> Ecto.Changeset.put_change(:sso_identity_id, nil)
|> Ecto.Changeset.put_assoc(:sso_integration, nil)
|> Repo.update!()
end
@spec update_policy(Teams.Team.t(), [policy_attr()]) ::
{:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()}
def update_policy(team, attrs \\ []) do
params = Map.new(attrs)
policy_changeset = Teams.Policy.update_changeset(team.policy, params)
team
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_embed(:policy, policy_changeset)
|> Repo.update()
end
@spec set_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) ::
{:ok, Teams.Team.t()}
| {:error,
:no_integration
| :no_domain
| :no_verified_domain
| :owner_mfa_disabled
| :no_sso_user}
def set_force_sso(team, mode) do
with :ok <- check_force_sso(team, mode) do
policy_changeset = Teams.Policy.force_sso_changeset(team.policy, mode)
team
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_embed(:policy, policy_changeset)
|> Repo.update()
end
end
@spec check_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) ::
:ok
| {:error,
:no_integration
| :no_domain
| :no_verified_domain
| :owner_mfa_disabled
| :no_sso_user}
def check_force_sso(_team, :none), do: :ok
def check_force_sso(team, :all_but_owners) do
with :ok <- check_integration_configured(team),
:ok <- check_sso_user_present(team) do
check_owners_mfa_enabled(team)
end
end
@spec check_can_remove_integration(SSO.Integration.t()) ::
:ok | {:error, :force_sso_enabled | :sso_users_present}
def check_can_remove_integration(integration) do
team = Repo.preload(integration, :team).team
cond do
team.policy.force_sso != :none ->
{:error, :force_sso_enabled}
check_sso_user_present(integration) == :ok ->
{:error, :sso_users_present}
true ->
:ok
end
end
@spec remove_integration(SSO.Integration.t(), Keyword.t()) ::
:ok | {:error, :force_sso_enabled | :sso_users_present}
def remove_integration(integration, opts \\ []) do
force_deprovision? = Keyword.get(opts, :force_deprovision?, false)
check = check_can_remove_integration(integration)
case {check, force_deprovision?} do
{:ok, _} ->
Repo.delete!(integration)
:ok
{{:error, :sso_users_present}, true} ->
users = Repo.preload(integration, :users).users
{:ok, :ok} =
Repo.transaction(fn ->
Enum.each(users, &deprovision_user!/1)
Repo.delete!(integration)
:ok
end)
:ok
{{:error, error}, _} ->
{:error, error}
end
end
defp check_integration_configured(team) do
integrations =
Repo.all(
from(
i in SSO.Integration,
left_join: d in assoc(i, :sso_domains),
where: i.team_id == ^team.id,
preload: [sso_domains: d]
)
)
domains = Enum.flat_map(integrations, & &1.sso_domains)
no_verified_domains? = Enum.all?(domains, &(&1.status != :validated))
cond do
integrations == [] -> {:error, :no_integration}
domains == [] -> {:error, :no_domain}
no_verified_domains? -> {:error, :no_verified_domain}
true -> :ok
end
end
defp check_sso_user_present(%Teams.Team{} = team) do
sso_user_count =
Repo.aggregate(
from(
tm in Teams.Membership,
inner_join: u in assoc(tm, :user),
where: tm.team_id == ^team.id,
where: tm.role != :guest,
where: u.type == :sso
),
:count
)
if sso_user_count > 0 do
:ok
else
{:error, :no_sso_user}
end
end
defp check_sso_user_present(%SSO.Integration{} = integration) do
sso_user_count =
Repo.aggregate(
from(
i in SSO.Integration,
inner_join: u in assoc(i, :users),
inner_join: tm in assoc(u, :team_memberships),
on: tm.team_id == i.team_id,
where: i.id == ^integration.id,
where: tm.role != :guest,
where: u.type == :sso
),
:count
)
if sso_user_count > 0 do
:ok
else
{:error, :no_sso_user}
end
end
defp check_owners_mfa_enabled(team) do
disabled_mfa_count =
Repo.aggregate(
from(
tm in Teams.Membership,
inner_join: u in assoc(tm, :user),
where: tm.team_id == ^team.id,
where: tm.role == :owner,
where: u.totp_enabled == false or is_nil(u.totp_secret)
),
:count
)
if disabled_mfa_count == 0 do
:ok
else
{:error, :owner_mfa_disabled}
end
end
defp find_user(identity) do
case find_user_with_fallback(identity) do
{:ok, type, user, integration} ->
@ -152,6 +349,7 @@ defmodule Plausible.Auth.SSO do
with :ok <- ensure_team_member(integration.team, user),
:ok <- ensure_one_membership(user, integration.team),
:ok <- Auth.UserSessions.revoke_all(user),
{:ok, user} <- Repo.update(changeset) do
{:ok, :standard, integration.team, user}
end

View File

@ -31,6 +31,7 @@ defmodule Plausible.Auth.SSO.Integration do
belongs_to :team, Plausible.Teams.Team
has_many :users, Plausible.Auth.User, foreign_key: :sso_integration_id
has_many :sso_domains, SSO.Domain, foreign_key: :sso_integration_id
timestamps()
end

View File

@ -46,7 +46,7 @@ defmodule Plausible.Auth.User do
field :sso_identity_id, :string
field :last_sso_login, :naive_datetime
belongs_to :sso_integration, Plausible.Auth.SSO.Integration
belongs_to :sso_integration, Plausible.Auth.SSO.Integration, on_replace: :nilify
end
has_many :sessions, Plausible.Auth.UserSession

View File

@ -29,19 +29,11 @@ defmodule Plausible.Auth.UserSession do
@spec timeout_duration() :: Duration.t()
def timeout_duration(), do: @timeout
@spec new_session(Auth.User.t(), String.t(), NaiveDateTime.t()) :: Ecto.Changeset.t()
def new_session(user, device, now \\ NaiveDateTime.utc_now(:second)) do
%__MODULE__{}
|> cast(%{device: device}, [:device])
|> generate_token()
|> put_assoc(:user, user)
|> put_change(:timeout_at, NaiveDateTime.shift(now, @timeout))
|> touch_session(now)
end
@spec new_session(Auth.User.t(), String.t(), Keyword.t()) :: Ecto.Changeset.t()
def new_session(user, device, opts \\ []) do
now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second))
timeout_at = Keyword.get(opts, :timeout_at, NaiveDateTime.shift(now, @timeout))
@spec new_sso_session(Auth.User.t(), String.t(), NaiveDateTime.t(), NaiveDateTime.t()) ::
Ecto.Changeset.t()
def new_sso_session(user, device, timeout_at, now \\ NaiveDateTime.utc_now(:second)) do
%__MODULE__{}
|> cast(%{device: device}, [:device])
|> generate_token()

View File

@ -3,10 +3,12 @@ defmodule Plausible.Auth.UserSessions do
Functions for interacting with user sessions.
"""
import Ecto.Query, only: [from: 2]
import Ecto.Query
alias Plausible.Auth
alias Plausible.Repo
@socket_id_prefix "user_sessions:"
@spec list_for_user(Auth.User.t(), NaiveDateTime.t()) :: [Auth.UserSession.t()]
def list_for_user(user, now \\ NaiveDateTime.utc_now(:second)) do
Repo.all(
@ -30,4 +32,118 @@ defmodule Plausible.Auth.UserSessions do
true -> "#{diff_days} days ago"
end
end
@spec get_by_token(String.t()) :: {:ok, Auth.UserSession.t()} | {:error, :not_found}
def get_by_token(token) do
now = NaiveDateTime.utc_now(:second)
last_team_subscription_query = Plausible.Teams.last_subscription_join_query()
token_query =
from(us in Auth.UserSession,
inner_join: u in assoc(us, :user),
as: :user,
left_join: tm in assoc(u, :team_memberships),
on: tm.role != :guest,
left_join: t in assoc(tm, :team),
as: :team,
left_join: o in assoc(t, :owners),
left_lateral_join: ts in subquery(last_team_subscription_query),
on: true,
where: us.token == ^token and us.timeout_at > ^now,
order_by: t.id,
preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}]
)
case Repo.one(token_query) do
%Auth.UserSession{} = user_session ->
{:ok, user_session}
nil ->
{:error, :not_found}
end
end
@spec create!(Auth.User.t(), String.t(), Keyword.t()) :: Auth.UserSession.t()
def create!(user, device_name, opts \\ []) do
user
|> Auth.UserSession.new_session(device_name, opts)
|> Repo.insert!()
end
@spec remove_by_token(String.t()) :: :ok
def remove_by_token(token) do
Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token)
:ok
end
@spec touch(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t()
def touch(user_session, now \\ NaiveDateTime.utc_now(:second)) do
if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do
Plausible.Users.bump_last_seen(user_session.user_id, now)
user_session
|> Repo.preload(:user)
|> Auth.UserSession.touch_session(now)
|> Repo.update!(allow_stale: true)
else
user_session
end
end
@spec revoke_by_id(Auth.User.t(), pos_integer()) :: :ok
def revoke_by_id(user, session_id) do
{_, tokens} =
Repo.delete_all(
from us in Auth.UserSession,
where: us.user_id == ^user.id and us.id == ^session_id,
select: us.token
)
case tokens do
[token] ->
disconnect_by_token(token)
_ ->
:pass
end
:ok
end
@spec revoke_all(Auth.User.t(), Keyword.t()) :: :ok
def revoke_all(user, opts \\ []) do
except = Keyword.get(opts, :except)
delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
delete_query =
if except do
where(delete_query, [us], us.id != ^except.id)
else
delete_query
end
{_count, tokens} = Repo.delete_all(delete_query)
Enum.each(tokens, &disconnect_by_token/1)
end
@spec disconnect_by_token(String.t()) :: :ok
def disconnect_by_token(token_or_socket_id) do
socket_id =
if String.starts_with?(token_or_socket_id, @socket_id_prefix) do
token_or_socket_id
else
socket_id(token_or_socket_id)
end
PlausibleWeb.Endpoint.broadcast(socket_id, "disconnect", %{})
:ok
end
@spec socket_id(String.t()) :: String.t()
def socket_id(token) do
@socket_id_prefix <> Base.url_encode64(token)
end
end

View File

@ -8,9 +8,19 @@ defmodule Plausible.Teams.Policy do
import Ecto.Changeset
@sso_member_roles Plausible.Teams.Membership.roles() -- [:guest]
@force_sso_modes [:none, :all_but_owners]
@update_fields [:sso_default_role, :sso_session_timeout_minutes]
@default_timeout_minutes 6 * 60
@max_timeout_minutes 12 * 60
@type t() :: %__MODULE__{}
@type sso_member_role() :: unquote(Enum.reduce(@sso_member_roles, &{:|, [], [&1, &2]}))
@type force_sso_mode() :: unquote(Enum.reduce(@force_sso_modes, &{:|, [], [&1, &2]}))
embedded_schema do
# SSO options apply to all team's integrations, should there
# ever be more than one allowed at once.
@ -28,15 +38,21 @@ 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: 360
field :sso_session_timeout_minutes, :integer, default: @default_timeout_minutes
end
@spec update_changeset(t(), map()) :: Ecto.Changeset.t()
def update_changeset(policy, params) do
policy
|> cast(params, @update_fields)
|> validate_required(@update_fields)
|> validate_number(:sso_session_timeout_minutes,
greater_than: 0,
less_than: @max_timeout_minutes
)
end
@spec force_sso_changeset(t(), force_sso_mode()) :: Ecto.Changeset.t()
def force_sso_changeset(policy, mode) do
policy
|> cast(%{force_sso: mode}, [:force_sso])

View File

@ -3,7 +3,6 @@ defmodule PlausibleWeb.SettingsController do
use Plausible.Repo
alias Plausible.Auth
alias PlausibleWeb.UserAuth
alias Plausible.Teams
require Logger
@ -293,7 +292,7 @@ defmodule PlausibleWeb.SettingsController do
with :ok <- Auth.rate_limit(:password_change_user, user),
{:ok, user} <- do_update_password(user, params) do
UserAuth.revoke_all_user_sessions(user, except: user_session)
Auth.UserSessions.revoke_all(user, except: user_session)
conn
|> put_flash(:success, "Your password is now changed")
@ -342,7 +341,7 @@ defmodule PlausibleWeb.SettingsController do
def delete_session(conn, %{"id" => session_id}) do
current_user = conn.assigns.current_user
:ok = UserAuth.revoke_user_session(current_user, session_id)
:ok = Auth.UserSessions.revoke_by_id(current_user, session_id)
conn
|> put_flash(:success, "Session logged out successfully")

View File

@ -7,7 +7,6 @@ defmodule PlausibleWeb.Live.ResetPasswordForm do
alias Plausible.Auth
alias Plausible.Repo
alias PlausibleWeb.UserAuth
def mount(_params, %{"email" => email}, socket) do
socket =
@ -94,7 +93,7 @@ defmodule PlausibleWeb.Live.ResetPasswordForm do
case result do
{:ok, user} ->
UserAuth.revoke_all_user_sessions(user)
Auth.UserSessions.revoke_all(user)
{:noreply, assign(socket, trigger_submit: true)}
{:error, changeset} ->

View File

@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do
import Plug.Conn
alias PlausibleWeb.UserAuth
alias Plausible.Auth
def init(opts \\ []) do
opts
@ -16,7 +16,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do
assign(
conn,
:current_user_session,
UserAuth.touch_user_session(user_session)
Auth.UserSessions.touch(user_session)
)
else
conn

View File

@ -5,10 +5,7 @@ defmodule PlausibleWeb.UserAuth do
use Plausible
import Ecto.Query
alias Plausible.Auth
alias Plausible.Repo
alias PlausibleWeb.TwoFactor
alias PlausibleWeb.Router.Helpers, as: Routes
@ -27,10 +24,11 @@ defmodule PlausibleWeb.UserAuth do
def log_in_user(conn, %Auth.User{} = user, redirect_path) do
redirect_to = login_redirect_path(conn, redirect_path)
{token, _} = create_user_session(conn, user)
device_name = get_device_name(conn)
session = Auth.UserSessions.create!(user, device_name)
conn
|> set_user_token(token)
|> set_user_token(session.token)
|> set_logged_in_cookie()
|> Phoenix.Controller.redirect(to: redirect_to)
end
@ -38,16 +36,13 @@ defmodule PlausibleWeb.UserAuth do
on_ee do
def log_in_user(conn, %Auth.SSO.Identity{} = identity, redirect_path) do
case Auth.SSO.provision_user(identity) do
{:ok, provisioning_from, team, user} ->
if provisioning_from == :standard do
:ok = revoke_all_user_sessions(user)
end
{:ok, _provisioning_from, team, user} ->
redirect_to = login_redirect_path(conn, redirect_path)
{token, _} = create_sso_user_session(conn, user, identity.expires_at)
device_name = get_device_name(conn)
session = Auth.UserSessions.create!(user, device_name, timeout_at: identity.expires_at)
conn
|> set_user_token(token)
|> set_user_token(session.token)
|> Plug.Conn.put_session("current_team_id", team.identifier)
|> set_logged_in_cookie()
|> Phoenix.Controller.redirect(to: redirect_to)
@ -75,28 +70,17 @@ defmodule PlausibleWeb.UserAuth do
log_in_user(conn, user, redirect_path)
end
end
defp create_sso_user_session(conn, user, expires_at) do
device_name = get_device_name(conn)
user_session =
user
|> Auth.UserSession.new_sso_session(device_name, expires_at)
|> Repo.insert!()
{user_session.token, user_session}
end
end
@spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t()
def log_out_user(conn) do
case get_user_token(conn) do
{:ok, token} -> remove_user_session(token)
{:ok, token} -> Auth.UserSessions.remove_by_token(token)
{:error, _} -> :pass
end
if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do
PlausibleWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
Auth.UserSessions.disconnect_by_token(live_socket_id)
end
conn
@ -112,62 +96,11 @@ defmodule PlausibleWeb.UserAuth do
def get_user_session(conn_or_session) do
with {:ok, token} <- get_user_token(conn_or_session) do
get_session_by_token(token)
end
end
@spec touch_user_session(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t()
def touch_user_session(user_session, now \\ NaiveDateTime.utc_now(:second)) do
if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do
Plausible.Users.bump_last_seen(user_session.user_id, now)
user_session
|> Repo.preload(:user)
|> Auth.UserSession.touch_session(now)
|> Repo.update!(allow_stale: true)
else
user_session
end
end
@spec revoke_user_session(Auth.User.t(), pos_integer()) :: :ok
def revoke_user_session(user, session_id) do
{_, tokens} =
Repo.delete_all(
from us in Auth.UserSession,
where: us.user_id == ^user.id and us.id == ^session_id,
select: us.token
)
case tokens do
[token] ->
PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{})
_ ->
:pass
end
:ok
end
@spec revoke_all_user_sessions(Auth.User.t(), Keyword.t()) :: :ok
def revoke_all_user_sessions(user, opts \\ []) do
except = Keyword.get(opts, :except)
delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token
delete_query =
if except do
where(delete_query, [us], us.id != ^except.id)
else
delete_query
case Auth.UserSessions.get_by_token(token) do
{:ok, session} -> {:ok, session}
{:error, :not_found} -> {:error, :session_not_found}
end
{_count, tokens} = Repo.delete_all(delete_query)
Enum.each(tokens, fn token ->
PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{})
end)
end
end
@doc """
@ -185,36 +118,6 @@ defmodule PlausibleWeb.UserAuth do
)
end
defp get_session_by_token(token) do
now = NaiveDateTime.utc_now(:second)
last_team_subscription_query = Plausible.Teams.last_subscription_join_query()
token_query =
from(us in Auth.UserSession,
inner_join: u in assoc(us, :user),
as: :user,
left_join: tm in assoc(u, :team_memberships),
on: tm.role != :guest,
left_join: t in assoc(tm, :team),
as: :team,
left_join: o in assoc(t, :owners),
left_lateral_join: ts in subquery(last_team_subscription_query),
on: true,
where: us.token == ^token and us.timeout_at > ^now,
order_by: t.id,
preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}]
)
case Repo.one(token_query) do
%Auth.UserSession{} = user_session ->
{:ok, user_session}
nil ->
{:error, :session_not_found}
end
end
defp set_user_token(conn, token) do
conn
|> renew_session()
@ -245,11 +148,7 @@ defmodule PlausibleWeb.UserAuth do
defp put_token_in_session(conn, token) do
conn
|> Plug.Conn.put_session(:user_token, token)
|> Plug.Conn.put_session(:live_socket_id, live_socket_id(token))
end
defp live_socket_id(token) do
"user_sessions:#{Base.url_encode64(token)}"
|> Plug.Conn.put_session(:live_socket_id, Auth.UserSessions.socket_id(token))
end
defp get_user_token(%Plug.Conn{} = conn) do
@ -266,22 +165,6 @@ defmodule PlausibleWeb.UserAuth do
{:error, :no_valid_token}
end
defp create_user_session(conn, user) do
device_name = get_device_name(conn)
user_session =
user
|> Auth.UserSession.new_session(device_name)
|> Repo.insert!()
{user_session.token, user_session}
end
defp remove_user_session(token) do
Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token)
:ok
end
@unknown_label "Unknown"
defp get_device_name(%Plug.Conn{} = conn) do

View File

@ -5,6 +5,7 @@ defmodule Plausible.Auth.SSOTest do
on_ee do
use Plausible.Teams.Test
alias Plausible.Auth
alias Plausible.Auth.SSO
describe "initiate_saml_integration/1" do
@ -304,6 +305,381 @@ defmodule Plausible.Auth.SSOTest do
end
end
describe "deprovision_user!/1" do
test "deprovisions SSO user" do
team = new_site().team
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
identity = new_identity("Clarence Fortridge", "clarence@" <> domain)
{:ok, _, _, user} = SSO.provision_user(identity)
user = Repo.reload!(user)
session = Auth.UserSessions.create!(user, "Unknown")
updated_user = SSO.deprovision_user!(user)
refute Repo.reload(session)
assert updated_user.id == user.id
assert updated_user.type == :standard
refute updated_user.sso_identity_id
refute updated_user.sso_integration_id
end
test "handles standard user gracefully without revoking existing sessions" do
user = new_user()
session = Auth.UserSessions.create!(user, "Unknown")
assert updated_user = SSO.deprovision_user!(user)
assert Repo.reload(session)
assert updated_user.id == user.id
assert updated_user.type == :standard
refute updated_user.sso_identity_id
refute updated_user.sso_integration_id
end
end
describe "update_policy/2" do
test "updates team policy attributes" do
team = new_site().team
assert team.policy.sso_default_role == :viewer
assert team.policy.sso_session_timeout_minutes == 360
assert {:ok, team} =
SSO.update_policy(
team,
sso_default_role: "editor",
sso_session_timeout_minutes: 600
)
assert team.policy.sso_default_role == :editor
assert team.policy.sso_session_timeout_minutes == 600
end
test "accepts single attributes leaving others as they are" do
team = new_site().team
assert team.policy.sso_default_role == :viewer
assert team.policy.sso_session_timeout_minutes == 360
assert {:ok, team} = SSO.update_policy(team, sso_default_role: "editor")
assert team.policy.sso_default_role == :editor
assert team.policy.sso_session_timeout_minutes == 360
end
end
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")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
assert :ok = SSO.check_force_sso(team, :all_but_owners)
end
test "returns error when one owner does not have MFA configured" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Owner without MFA
another_owner = new_user()
add_member(team, user: another_owner, role: :owner)
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Carrie Mower", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
assert {:error, :owner_mfa_disabled} = SSO.check_force_sso(team, :all_but_owners)
end
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")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
assert {:error, :no_sso_user} = SSO.check_force_sso(team, :all_but_owners)
end
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")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
# Unverified domain
{:ok, _sso_domain} = SSO.Domains.add(integration, domain)
assert {:error, :no_verified_domain} = SSO.check_force_sso(team, :all_but_owners)
end
test "returns error when there's no SSO domain present" do
# Owner with MFA enabled
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
_integration = SSO.initiate_saml_integration(team)
assert {:error, :no_domain} = SSO.check_force_sso(team, :all_but_owners)
end
test "returns error when there's no SSO integration present" do
# Owner with MFA enabled
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
assert {:error, :no_integration} = SSO.check_force_sso(team, :all_but_owners)
end
test "returns ok when setting to none" do
team = new_site().team
assert :ok = SSO.check_force_sso(team, :none)
end
end
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")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
assert {:ok, updated_team} = SSO.set_force_sso(team, :all_but_owners)
assert updated_team.id == team.id
assert updated_team.policy.force_sso == :all_but_owners
end
test "returns error when conditions not met" do
team = new_site().team
assert {:error, :no_integration} = SSO.set_force_sso(team, :all_but_owners)
end
test "sets enforce mode to none" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
{:ok, team} = SSO.set_force_sso(team, :all_but_owners)
assert {:ok, updated_team} = SSO.set_force_sso(team, :none)
assert updated_team.id == team.id
assert updated_team.policy.force_sso == :none
end
end
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")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, sso_user} = SSO.provision_user(identity)
# SSO user deprovisioned
_user = SSO.deprovision_user!(sso_user)
integration = Repo.reload!(integration)
assert :ok = SSO.check_can_remove_integration(integration)
end
test "returns error if force SSO enabled" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, sso_user} = SSO.provision_user(identity)
# Force SSO enabled
{:ok, _} = SSO.set_force_sso(team, :all_but_owners)
# SSO user deprovisioned
_user = SSO.deprovision_user!(sso_user)
integration = Repo.reload!(integration)
assert {:error, :force_sso_enabled} = SSO.check_can_remove_integration(integration)
end
test "returns error if SSO user present" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
_sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
integration = Repo.reload!(integration)
assert {:error, :sso_users_present} = SSO.check_can_remove_integration(integration)
end
end
describe "remove_integration/1,2" do
test "removes integration when conditions met" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, sso_user} = SSO.provision_user(identity)
# SSO user deprovisioned
_user = SSO.deprovision_user!(sso_user)
integration = Repo.reload!(integration)
assert :ok = SSO.remove_integration(integration)
refute Repo.reload(integration)
refute Repo.reload(sso_domain)
end
test "returns error when conditions not met" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Lance Wurst", "lance@" <> domain)
{:ok, _, _, _sso_user} = SSO.provision_user(identity)
integration = Repo.reload!(integration)
assert {:error, :sso_users_present} = SSO.remove_integration(integration)
assert Repo.reload(integration)
assert Repo.reload(sso_domain)
end
test "succeeds when SSO user present and force flag set" do
owner = new_user(totp_enabled: true, totp_secret: "secret")
team = new_site(owner: owner).team
# Setup integration
integration = SSO.initiate_saml_integration(team)
domain = "example-#{Enum.random(1..10_000)}.com"
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
# Provisioned SSO identity
#
identity = new_identity("Carrie Mower", "carrie@" <> domain)
{:ok, _, _, sso_user} = SSO.provision_user(identity)
integration = Repo.reload!(integration)
assert :ok = SSO.remove_integration(integration, force_deprovision?: true)
refute Repo.reload(integration)
refute Repo.reload(sso_domain)
# SSO user is deprovisioned
sso_user = Repo.reload(sso_user)
assert sso_user.type == :standard
refute sso_user.sso_identity_id
refute sso_user.sso_integration_id
end
end
defp new_identity(name, email, id \\ Ecto.UUID.generate()) do
%SSO.Identity{
id: id,

View File

@ -1,5 +1,9 @@
defmodule Plausible.Auth.UserSessionsTest do
use Plausible.DataCase, async: true
use Plausible.Teams.Test
use Plausible
import Phoenix.ChannelTest
alias Plausible.Auth
alias Plausible.Auth.UserSessions
@ -50,6 +54,212 @@ defmodule Plausible.Auth.UserSessionsTest do
end
end
describe "touch/1,2" do
setup do
user = new_user()
session =
user
|> Auth.UserSession.new_session("A Device")
|> Repo.insert!()
{:ok, user: user, session: session}
end
test "refreshes user session timestamps", %{user: user, session: session} do
two_days_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(day: 2)
assert refreshed_session =
%Auth.UserSession{} = UserSessions.touch(session, two_days_later)
assert refreshed_session.id == session.id
assert NaiveDateTime.compare(refreshed_session.last_used_at, two_days_later) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, two_days_later) == :eq
assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :gt
end
test "does not refresh if timestamps were updated less than hour before", %{
user: user,
session: session
} do
last_seen = Repo.reload(user).last_seen
fifty_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 50)
assert refreshed_session1 =
%Auth.UserSession{} =
UserSessions.touch(session, fifty_minutes_later)
assert NaiveDateTime.compare(
refreshed_session1.last_used_at,
session.last_used_at
) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, last_seen) == :eq
sixty_five_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 65)
assert refreshed_session2 =
%Auth.UserSession{} =
UserSessions.touch(session, sixty_five_minutes_later)
assert NaiveDateTime.compare(
refreshed_session2.last_used_at,
sixty_five_minutes_later
) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, sixty_five_minutes_later) == :eq
end
test "handles concurrent refresh gracefully", %{session: session} do
# concurrent update
now = NaiveDateTime.utc_now(:second)
two_days_later = NaiveDateTime.shift(now, day: 2)
Repo.update_all(
from(us in Auth.UserSession, where: us.token == ^session.token),
set: [timeout_at: two_days_later, last_used_at: now]
)
assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session)
assert refreshed_session.id == session.id
assert Repo.reload(session)
end
test "handles deleted session case gracefully", %{session: session} do
Repo.delete!(session)
assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session)
assert refreshed_session.id == session.id
refute Repo.reload(session)
end
on_ee do
test "only records last usage but does not refresh for SSO user", %{
user: user,
session: session
} do
sixty_five_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 65)
user |> Ecto.Changeset.change(type: :sso) |> Repo.update!()
session = Repo.reload!(session)
assert refreshed_session =
%Auth.UserSession{} =
UserSessions.touch(session, sixty_five_minutes_later)
assert refreshed_session.id == session.id
assert NaiveDateTime.compare(refreshed_session.last_used_at, sixty_five_minutes_later) ==
:eq
assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :eq
end
end
end
describe "revoke_by_id/2" do
setup do
user = new_user()
session =
user
|> Auth.UserSession.new_session("A Device")
|> Repo.insert!()
{:ok, user: user, session: session}
end
test "deletes and disconnects user session", %{user: user, session: active_session} do
live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token)
Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id)
another_session =
user
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserSessions.revoke_by_id(user, active_session.id)
assert [remaining_session] = Repo.preload(user, :sessions).sessions
assert_broadcast "disconnect", %{}
assert remaining_session.id == another_session.id
refute Repo.reload(active_session)
assert Repo.reload(another_session)
end
test "does not delete session of another user", %{user: user, session: active_session} do
other_session =
insert(:user)
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserSessions.revoke_by_id(user, other_session.id)
assert Repo.reload(active_session)
assert Repo.reload(other_session)
end
test "executes gracefully when session does not exist", %{user: user, session: active_session} do
Repo.delete!(active_session)
assert :ok = UserSessions.revoke_by_id(user, active_session.id)
end
end
describe "revoke_all/1,2" do
setup do
user = new_user()
session =
user
|> Auth.UserSession.new_session("A Device")
|> Repo.insert!()
{:ok, user: user, session: session}
end
test "deletes and disconnects all user's sessions", %{user: user, session: active_session} do
live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token)
Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id)
another_session =
user
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
unrelated_session =
insert(:user)
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserSessions.revoke_all(user)
assert [] = Repo.preload(user, :sessions).sessions
assert_broadcast "disconnect", %{}
refute Repo.reload(another_session)
assert Repo.reload(unrelated_session)
end
test "executes gracefully when user has no sessions" do
user = insert(:user)
assert :ok = UserSessions.revoke_all(user)
end
end
defp last_used_humanize(user, dt) do
user
|> insert_session("Some Device", dt)
@ -58,7 +268,7 @@ defmodule Plausible.Auth.UserSessionsTest do
defp insert_session(user, device_name, now) do
user
|> Auth.UserSession.new_session(device_name, now)
|> Auth.UserSession.new_session(device_name, now: now)
|> Repo.insert!()
end
end

View File

@ -603,7 +603,7 @@ defmodule PlausibleWeb.SettingsControllerTest do
another_session =
user
|> Auth.UserSession.new_session("Some Device", seventy_minutes_ago)
|> Auth.UserSession.new_session("Some Device", now: seventy_minutes_ago)
|> Repo.insert!()
conn = get(conn, Routes.settings_path(conn, :security))

View File

@ -3,10 +3,6 @@ defmodule PlausibleWeb.UserAuthTest do
use Plausible
use Plausible.Teams.Test
import Ecto.Query, only: [from: 2]
import Phoenix.ChannelTest
alias Plausible.Auth
alias Plausible.Repo
alias PlausibleWeb.UserAuth
@ -297,191 +293,6 @@ defmodule PlausibleWeb.UserAuthTest do
end
end
describe "touch_user_session/1" do
setup [:create_user, :log_in]
test "refreshes user session timestamps", %{user: user} do
%{sessions: [user_session]} = Repo.preload(user, :sessions)
two_days_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(day: 2)
assert refreshed_session =
%Auth.UserSession{} = UserAuth.touch_user_session(user_session, two_days_later)
assert refreshed_session.id == user_session.id
assert NaiveDateTime.compare(refreshed_session.last_used_at, two_days_later) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, two_days_later) == :eq
assert NaiveDateTime.compare(refreshed_session.timeout_at, user_session.timeout_at) == :gt
end
test "does not refresh if timestamps were updated less than hour before", %{user: user} do
%{sessions: [user_session]} = Repo.preload(user, :sessions)
user_session = Repo.reload(user_session)
last_seen = Repo.reload(user).last_seen
fifty_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 50)
assert refreshed_session1 =
%Auth.UserSession{} =
UserAuth.touch_user_session(user_session, fifty_minutes_later)
assert NaiveDateTime.compare(
refreshed_session1.last_used_at,
user_session.last_used_at
) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, last_seen) == :eq
sixty_five_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 65)
assert refreshed_session2 =
%Auth.UserSession{} =
UserAuth.touch_user_session(user_session, sixty_five_minutes_later)
assert NaiveDateTime.compare(
refreshed_session2.last_used_at,
sixty_five_minutes_later
) == :eq
assert NaiveDateTime.compare(Repo.reload(user).last_seen, sixty_five_minutes_later) == :eq
end
test "handles concurrent refresh gracefully", %{user: user} do
%{sessions: [user_session]} = Repo.preload(user, :sessions)
# concurrent update
now = NaiveDateTime.utc_now(:second)
two_days_later = NaiveDateTime.shift(now, day: 2)
Repo.update_all(
from(us in Auth.UserSession, where: us.token == ^user_session.token),
set: [timeout_at: two_days_later, last_used_at: now]
)
assert refreshed_session =
%Auth.UserSession{} = UserAuth.touch_user_session(user_session)
assert refreshed_session.id == user_session.id
assert Repo.reload(user_session)
end
test "handles deleted session case gracefully", %{user: user} do
%{sessions: [user_session]} = Repo.preload(user, :sessions)
Repo.delete!(user_session)
assert refreshed_session =
%Auth.UserSession{} = UserAuth.touch_user_session(user_session)
assert refreshed_session.id == user_session.id
refute Repo.reload(user_session)
end
on_ee do
test "only records last usage but does not refresh for SSO user", %{user: user} do
sixty_five_minutes_later =
NaiveDateTime.utc_now(:second)
|> NaiveDateTime.shift(minute: 65)
user = user |> Ecto.Changeset.change(type: :sso) |> Repo.update!()
%{sessions: [session]} = Repo.preload(user, :sessions)
assert refreshed_session =
%Auth.UserSession{} =
UserAuth.touch_user_session(session, sixty_five_minutes_later)
assert refreshed_session.id == session.id
assert NaiveDateTime.compare(refreshed_session.last_used_at, sixty_five_minutes_later) ==
:eq
assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :eq
end
end
end
describe "revoke_user_session/2" do
setup [:create_user, :log_in]
test "deletes and disconnects user session", %{user: user} do
assert [active_session] = Repo.preload(user, :sessions).sessions
live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token)
Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id)
another_session =
user
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserAuth.revoke_user_session(user, active_session.id)
assert [remaining_session] = Repo.preload(user, :sessions).sessions
assert_broadcast "disconnect", %{}
assert remaining_session.id == another_session.id
refute Repo.reload(active_session)
assert Repo.reload(another_session)
end
test "does not delete session of another user", %{user: user} do
assert [active_session] = Repo.preload(user, :sessions).sessions
other_session =
insert(:user)
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserAuth.revoke_user_session(user, other_session.id)
assert Repo.reload(active_session)
assert Repo.reload(other_session)
end
test "executes gracefully when session does not exist", %{user: user} do
assert [active_session] = Repo.preload(user, :sessions).sessions
Repo.delete!(active_session)
assert :ok = UserAuth.revoke_user_session(user, active_session.id)
end
end
describe "revoke_all_user_sessions/1" do
setup [:create_user, :log_in]
test "deletes and disconnects all user's sessions", %{user: user} do
assert [active_session] = Repo.preload(user, :sessions).sessions
live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token)
Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id)
another_session =
user
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
unrelated_session =
insert(:user)
|> Auth.UserSession.new_session("Some Device")
|> Repo.insert!()
assert :ok = UserAuth.revoke_all_user_sessions(user)
assert [] = Repo.preload(user, :sessions).sessions
assert_broadcast "disconnect", %{}
refute Repo.reload(another_session)
assert Repo.reload(unrelated_session)
end
test "executes gracefully when user has no sessions" do
user = insert(:user)
assert :ok = UserAuth.revoke_all_user_sessions(user)
end
end
@user_agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36"
@user_agent_mobile "Mozilla/5.0 (Linux; Android 6.0; U007 Pro Build/MRA58K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36"
@user_agent_tablet "Mozilla/5.0 (Linux; U; Android 4.2.2; it-it; Surfing TAB B 9.7 3G Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30"

View File

@ -2,6 +2,8 @@ defmodule Plausible.Teams.Test do
@moduledoc """
Convenience assertions for teams schema transition
"""
use Plausible
alias Plausible.Repo
alias Plausible.Teams
@ -56,6 +58,11 @@ defmodule Plausible.Teams.Test do
def new_user(args \\ []) do
{team_args, args} = Keyword.pop(args, :team, [])
{trial_expiry_date, args} = Keyword.pop(args, :trial_expiry_date)
on_ee do
args = Keyword.merge([type: :standard], args)
end
user = insert(:user, args)
trial_expiry_date =

View File

@ -28,7 +28,7 @@ defmodule Plausible.Workers.CleanUserSessionsTest do
user = insert(:user)
user
|> UserSession.new_session("Unknown", now)
|> UserSession.new_session("Unknown", now: now)
|> Repo.insert!()
end
end