defmodule Plausible.Auth.UserSessions do @moduledoc """ Functions for interacting with user sessions. """ use Plausible 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( from us in Auth.UserSession, where: us.user_id == ^user.id, where: us.timeout_at >= ^now, order_by: [desc: us.last_used_at, desc: us.id] ) end @spec count_for_users([Auth.User.t()], NaiveDateTime.t()) :: list() def count_for_users(users, now \\ NaiveDateTime.utc_now(:second)) when is_list(users) do Repo.all( from(us in Auth.UserSession, where: us.user_id in ^Enum.map(users, & &1.id), where: us.timeout_at >= ^now, group_by: us.user_id, select: {us.user_id, count(us.id)}, order_by: [asc: us.user_id] ) ) end on_ee do alias Plausible.Teams @spec list_sso_for_team(Teams.Team.t(), NaiveDateTime.t()) :: [Auth.UserSession.t()] def list_sso_for_team(team, now \\ NaiveDateTime.utc_now(:second)) do user_ids = Repo.all( from t in Teams.Team, inner_join: tm in assoc(t, :team_memberships), inner_join: u in assoc(tm, :user), where: t.id == ^team.id, where: tm.role != :guest, where: u.type == :sso, select: u.id ) Repo.all( from us in Auth.UserSession, inner_join: u in assoc(us, :user), where: us.user_id in ^user_ids, where: us.timeout_at >= ^now, order_by: [desc: us.last_used_at, desc: us.id], preload: [user: u] ) end @spec revoke_sso_by_id(Teams.Team.t(), pos_integer()) :: :ok def revoke_sso_by_id(team, session_id) do {_, tokens} = Repo.delete_all( from us in Auth.UserSession, inner_join: u in assoc(us, :user), inner_join: tm in assoc(u, :team_memberships), where: u.type == :sso, where: us.id == ^session_id, where: tm.role != :guest, where: tm.team_id == ^team.id, select: us.token ) case tokens do [token] -> disconnect_by_token(token) _ -> :pass end :ok end end @spec last_used_humanize(Auth.UserSession.t(), NaiveDateTime.t()) :: String.t() def last_used_humanize(user_session, now \\ NaiveDateTime.utc_now(:second)) do diff = NaiveDateTime.diff(now, user_session.last_used_at, :hour) diff_days = NaiveDateTime.diff(now, user_session.last_used_at, :day) cond do diff < 1 -> "Just recently" diff == 1 -> "1 hour ago" diff < 24 -> "#{diff} hours ago" diff < 2 * 24 -> "Yesterday" true -> "#{diff_days} days ago" end end @spec get_by_token(String.t()) :: {:ok, Auth.UserSession.t()} | {:error, :not_found} | {:error, :expired, Auth.UserSession.t()} 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, 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 -> if NaiveDateTime.compare(user_session.timeout_at, now) == :gt do {:ok, user_session} else {:error, :expired, user_session} end 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