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:
parent
f86ef2a4c1
commit
4a587e2a6e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue