diff --git a/extra/lib/plausible_web/live/funnel_settings.ex b/extra/lib/plausible_web/live/funnel_settings.ex index a5859f961022..61d0988782e5 100644 --- a/extra/lib/plausible_web/live/funnel_settings.ex +++ b/extra/lib/plausible_web/live/funnel_settings.ex @@ -11,13 +11,13 @@ defmodule PlausibleWeb.Live.FunnelSettings do def mount( _params, - %{"site_id" => site_id, "domain" => domain, "current_user_id" => user_id}, + %{"site_id" => site_id, "domain" => domain}, socket ) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:all_funnels, fn %{site: %{id: ^site_id} = site} -> Funnels.list(site) @@ -32,7 +32,6 @@ defmodule PlausibleWeb.Live.FunnelSettings do displayed_funnels: socket.assigns.all_funnels, setup_funnel?: false, filter_text: "", - current_user_id: user_id, funnel_id: nil )} end @@ -50,7 +49,6 @@ defmodule PlausibleWeb.Live.FunnelSettings do PlausibleWeb.Live.FunnelSettings.Form, id: "funnels-form", session: %{ - "current_user_id" => @current_user_id, "domain" => @domain, "funnel_id" => @funnel_id } @@ -103,7 +101,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do def handle_event("delete-funnel", %{"funnel-id" => id}, socket) do site = - Sites.get_for_user!(socket.assigns.current_user_id, socket.assigns.domain, [:owner, :admin]) + Sites.get_for_user!(socket.assigns.current_user, socket.assigns.domain, [:owner, :admin]) id = String.to_integer(id) :ok = Funnels.delete(site, id) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 6412e6335143..24346e5f27d1 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -11,8 +11,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do import PlausibleWeb.Live.Components.Form alias Plausible.{Sites, Goals, Funnels} - def mount(_params, %{"current_user_id" => user_id, "domain" => domain} = session, socket) do - site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + def mount(_params, %{"domain" => domain} = session, socket) do + site = + Sites.get_for_user!(socket.assigns.current_user, domain, [:owner, :admin, :super_admin]) # We'll have the options trimmed to only the data we care about, to keep # it minimal at the socket assigns, yet, we want to retain specific %Goal{} diff --git a/lib/plausible/auth/user_session.ex b/lib/plausible/auth/user_session.ex new file mode 100644 index 000000000000..84e340d19890 --- /dev/null +++ b/lib/plausible/auth/user_session.ex @@ -0,0 +1,13 @@ +defmodule Plausible.Auth.UserSession do + @moduledoc """ + Schema for storing user session data. + """ + + use Ecto.Schema + + @type t() :: %__MODULE__{} + + embedded_schema do + field :user_id, :integer + end +end diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index abd33e438da8..27468652a2bf 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -287,7 +287,17 @@ defmodule Plausible.Sites do base <> domain <> "?auth=" <> link.slug end - def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]) do + @spec get_for_user!(Auth.User.t() | pos_integer(), String.t(), [ + :super_admin | :owner | :admin | :viewer + ]) :: + Site.t() + def get_for_user!(user, domain, roles \\ [:owner, :admin, :viewer]) + + def get_for_user!(%Auth.User{id: user_id}, domain, roles) do + get_for_user!(user_id, domain, roles) + end + + def get_for_user!(user_id, domain, roles) do if :super_admin in roles and Auth.is_super_admin?(user_id) do get_by_domain!(domain) else @@ -297,7 +307,17 @@ defmodule Plausible.Sites do end end - def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]) do + @spec get_for_user(Auth.User.t() | pos_integer(), String.t(), [ + :super_admin | :owner | :admin | :viewer + ]) :: + Site.t() | nil + def get_for_user(user, domain, roles \\ [:owner, :admin, :viewer]) + + def get_for_user(%Auth.User{id: user_id}, domain, roles) do + get_for_user(user_id, domain, roles) + end + + def get_for_user(user_id, domain, roles) do if :super_admin in roles and Auth.is_super_admin?(user_id) do get_by_domain(domain) else diff --git a/lib/plausible_web.ex b/lib/plausible_web.ex index ff791ffc2b1a..070b6bcc6cf1 100644 --- a/lib/plausible_web.ex +++ b/lib/plausible_web.ex @@ -9,6 +9,8 @@ defmodule PlausibleWeb do use PlausibleWeb.Live.SentryContext end + use PlausibleWeb.Live.AuthContext + alias PlausibleWeb.Router.Helpers, as: Routes alias Phoenix.LiveView.JS end diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index 299064d2f214..90709745c845 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -193,7 +193,8 @@ defmodule PlausibleWeb.Components.Billing do <.styled_link :if={ - not (Plausible.Auth.enterprise_configured?(@user) && Subscriptions.halted?(@subscription)) + not (Plausible.Auth.enterprise_configured?(@user) && + Subscriptions.halted?(@subscription)) } id="#upgrade-or-change-plan-link" href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} diff --git a/lib/plausible_web/components/billing/plan_box.ex b/lib/plausible_web/components/billing/plan_box.ex index 9aaf66050949..2770a2e5dc8b 100644 --- a/lib/plausible_web/components/billing/plan_box.ex +++ b/lib/plausible_web/components/billing/plan_box.ex @@ -174,7 +174,7 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do paddle_product_id = get_paddle_product_id(assigns.plan_to_render, assigns.selected_interval) change_plan_link_text = change_plan_link_text(assigns) - subscription = assigns.user.subscription + subscription = assigns.current_user.subscription billing_details_expired = Subscription.Status.in?(subscription, [ @@ -224,10 +224,10 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do |> assign(:confirm_message, losing_features_message(feature_usage_check)) ~H""" - <%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@user.subscription) do %> + <%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@current_user.subscription) do %> <.change_plan_link {assigns} /> <% else %> - + Upgrade <% end %> @@ -259,21 +259,22 @@ defmodule PlausibleWeb.Components.Billing.PlanBox do defp check_usage_within_plan_limits(%{ available: true, usage: usage, - user: user, + current_user: current_user, plan_to_render: plan }) do # At this point, the user is *not guaranteed* to have a `trial_expiry_date`, # because in the past we've let users upgrade without that constraint, as # well as transfer sites to those accounts. to these accounts we won't be # offering an extra pageview limit allowance margin though. - invited_user? = is_nil(user.trial_expiry_date) + invited_user? = is_nil(current_user.trial_expiry_date) trial_active_or_ended_recently? = - not invited_user? && Timex.diff(Date.utc_today(), user.trial_expiry_date, :days) <= 10 + not invited_user? && + Timex.diff(Date.utc_today(), current_user.trial_expiry_date, :days) <= 10 limit_checking_opts = cond do - user.allow_next_upgrade_override -> + current_user.allow_next_upgrade_override -> [ignore_pageview_limit: true] trial_active_or_ended_recently? && plan.volume == "10k" -> diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 5cb549f5d2a7..4db14ec22cbc 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -749,8 +749,12 @@ defmodule PlausibleWeb.Api.StatsController do query = Query.from(site, params, debug_metadata(conn)) - user_id = get_session(conn, :current_user_id) - is_admin = user_id && Plausible.Sites.has_admin_access?(user_id, site) + is_admin = + if current_user = conn.assigns[:current_user] do + Plausible.Sites.has_admin_access?(current_user.id, site) + else + false + end pagination = { to_int(params["limit"], 9), diff --git a/lib/plausible_web/controllers/auth_controller.ex b/lib/plausible_web/controllers/auth_controller.ex index ac800d9ad928..d663f9d80eef 100644 --- a/lib/plausible_web/controllers/auth_controller.ex +++ b/lib/plausible_web/controllers/auth_controller.ex @@ -5,6 +5,7 @@ defmodule PlausibleWeb.AuthController do alias Plausible.Auth alias Plausible.Billing.Quota alias PlausibleWeb.TwoFactor + alias PlausibleWeb.UserAuth require Logger @@ -193,10 +194,9 @@ defmodule PlausibleWeb.AuthController do def password_reset(conn, _params) do conn + |> UserAuth.log_out_user() |> put_flash(:login_title, "Password updated successfully") |> put_flash(:login_instructions, "Please log in with your new credentials") - |> put_session(:current_user_id, nil) - |> delete_resp_cookie("logged_in") |> redirect(to: Routes.auth_path(conn, :login_form)) end @@ -214,7 +214,7 @@ defmodule PlausibleWeb.AuthController do :ok <- Auth.rate_limit(:login_user, user), :ok <- Auth.check_password(user, password), :ok <- check_2fa_verified(conn, user) do - conn = + redirect_path = cond do not is_nil(params["register_action"]) and not user.email_verified -> Auth.EmailVerification.issue_code(user) @@ -226,19 +226,19 @@ defmodule PlausibleWeb.AuthController do "invitation" end - put_session(conn, :login_dest, Routes.auth_path(conn, :activate_form, flow: flow)) + Routes.auth_path(conn, :activate_form, flow: flow) params["register_action"] == "register_from_invitation_form" -> - put_session(conn, :login_dest, Routes.site_path(conn, :index)) + Routes.site_path(conn, :index) params["register_action"] == "register_form" -> - put_session(conn, :login_dest, Routes.site_path(conn, :new)) + Routes.site_path(conn, :new) true -> - conn + nil end - set_user_session_and_redirect(conn, user) + UserAuth.log_in_user(conn, user, redirect_path) else {:error, :wrong_password} -> maybe_log_failed_login_attempts("wrong password for #{email}") @@ -388,7 +388,7 @@ defmodule PlausibleWeb.AuthController do {:ok, user} -> conn |> TwoFactor.Session.maybe_set_remember_2fa(user, params["remember_2fa"]) - |> set_user_session_and_redirect(user) + |> UserAuth.log_in_user(user) {:error, :invalid_code} -> maybe_log_failed_login_attempts( @@ -403,7 +403,7 @@ defmodule PlausibleWeb.AuthController do ) {:error, :not_enabled} -> - set_user_session_and_redirect(conn, user) + UserAuth.log_in_user(conn, user) end end end @@ -428,7 +428,7 @@ defmodule PlausibleWeb.AuthController do with {:ok, user} <- get_2fa_user_limited(conn) do case Auth.TOTP.use_recovery_code(user, recovery_code) do :ok -> - set_user_session_and_redirect(conn, user) + UserAuth.log_in_user(conn, user) {:error, :invalid_code} -> maybe_log_failed_login_attempts("wrong 2FA recovery code provided for #{user.email}") @@ -440,7 +440,7 @@ defmodule PlausibleWeb.AuthController do ) {:error, :not_enabled} -> - set_user_session_and_redirect(conn, user) + UserAuth.log_in_user(conn, user) end end end @@ -552,25 +552,25 @@ defmodule PlausibleWeb.AuthController do end defp render_settings(conn, opts) do + current_user = conn.assigns.current_user settings_changeset = Keyword.fetch!(opts, :settings_changeset) email_changeset = Keyword.fetch!(opts, :email_changeset) - - user = Plausible.Users.with_subscription(conn.assigns[:current_user]) + api_keys = Repo.preload(current_user, :api_keys).api_keys render(conn, "user_settings.html", - user: user |> Repo.preload(:api_keys), + api_keys: api_keys, settings_changeset: settings_changeset, email_changeset: email_changeset, - subscription: user.subscription, - invoices: Plausible.Billing.paddle_api().get_invoices(user.subscription), - theme: user.theme || "system", - team_member_limit: Quota.Limits.team_member_limit(user), - team_member_usage: Quota.Usage.team_member_usage(user), - site_limit: Quota.Limits.site_limit(user), - site_usage: Quota.Usage.site_usage(user), - pageview_limit: Quota.Limits.monthly_pageview_limit(user), - pageview_usage: Quota.Usage.monthly_pageview_usage(user), - totp_enabled?: Auth.TOTP.enabled?(user) + subscription: current_user.subscription, + invoices: Plausible.Billing.paddle_api().get_invoices(current_user.subscription), + theme: current_user.theme || "system", + team_member_limit: Quota.Limits.team_member_limit(current_user), + team_member_usage: Quota.Usage.team_member_usage(current_user), + site_limit: Quota.Limits.site_limit(current_user), + site_usage: Quota.Usage.site_usage(current_user), + pageview_limit: Quota.Limits.monthly_pageview_limit(current_user), + pageview_usage: Quota.Usage.monthly_pageview_usage(current_user), + totp_enabled?: Auth.TOTP.enabled?(current_user) ) end @@ -622,8 +622,7 @@ defmodule PlausibleWeb.AuthController do redirect_to = Map.get(params, "redirect", "/") conn - |> configure_session(drop: true) - |> delete_resp_cookie("logged_in") + |> UserAuth.log_out_user() |> redirect(to: redirect_to) end @@ -712,25 +711,6 @@ defmodule PlausibleWeb.AuthController do redirect(conn, to: Routes.auth_path(conn, :login_form)) end - defp set_user_session_and_redirect(conn, user) do - login_dest = get_session(conn, :login_dest) || Routes.site_path(conn, :index) - - conn - |> set_user_session(user) - |> put_session(:login_dest, nil) - |> redirect(external: login_dest) - end - - defp set_user_session(conn, user) do - conn - |> TwoFactor.Session.clear_2fa_user() - |> put_session(:current_user_id, user.id) - |> put_resp_cookie("logged_in", "true", - http_only: false, - max_age: 60 * 60 * 24 * 365 * 5000 - ) - end - defp maybe_log_failed_login_attempts(message) do if Application.get_env(:plausible, :log_failed_login_attempts) do Logger.warning("[login] #{message}") diff --git a/lib/plausible_web/controllers/billing_controller.ex b/lib/plausible_web/controllers/billing_controller.ex index c785c265fc59..2f88f6d283ec 100644 --- a/lib/plausible_web/controllers/billing_controller.ex +++ b/lib/plausible_web/controllers/billing_controller.ex @@ -14,14 +14,13 @@ defmodule PlausibleWeb.BillingController do end def choose_plan(conn, _params) do - user = conn.assigns.current_user + current_user = conn.assigns.current_user - if Plausible.Auth.enterprise_configured?(user) do + if Plausible.Auth.enterprise_configured?(current_user) do redirect(conn, to: Routes.billing_path(conn, :upgrade_to_enterprise_plan)) else render(conn, "choose_plan.html", skip_plausible_tracking: true, - user: user, layout: {PlausibleWeb.LayoutView, "focus.html"}, connect_live_socket: true ) @@ -29,19 +28,20 @@ defmodule PlausibleWeb.BillingController do end def upgrade_to_enterprise_plan(conn, _params) do - user = Plausible.Users.with_subscription(conn.assigns.current_user) + current_user = conn.assigns.current_user {latest_enterprise_plan, price} = - Plans.latest_enterprise_plan_with_price(user, PlausibleWeb.RemoteIP.get(conn)) + Plans.latest_enterprise_plan_with_price(current_user, PlausibleWeb.RemoteIP.get(conn)) - subscription_resumable? = Plausible.Billing.Subscriptions.resumable?(user.subscription) + subscription_resumable? = + Plausible.Billing.Subscriptions.resumable?(current_user.subscription) subscribed_to_latest? = subscription_resumable? && - user.subscription.paddle_plan_id == latest_enterprise_plan.paddle_plan_id + current_user.subscription.paddle_plan_id == latest_enterprise_plan.paddle_plan_id cond do - Subscription.Status.in?(user.subscription, [ + Subscription.Status.in?(current_user.subscription, [ Subscription.Status.past_due(), Subscription.Status.paused() ]) -> @@ -55,7 +55,6 @@ defmodule PlausibleWeb.BillingController do true -> render(conn, "upgrade_to_enterprise_plan.html", - user: user, latest_enterprise_plan: latest_enterprise_plan, price: price, subscription_resumable: subscription_resumable?, @@ -71,9 +70,9 @@ defmodule PlausibleWeb.BillingController do end def change_plan_preview(conn, %{"plan_id" => new_plan_id}) do - user = conn.assigns.current_user + current_user = conn.assigns.current_user - case preview_subscription(user, new_plan_id) do + case preview_subscription(current_user, new_plan_id) do {:ok, {subscription, preview_info}} -> render(conn, "change_plan_preview.html", back_link: Routes.billing_path(conn, :choose_plan), @@ -91,7 +90,7 @@ defmodule PlausibleWeb.BillingController do extra: %{ message: msg, new_plan_id: new_plan_id, - user_id: user.id + user_id: current_user.id } ) @@ -102,7 +101,9 @@ defmodule PlausibleWeb.BillingController do end def change_plan(conn, %{"new_plan_id" => new_plan_id}) do - case Billing.change_plan(conn.assigns.current_user, new_plan_id) do + current_user = conn.assigns.current_user + + case Billing.change_plan(current_user, new_plan_id) do {:ok, _subscription} -> conn |> put_flash(:success, "Plan changed successfully") @@ -130,7 +131,7 @@ defmodule PlausibleWeb.BillingController do errors: inspect(e), message: msg, new_plan_id: new_plan_id, - user_id: conn.assigns[:current_user].id + user_id: current_user.id } ) diff --git a/lib/plausible_web/controllers/helpers.ex b/lib/plausible_web/controllers/helpers.ex index e6c3edea6608..022fd75a4a74 100644 --- a/lib/plausible_web/controllers/helpers.ex +++ b/lib/plausible_web/controllers/helpers.ex @@ -33,5 +33,11 @@ defmodule PlausibleWeb.ControllerHelpers do end defp get_user_id(_conn, %{current_user: user}), do: user.id - defp get_user_id(conn, _assigns), do: get_session(conn, :current_user_id) + + defp get_user_id(conn, _assigns) do + case PlausibleWeb.UserAuth.get_user_session(conn) do + {:ok, user_session} -> user_session.user_id + _ -> nil + end + end end diff --git a/lib/plausible_web/live/auth_context.ex b/lib/plausible_web/live/auth_context.ex new file mode 100644 index 000000000000..4b982aedf923 --- /dev/null +++ b/lib/plausible_web/live/auth_context.ex @@ -0,0 +1,39 @@ +defmodule PlausibleWeb.Live.AuthContext do + @moduledoc """ + This module supplies LiveViews with currently logged in user data _if_ session + data contains a valid token. + + Must be kept in sync with `PlausibleWeb.AuthPlug`. + """ + + import Phoenix.Component + + alias PlausibleWeb.UserAuth + + defmacro __using__(_) do + quote do + on_mount unquote(__MODULE__) + end + end + + def on_mount(:default, _params, session, socket) do + socket = + socket + |> assign_new(:current_user_session, fn -> + case UserAuth.get_user_session(session) do + {:ok, user_session} -> user_session + _ -> nil + end + end) + |> assign_new(:current_user, fn context -> + with %{} = user_session <- context.current_user_session, + {:ok, user} <- UserAuth.get_user(user_session) do + user + else + _ -> nil + end + end) + + {:cont, socket} + end +end diff --git a/lib/plausible_web/live/choose_plan.ex b/lib/plausible_web/live/choose_plan.ex index f4681798de66..f80580220940 100644 --- a/lib/plausible_web/live/choose_plan.ex +++ b/lib/plausible_web/live/choose_plan.ex @@ -9,43 +9,39 @@ defmodule PlausibleWeb.Live.ChoosePlan do alias PlausibleWeb.Components.Billing.{PlanBox, PlanBenefits, Notice, PageviewSlider} alias Plausible.Site - alias Plausible.Users alias Plausible.Billing.{Plans, Quota} @contact_link "https://plausible.io/contact" @billing_faq_link "https://plausible.io/docs/billing" - def mount(_params, %{"current_user_id" => user_id, "remote_ip" => remote_ip}, socket) do + def mount(_params, %{"remote_ip" => remote_ip}, socket) do socket = socket - |> assign_new(:user, fn -> - Users.with_subscription(user_id) - end) - |> assign_new(:pending_ownership_site_ids, fn %{user: user} -> - user.email + |> assign_new(:pending_ownership_site_ids, fn %{current_user: current_user} -> + current_user.email |> Site.Memberships.all_pending_ownerships() |> Enum.map(& &1.site_id) end) |> assign_new(:usage, fn %{ - user: user, + current_user: current_user, pending_ownership_site_ids: pending_ownership_site_ids } -> - Quota.Usage.usage(user, + Quota.Usage.usage(current_user, with_features: true, pending_ownership_site_ids: pending_ownership_site_ids ) end) - |> assign_new(:owned_plan, fn %{user: %{subscription: subscription}} -> + |> assign_new(:owned_plan, fn %{current_user: %{subscription: subscription}} -> Plans.get_regular_plan(subscription, only_non_expired: true) end) |> assign_new(:owned_tier, fn %{owned_plan: owned_plan} -> if owned_plan, do: Map.get(owned_plan, :kind), else: nil end) - |> assign_new(:current_interval, fn %{user: user} -> - current_user_subscription_interval(user.subscription) + |> assign_new(:current_interval, fn %{current_user: current_user} -> + current_user_subscription_interval(current_user.subscription) end) - |> assign_new(:available_plans, fn %{user: user} -> - Plans.available_plans_for(user, with_prices: true, customer_ip: remote_ip) + |> assign_new(:available_plans, fn %{current_user: current_user} -> + Plans.available_plans_for(current_user, with_prices: true, customer_ip: remote_ip) end) |> assign_new(:recommended_tier, fn %{usage: usage, available_plans: available_plans} -> highest_growth_plan = List.last(available_plans.growth) @@ -106,8 +102,8 @@ defmodule PlausibleWeb.Live.ChoosePlan do class="pb-6" pending_ownership_count={length(@pending_ownership_site_ids)} /> - - + +

diff --git a/lib/plausible_web/live/csv_import.ex b/lib/plausible_web/live/csv_import.ex index bfc617ae9cbe..5234ba1fe7f0 100644 --- a/lib/plausible_web/live/csv_import.ex +++ b/lib/plausible_web/live/csv_import.ex @@ -15,7 +15,7 @@ defmodule PlausibleWeb.Live.CSVImport do # to check that current_user_role is allowed to make site imports @impl true def mount(:not_mounted_at_router, session, socket) do - %{"site_id" => site_id, "current_user_id" => user_id, "storage" => storage} = session + %{"site_id" => site_id, "storage" => storage} = session upload_opts = [ accept: [".csv", "text/csv"], @@ -64,7 +64,6 @@ defmodule PlausibleWeb.Live.CSVImport do socket |> assign( site_id: site_id, - user_id: user_id, storage: storage, upload_consumer: upload_consumer, occupied_ranges: occupied_ranges, @@ -214,17 +213,16 @@ defmodule PlausibleWeb.Live.CSVImport do %{ storage: storage, site: site, - user_id: user_id, + current_user: current_user, clamped_date_range: clamped_date_range, upload_consumer: upload_consumer } = socket.assigns - user = Plausible.Repo.get!(Plausible.Auth.User, user_id) uploads = consume_uploaded_entries(socket, :import, upload_consumer) {:ok, _job} = - CSVImporter.new_import(site, user, + CSVImporter.new_import(site, current_user, start_date: clamped_date_range.first, end_date: clamped_date_range.last, uploads: uploads, diff --git a/lib/plausible_web/live/goal_settings.ex b/lib/plausible_web/live/goal_settings.ex index 2ad543cbe7db..eac4d5c1869e 100644 --- a/lib/plausible_web/live/goal_settings.ex +++ b/lib/plausible_web/live/goal_settings.ex @@ -10,13 +10,13 @@ defmodule PlausibleWeb.Live.GoalSettings do def mount( _params, - %{"site_id" => site_id, "domain" => domain, "current_user_id" => user_id}, + %{"site_id" => site_id, "domain" => domain}, socket ) do socket = socket - |> assign_new(:site, fn -> - user_id + |> assign_new(:site, fn %{current_user: current_user} -> + current_user |> Sites.get_for_user!(domain, [:owner, :admin, :super_admin]) |> Plausible.Imported.load_import_data() end) @@ -34,9 +34,6 @@ defmodule PlausibleWeb.Live.GoalSettings do limit: :unlimited ) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) {:ok, assign(socket, diff --git a/lib/plausible_web/live/imports_exports_settings.ex b/lib/plausible_web/live/imports_exports_settings.ex index b1c822ba3394..ef8370bab080 100644 --- a/lib/plausible_web/live/imports_exports_settings.ex +++ b/lib/plausible_web/live/imports_exports_settings.ex @@ -14,15 +14,11 @@ defmodule PlausibleWeb.Live.ImportsExportsSettings do require Plausible.Imported.SiteImport - def mount( - _params, - %{"domain" => domain, "current_user_id" => user_id}, - socket - ) do + def mount(_params, %{"domain" => domain}, socket) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:site_imports, fn %{site: site} -> site @@ -34,9 +30,6 @@ defmodule PlausibleWeb.Live.ImportsExportsSettings do |> assign_new(:pageview_counts, fn %{site: site} -> Plausible.Stats.Clickhouse.imported_pageview_counts(site) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) :ok = Imported.listen() diff --git a/lib/plausible_web/live/plugins/api/settings.ex b/lib/plausible_web/live/plugins/api/settings.ex index c9bbb25c1e9d..3a0bbc144349 100644 --- a/lib/plausible_web/live/plugins/api/settings.ex +++ b/lib/plausible_web/live/plugins/api/settings.ex @@ -9,15 +9,11 @@ defmodule PlausibleWeb.Live.Plugins.API.Settings do alias Plausible.Sites alias Plausible.Plugins.API.Tokens - def mount( - _params, - %{"domain" => domain, "current_user_id" => user_id} = session, - socket - ) do + def mount(_params, %{"domain" => domain} = session, socket) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:displayed_tokens, fn %{site: site} -> Tokens.list(site) @@ -27,8 +23,7 @@ defmodule PlausibleWeb.Live.Plugins.API.Settings do assign(socket, domain: domain, add_token?: not is_nil(session["new_token"]), - token_description: session["new_token"] || "", - current_user_id: user_id + token_description: session["new_token"] || "" )} end @@ -42,7 +37,6 @@ defmodule PlausibleWeb.Live.Plugins.API.Settings do PlausibleWeb.Live.Plugins.API.TokenForm, id: "token-form", session: %{ - "current_user_id" => @current_user_id, "domain" => @domain, "token_description" => @token_description, "rendered_by" => self() diff --git a/lib/plausible_web/live/plugins/api/token_form.ex b/lib/plausible_web/live/plugins/api/token_form.ex index 2b5026666b32..732970e6d967 100644 --- a/lib/plausible_web/live/plugins/api/token_form.ex +++ b/lib/plausible_web/live/plugins/api/token_form.ex @@ -5,7 +5,6 @@ defmodule PlausibleWeb.Live.Plugins.API.TokenForm do use PlausibleWeb, live_view: :no_sentry_context import PlausibleWeb.Live.Components.Form - alias Plausible.Repo alias Plausible.Sites alias Plausible.Plugins.API.{Token, Tokens} @@ -13,7 +12,6 @@ defmodule PlausibleWeb.Live.Plugins.API.TokenForm do _params, %{ "token_description" => token_description, - "current_user_id" => user_id, "domain" => domain, "rendered_by" => pid }, @@ -21,8 +19,8 @@ defmodule PlausibleWeb.Live.Plugins.API.TokenForm do ) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) token = Token.generate() @@ -32,7 +30,6 @@ defmodule PlausibleWeb.Live.Plugins.API.TokenForm do assign(socket, token_description: token_description, token: token, - current_user: Repo.get(Plausible.Auth.User, user_id), form: form, domain: domain, rendered_by: pid, diff --git a/lib/plausible_web/live/props_settings.ex b/lib/plausible_web/live/props_settings.ex index b850d656be9c..26f35b49c5ec 100644 --- a/lib/plausible_web/live/props_settings.ex +++ b/lib/plausible_web/live/props_settings.ex @@ -8,15 +8,11 @@ defmodule PlausibleWeb.Live.PropsSettings do alias PlausibleWeb.Live.Components.ComboBox - def mount( - _params, - %{"site_id" => site_id, "domain" => domain, "current_user_id" => user_id}, - socket - ) do + def mount(_params, %{"site_id" => site_id, "domain" => domain}, socket) do socket = socket - |> assign_new(:site, fn -> - Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Plausible.Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:all_props, fn %{site: site} -> site.allowed_event_props || [] @@ -29,7 +25,6 @@ defmodule PlausibleWeb.Live.PropsSettings do assign(socket, site_id: site_id, domain: domain, - current_user_id: user_id, add_prop?: false, filter_text: "" )} @@ -45,7 +40,6 @@ defmodule PlausibleWeb.Live.PropsSettings do PlausibleWeb.Live.PropsSettings.Form, id: "props-form", session: %{ - "current_user_id" => @current_user_id, "domain" => @domain, "site_id" => @site_id, "rendered_by" => self() diff --git a/lib/plausible_web/live/props_settings/form.ex b/lib/plausible_web/live/props_settings/form.ex index b0f750b8b42a..30c18a170d8c 100644 --- a/lib/plausible_web/live/props_settings/form.ex +++ b/lib/plausible_web/live/props_settings/form.ex @@ -10,23 +10,25 @@ defmodule PlausibleWeb.Live.PropsSettings.Form do _params, %{ "site_id" => _site_id, - "current_user_id" => user_id, "domain" => domain, "rendered_by" => pid }, socket ) do - site = Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) - - form = new_form(site) + socket = + socket + |> assign_new(:site, fn %{current_user: current_user} -> + Plausible.Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) + end) + |> assign_new(:form, fn %{site: site} -> + new_form(site) + end) {:ok, assign(socket, - form: form, domain: domain, rendered_by: pid, - prop_key_options_count: 0, - site: site + prop_key_options_count: 0 )} end diff --git a/lib/plausible_web/live/sentry_context.ex b/lib/plausible_web/live/sentry_context.ex index c3730662cfdf..93c5ef07d22a 100644 --- a/lib/plausible_web/live/sentry_context.ex +++ b/lib/plausible_web/live/sentry_context.ex @@ -49,12 +49,14 @@ defmodule PlausibleWeb.Live.SentryContext do Sentry.Context.set_request_context(request_context) - user_id = session["current_user_id"] + case PlausibleWeb.UserAuth.get_user_session(session) do + {:ok, user_session} -> + Sentry.Context.set_user_context(%{ + id: user_session.user_id + }) - if user_id do - Sentry.Context.set_user_context(%{ - id: user_id - }) + _ -> + :pass end end diff --git a/lib/plausible_web/live/shields/countries.ex b/lib/plausible_web/live/shields/countries.ex index e26632456a81..e08e4d8db2a3 100644 --- a/lib/plausible_web/live/shields/countries.ex +++ b/lib/plausible_web/live/shields/countries.ex @@ -10,23 +10,17 @@ defmodule PlausibleWeb.Live.Shields.Countries do def mount( _params, - %{ - "domain" => domain, - "current_user_id" => user_id - }, + %{"domain" => domain}, socket ) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:country_rules_count, fn %{site: site} -> Shields.count_country_rules(site) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) {:ok, socket} end @@ -40,7 +34,7 @@ defmodule PlausibleWeb.Live.Shields.Countries do current_user={@current_user} country_rules_count={@country_rules_count} site={@site} - id="country-rules-#{@current_user.id}" + id={"country-rules-#{@current_user.id}"} />

""" diff --git a/lib/plausible_web/live/shields/hostnames.ex b/lib/plausible_web/live/shields/hostnames.ex index 3bc7b424ca6f..efc3d32c4edd 100644 --- a/lib/plausible_web/live/shields/hostnames.ex +++ b/lib/plausible_web/live/shields/hostnames.ex @@ -8,25 +8,15 @@ defmodule PlausibleWeb.Live.Shields.Hostnames do alias Plausible.Shields alias Plausible.Sites - def mount( - _params, - %{ - "domain" => domain, - "current_user_id" => user_id - }, - socket - ) do + def mount(_params, %{"domain" => domain}, socket) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:hostname_rules_count, fn %{site: site} -> Shields.count_hostname_rules(site) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) {:ok, socket} end @@ -40,7 +30,7 @@ defmodule PlausibleWeb.Live.Shields.Hostnames do current_user={@current_user} hostname_rules_count={@hostname_rules_count} site={@site} - id="hostname-rules-#{@current_user.id}" + id={"hostname-rules-#{@current_user.id}"} /> """ diff --git a/lib/plausible_web/live/shields/ip_addresses.ex b/lib/plausible_web/live/shields/ip_addresses.ex index f0994a2193c4..11358a142821 100644 --- a/lib/plausible_web/live/shields/ip_addresses.ex +++ b/lib/plausible_web/live/shields/ip_addresses.ex @@ -12,22 +12,18 @@ defmodule PlausibleWeb.Live.Shields.IPAddresses do _params, %{ "remote_ip" => remote_ip, - "domain" => domain, - "current_user_id" => user_id + "domain" => domain }, socket ) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:ip_rules_count, fn %{site: site} -> Shields.count_ip_rules(site) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) |> assign_new(:remote_ip, fn -> remote_ip end) {:ok, socket} @@ -43,7 +39,7 @@ defmodule PlausibleWeb.Live.Shields.IPAddresses do ip_rules_count={@ip_rules_count} site={@site} remote_ip={@remote_ip} - id="ip-rules-#{@current_user.id}" + id={"ip-rules-#{@current_user.id}"} /> """ diff --git a/lib/plausible_web/live/shields/pages.ex b/lib/plausible_web/live/shields/pages.ex index cd42c47b3183..55d8ed1894ca 100644 --- a/lib/plausible_web/live/shields/pages.ex +++ b/lib/plausible_web/live/shields/pages.ex @@ -8,25 +8,15 @@ defmodule PlausibleWeb.Live.Shields.Pages do alias Plausible.Shields alias Plausible.Sites - def mount( - _params, - %{ - "domain" => domain, - "current_user_id" => user_id - }, - socket - ) do + def mount(_params, %{"domain" => domain}, socket) do socket = socket - |> assign_new(:site, fn -> - Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin]) + |> assign_new(:site, fn %{current_user: current_user} -> + Sites.get_for_user!(current_user, domain, [:owner, :admin, :super_admin]) end) |> assign_new(:page_rules_count, fn %{site: site} -> Shields.count_page_rules(site) end) - |> assign_new(:current_user, fn -> - Plausible.Repo.get(Plausible.Auth.User, user_id) - end) {:ok, socket} end @@ -40,7 +30,7 @@ defmodule PlausibleWeb.Live.Shields.Pages do current_user={@current_user} page_rules_count={@page_rules_count} site={@site} - id="page-rules-#{@current_user.id}" + id={"page-rules-#{@current_user.id}"} /> """ diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index d9621fee6e0d..974c5ed7a8bc 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -10,12 +10,11 @@ defmodule PlausibleWeb.Live.Sites do import PlausibleWeb.Live.Components.Pagination alias Plausible.Auth - alias Plausible.Repo alias Plausible.Site alias Plausible.Sites alias Plausible.Site.Memberships.Invitations - def mount(params, %{"current_user_id" => user_id}, socket) do + def mount(params, _session, socket) do uri = ("/sites?" <> URI.encode_query(Map.take(params, ["filter_text"]))) |> URI.new!() @@ -24,7 +23,6 @@ defmodule PlausibleWeb.Live.Sites do socket |> assign(:uri, uri) |> assign(:filter_text, params["filter_text"] || "") - |> assign(:user, Repo.get!(Auth.User, user_id)) {:ok, socket} end @@ -34,17 +32,17 @@ defmodule PlausibleWeb.Live.Sites do socket |> assign(:params, params) |> load_sites() - |> assign_new(:has_sites?, fn %{user: user} -> - Site.Memberships.any_or_pending?(user) + |> assign_new(:has_sites?, fn %{current_user: current_user} -> + Site.Memberships.any_or_pending?(current_user) end) - |> assign_new(:needs_to_upgrade, fn %{user: user, sites: sites} -> + |> assign_new(:needs_to_upgrade, fn %{current_user: current_user, sites: sites} -> user_owns_sites = Enum.any?(sites.entries, fn site -> List.first(site.memberships ++ site.invitations).role == :owner end) || - Auth.user_owns_sites?(user) + Auth.user_owns_sites?(current_user) - user_owns_sites && Plausible.Billing.check_needs_to_upgrade(user) + user_owns_sites && Plausible.Billing.check_needs_to_upgrade(current_user) end) {:noreply, socket} @@ -112,10 +110,7 @@ defmodule PlausibleWeb.Live.Sites do > Total of <%= @sites.total_entries %> sites - <.invitation_modal - :if={Enum.any?(@sites.entries, &(&1.entry_type == "invitation"))} - user={@user} - /> + <.invitation_modal :if={Enum.any?(@sites.entries, &(&1.entry_type == "invitation"))} /> """ @@ -345,8 +340,6 @@ defmodule PlausibleWeb.Live.Sites do """ end - attr :user, Plausible.Auth.User, required: true - def invitation_modal(assigns) do ~H"""
flash_message = if preference.pinned_at do @@ -599,7 +592,7 @@ defmodule PlausibleWeb.Live.Sites do {:noreply, socket} else Sentry.capture_message("Attempting to toggle pin for invalid domain.", - extra: %{domain: domain, user: socket.assigns.user.id} + extra: %{domain: domain, user: socket.assigns.current_user.id} ) {:noreply, socket} @@ -634,7 +627,7 @@ defmodule PlausibleWeb.Live.Sites do defp load_sites(%{assigns: assigns} = socket) do sites = - Sites.list_with_invitations(assigns.user, assigns.params, + Sites.list_with_invitations(assigns.current_user, assigns.params, filter_by_domain: assigns.filter_text ) @@ -648,7 +641,7 @@ defmodule PlausibleWeb.Live.Sites do end) end - invitations = extract_invitations(sites.entries, assigns.user) + invitations = extract_invitations(sites.entries, assigns.current_user) assign( socket, diff --git a/lib/plausible_web/plugs/auth_plug.ex b/lib/plausible_web/plugs/auth_plug.ex index cc0597467c3a..68355c565c29 100644 --- a/lib/plausible_web/plugs/auth_plug.ex +++ b/lib/plausible_web/plugs/auth_plug.ex @@ -1,19 +1,32 @@ defmodule PlausibleWeb.AuthPlug do + @moduledoc """ + Plug for populating conn assigns with user data + on the basis of authenticated session token. + + Must be kept in sync with `PlausibleWeb.Live.AuthContext`. + """ + import Plug.Conn use Plausible.Repo + alias PlausibleWeb.UserAuth + def init(options) do options end def call(conn, _opts) do - with id when is_integer(id) <- get_session(conn, :current_user_id), - %Plausible.Auth.User{} = user <- Plausible.Users.with_subscription(id) do + with {:ok, user_session} <- UserAuth.get_user_session(conn), + {:ok, user} <- UserAuth.get_user(user_session) do Plausible.OpenTelemetry.add_user_attributes(user) Sentry.Context.set_user_context(%{id: user.id, name: user.name, email: user.email}) - assign(conn, :current_user, user) + + conn + |> assign(:current_user, user) + |> assign(:current_user_session, user_session) else - nil -> conn + _ -> + conn end end end diff --git a/lib/plausible_web/plugs/authorize_site_access.ex b/lib/plausible_web/plugs/authorize_site_access.ex index 619d28327ab0..0f8cd981b7c6 100644 --- a/lib/plausible_web/plugs/authorize_site_access.ex +++ b/lib/plausible_web/plugs/authorize_site_access.ex @@ -20,7 +20,12 @@ defmodule PlausibleWeb.AuthorizeSiteAccess do if !site do PlausibleWeb.ControllerHelpers.render_error(conn, 404) |> halt else - user_id = get_session(conn, :current_user_id) + user_id = + case PlausibleWeb.UserAuth.get_user_session(conn) do + {:ok, user_session} -> user_session.user_id + _ -> nil + end + membership_role = user_id && Plausible.Sites.role(user_id, site) role = diff --git a/lib/plausible_web/plugs/require_logged_out.ex b/lib/plausible_web/plugs/require_logged_out.ex index ac35a4e7fb28..f1cd1d448ad6 100644 --- a/lib/plausible_web/plugs/require_logged_out.ex +++ b/lib/plausible_web/plugs/require_logged_out.ex @@ -1,23 +1,18 @@ defmodule PlausibleWeb.RequireLoggedOutPlug do import Plug.Conn - def init(options) do - options + def init(opts \\ []) do + opts end def call(conn, _opts) do - cond do - conn.assigns[:current_user] -> - conn - |> put_resp_cookie("logged_in", "true", - http_only: false, - max_age: 60 * 60 * 24 * 365 * 5000 - ) - |> Phoenix.Controller.redirect(to: "/sites") - |> halt - - :else -> - conn + if conn.assigns[:current_user] do + conn + |> PlausibleWeb.UserAuth.set_logged_in_cookie() + |> Phoenix.Controller.redirect(to: "/sites") + |> halt() + else + conn end end end diff --git a/lib/plausible_web/plugs/session_timeout_plug.ex b/lib/plausible_web/plugs/session_timeout_plug.ex index efa9fa054137..90a4501344dd 100644 --- a/lib/plausible_web/plugs/session_timeout_plug.ex +++ b/lib/plausible_web/plugs/session_timeout_plug.ex @@ -1,19 +1,29 @@ defmodule PlausibleWeb.SessionTimeoutPlug do + @moduledoc """ + NOTE: This plug will be replaced with a different + session expiration mechanism once server-side persisted + sessions are rolled out. + """ import Plug.Conn + alias PlausibleWeb.UserAuth + def init(opts \\ []) do opts end def call(conn, opts) do timeout_at = get_session(conn, :session_timeout_at) - user_id = get_session(conn, :current_user_id) + + user_id = + case UserAuth.get_user_session(conn) do + {:ok, session} -> session.user_id + _ -> nil + end cond do user_id && timeout_at && now() > timeout_at -> - conn - |> configure_session(drop: true) - |> delete_resp_cookie("logged_in") + PlausibleWeb.UserAuth.log_out_user(conn) user_id -> put_session( diff --git a/lib/plausible_web/plugs/super_admin_only_plug.ex b/lib/plausible_web/plugs/super_admin_only_plug.ex index 812ed1d2c568..e369a8374dfc 100644 --- a/lib/plausible_web/plugs/super_admin_only_plug.ex +++ b/lib/plausible_web/plugs/super_admin_only_plug.ex @@ -9,18 +9,12 @@ defmodule PlausibleWeb.SuperAdminOnlyPlug do end def call(conn, _opts) do - case get_session(conn, :current_user_id) do - nil -> + with {:ok, user} <- PlausibleWeb.UserAuth.get_user(conn), + true <- Plausible.Auth.is_super_admin?(user) do + assign(conn, :current_user, user) + else + _ -> conn |> send_resp(403, "Not allowed") |> halt - - id -> - user = Repo.get_by(Plausible.Auth.User, id: id) - - if user && Plausible.Auth.is_super_admin?(user.id) do - assign(conn, :current_user, user) - else - conn |> send_resp(403, "Not allowed") |> halt - end end end end diff --git a/lib/plausible_web/templates/auth/user_settings.html.heex b/lib/plausible_web/templates/auth/user_settings.html.heex index ac7024be9298..636d75fd67e8 100644 --- a/lib/plausible_web/templates/auth/user_settings.html.heex +++ b/lib/plausible_web/templates/auth/user_settings.html.heex @@ -27,13 +27,13 @@
<% true -> %>
- +
<% end %>
@@ -334,7 +334,7 @@
- <%= if Enum.any?(@user.api_keys) do %> + <%= if Enum.any?(@api_keys) do %>
@@ -357,7 +357,7 @@ - <%= for api_key <- @user.api_keys do %> + <%= for api_key <- @api_keys do %>
<%= api_key.name %> diff --git a/lib/plausible_web/templates/billing/choose_plan.html.heex b/lib/plausible_web/templates/billing/choose_plan.html.heex index a5211afa3d5f..b3042d717199 100644 --- a/lib/plausible_web/templates/billing/choose_plan.html.heex +++ b/lib/plausible_web/templates/billing/choose_plan.html.heex @@ -1,4 +1,4 @@ <%= live_render(@conn, PlausibleWeb.Live.ChoosePlan, id: "choose-plan", - session: %{"current_user_id" => @user.id, "remote_ip" => PlausibleWeb.RemoteIP.get(@conn)} + session: %{"remote_ip" => PlausibleWeb.RemoteIP.get(@conn)} ) %> diff --git a/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex b/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex index a6dfa475929f..0a0555f320a6 100644 --- a/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex +++ b/lib/plausible_web/templates/billing/upgrade_to_enterprise_plan.html.heex @@ -66,7 +66,7 @@ @site.id, - "current_user_id" => @current_user.id, "storage" => on_ee(do: "s3", else: "local") } ) %> diff --git a/lib/plausible_web/user_auth.ex b/lib/plausible_web/user_auth.ex new file mode 100644 index 000000000000..72d46b70340f --- /dev/null +++ b/lib/plausible_web/user_auth.ex @@ -0,0 +1,156 @@ +defmodule PlausibleWeb.UserAuth do + @moduledoc """ + Functions for user session management. + + In it's current shape, both current (legacy) and soon to be implemented (new) + user sessions are supported side by side. + + Once the token-based sessions are implemented, `create_user_session/1` will + start returning new token instead of the legacy one. At the same time, + `put_token_in_session/2` will always set the new token. The legacy token will + still be accepted from the session cookie. Once 14 days pass (the current time + window for which session cookie is valid without any activity), the legacy + cookies won't be accepted anymore (token retrieval will most likely be + instrumented to confirm the usage falls in the mentioned time window as + expected) and the logic will be cleaned of branching for legacy session. + """ + + alias Plausible.Auth + alias PlausibleWeb.TwoFactor + + alias PlausibleWeb.Router.Helpers, as: Routes + + @spec log_in_user(Plug.Conn.t(), Auth.User.t(), String.t() | nil) :: Plug.Conn.t() + def log_in_user(conn, user, redirect_path \\ nil) do + login_dest = + redirect_path || Plug.Conn.get_session(conn, :login_dest) || Routes.site_path(conn, :index) + + conn + |> set_user_session(user) + |> set_logged_in_cookie() + |> Phoenix.Controller.redirect(external: login_dest) + 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) + {:error, _} -> :pass + end + + if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do + PlausibleWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> clear_logged_in_cookie() + end + + @spec get_user(Plug.Conn.t() | Auth.UserSession.t() | map()) :: + {:ok, Auth.User.t()} | {:error, :no_valid_token | :session_not_found | :user_not_found} + def get_user(%Auth.UserSession{} = user_session) do + if user = Plausible.Users.with_subscription(user_session.user_id) do + {:ok, user} + else + {:error, :user_not_found} + end + end + + def get_user(conn_or_session) do + with {:ok, user_session} <- get_user_session(conn_or_session) do + get_user(user_session) + end + end + + @spec get_user_session(Plug.Conn.t() | map()) :: + {:ok, map()} | {:error, :no_valid_token | :session_not_found} + 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 + + @doc """ + Sets the `logged_in` cookie share with the static site for determining + whether client is authenticated. + + As it's a separate cookie, there's a chance it might fall out of sync + with session cookie state due to manual deletion or premature expiration. + """ + @spec set_logged_in_cookie(Plug.Conn.t()) :: Plug.Conn.t() + def set_logged_in_cookie(conn) do + Plug.Conn.put_resp_cookie(conn, "logged_in", "true", + http_only: false, + max_age: 60 * 60 * 24 * 365 * 5000 + ) + end + + defp get_session_by_token({:legacy, user_id}) do + {:ok, %Auth.UserSession{user_id: user_id}} + end + + defp get_session_by_token({:new, _token}) do + {:error, :session_not_found} + end + + defp set_user_session(conn, user) do + {token, _} = create_user_session(user) + + conn + |> renew_session() + |> TwoFactor.Session.clear_2fa_user() + |> put_token_in_session(token) + end + + defp renew_session(conn) do + Phoenix.Controller.delete_csrf_token() + + conn + |> Plug.Conn.configure_session(renew: true) + |> Plug.Conn.clear_session() + end + + defp clear_logged_in_cookie(conn) do + Plug.Conn.delete_resp_cookie(conn, "logged_in") + end + + defp put_token_in_session(conn, {:new, token}) do + conn + |> Plug.Conn.put_session(:user_token, token) + |> Plug.Conn.put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp put_token_in_session(conn, {:legacy, user_id}) do + Plug.Conn.put_session(conn, :current_user_id, user_id) + end + + defp get_user_token(%Plug.Conn{} = conn) do + conn + |> Plug.Conn.get_session() + |> get_user_token() + end + + defp get_user_token(session) do + case Enum.map(["user_token", "current_user_id"], &Map.get(session, &1)) do + [token, nil] when is_binary(token) -> {:ok, {:new, token}} + [nil, current_user_id] when is_integer(current_user_id) -> {:ok, {:legacy, current_user_id}} + [nil, nil] -> {:error, :no_valid_token} + end + end + + defp create_user_session(user) do + # NOTE: a temporary fix for for dialyzer + # complaining about unreachable code + # path. + if :erlang.phash2(1, 1) == 0 do + {{:legacy, user.id}, %{}} + else + {{:new, "disabled-for-now"}, %{}} + end + end + + defp remove_user_session(_token) do + :ok + end +end diff --git a/test/plausible_web/controllers/auth_controller_test.exs b/test/plausible_web/controllers/auth_controller_test.exs index 9bf1ae1a08e4..8d99cf2119c0 100644 --- a/test/plausible_web/controllers/auth_controller_test.exs +++ b/test/plausible_web/controllers/auth_controller_test.exs @@ -503,12 +503,42 @@ defmodule PlausibleWeb.AuthControllerTest do assert location = "/login" = redirected_to(conn, 302) + # cookie state is as expected for logged out user + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + assert get_session(conn, :current_user_id) == nil + {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) conn = get(conn, location) assert html_response(conn, 200) =~ "Password updated successfully" end end + describe "GET /logout" do + setup [:create_user, :log_in] + + test "redirects the user to root", %{conn: conn} do + conn = get(conn, "/logout") + + assert location = "/" = redirected_to(conn, 302) + + # cookie state is as expected for logged out user + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + assert get_session(conn, :current_user_id) == nil + + {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) + conn = get(conn, location) + assert html_response(conn, 200) =~ "Welcome to Plausible!" + end + + test "redirects user to `redirect` param when provided", %{conn: conn} do + conn = get(conn, "/logout", %{redirect: "/docs"}) + + assert redirected_to(conn, 302) == "/docs" + end + end + describe "GET /settings" do setup [:create_user, :log_in] diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs index 4b208f5a6fb0..f2805d378d86 100644 --- a/test/plausible_web/controllers/site_controller_test.exs +++ b/test/plausible_web/controllers/site_controller_test.exs @@ -953,11 +953,11 @@ defmodule PlausibleWeb.SiteControllerTest do end describe "PUT /:website/settings/features/visibility/:setting" do - def build_conn_with_some_url(context) do - {:ok, Map.put(context, :conn, build_conn(:get, "/some_parent_path"))} + def query_conn_with_some_url(context) do + {:ok, Map.put(context, :conn, get(context.conn, "/some_parent_path"))} end - setup [:build_conn_with_some_url, :create_user, :log_in] + setup [:create_user, :log_in, :query_conn_with_some_url] for {title, setting} <- %{ "Goals" => :conversions_enabled, diff --git a/test/plausible_web/plugs/session_timeout_plug_test.exs b/test/plausible_web/plugs/session_timeout_plug_test.exs index 7859a9021e8b..adb3baf59a4f 100644 --- a/test/plausible_web/plugs/session_timeout_plug_test.exs +++ b/test/plausible_web/plugs/session_timeout_plug_test.exs @@ -30,6 +30,6 @@ defmodule PlausibleWeb.SessionTimeoutPlugTest do |> init_test_session(%{current_user_id: 1, session_timeout_at: 1}) |> SessionTimeoutPlug.call(@opts) - assert conn.private[:plug_session_info] == :drop + assert conn.private[:plug_session_info] == :renew end end diff --git a/test/plausible_web/user_auth_test.exs b/test/plausible_web/user_auth_test.exs new file mode 100644 index 000000000000..11620f3388ac --- /dev/null +++ b/test/plausible_web/user_auth_test.exs @@ -0,0 +1,153 @@ +defmodule PlausibleWeb.UserAuthTest do + use PlausibleWeb.ConnCase, async: true + + alias PlausibleWeb.UserAuth + + alias PlausibleWeb.Router.Helpers, as: Routes + + describe "log_in_user/2,3" do + setup [:create_user] + + test "sets up user session and redirects to sites list", %{conn: conn, user: user} do + conn = + conn + |> init_session() + |> UserAuth.log_in_user(user) + + assert redirected_to(conn, 302) == Routes.site_path(conn, :index) + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age > 0 + assert get_session(conn, :current_user_id) == user.id + assert get_session(conn, :login_dest) == nil + end + + test "redirects to `login_dest` if present", %{conn: conn, user: user} do + conn = + conn + |> init_session() + |> put_session("login_dest", "/next") + |> UserAuth.log_in_user(user) + + assert redirected_to(conn, 302) == "/next" + end + + test "redirects to `redirect_path` if present", %{conn: conn, user: user} do + conn = + conn + |> init_session() + |> UserAuth.log_in_user(user, "/next") + + assert redirected_to(conn, 302) == "/next" + end + + test "redirect_path` has precednce over `login_dest`", %{conn: conn, user: user} do + conn = + conn + |> init_session() + |> put_session("login_dest", "/ignored") + |> UserAuth.log_in_user(user, "/next") + + assert redirected_to(conn, 302) == "/next" + end + end + + describe "log_out_user/1" do + setup [:create_user, :log_in] + + test "logs user out", %{conn: conn} do + conn = + conn + |> init_session() + |> put_session("login_dest", "/ignored") + |> UserAuth.log_out_user() + + assert conn.private[:plug_session_info] == :renew + assert conn.resp_cookies["logged_in"].max_age == 0 + assert get_session(conn, :current_user_id) == nil + assert get_session(conn, :login_dest) == nil + end + end + + describe "get_user/1" do + setup [:create_user, :log_in] + + test "gets user from session data in conn", %{conn: conn, user: user} do + assert {:ok, session_user} = UserAuth.get_user(conn) + assert session_user.id == user.id + end + + test "gets user from session data map", %{user: user} do + assert {:ok, session_user} = UserAuth.get_user(%{"current_user_id" => user.id}) + assert session_user.id == user.id + end + + test "gets user from session schema", %{user: user} do + assert {:ok, session_user} = + UserAuth.get_user(%Plausible.Auth.UserSession{user_id: user.id}) + + assert session_user.id == user.id + end + + test "returns error on invalid or missing session data" do + conn = init_session(build_conn()) + assert {:error, :no_valid_token} = UserAuth.get_user(conn) + assert {:error, :no_valid_token} = UserAuth.get_user(%{}) + end + + test "returns error on missing user", %{conn: conn, user: user} do + Plausible.Repo.delete!(user) + + assert {:error, :user_not_found} = UserAuth.get_user(conn) + assert {:error, :user_not_found} = UserAuth.get_user(%{"current_user_id" => user.id}) + + assert {:error, :user_not_found} = + UserAuth.get_user(%Plausible.Auth.UserSession{user_id: user.id}) + end + + test "returns error on missing session (new token scaffold; to be revised)" do + conn = build_conn() |> init_session() |> put_session(:user_token, "does_not_exist") + + assert {:error, :session_not_found} = UserAuth.get_user(conn) + assert {:error, :session_not_found} = UserAuth.get_user(%{"user_token" => "does_not_exist"}) + end + end + + describe "get_user_session/1" do + setup [:create_user, :log_in] + + test "gets session from session data in conn", %{conn: conn, user: user} do + assert {:ok, user_session} = UserAuth.get_user_session(conn) + assert user_session.user_id == user.id + end + + test "gets session from session data map", %{user: user} do + assert {:ok, user_session} = UserAuth.get_user_session(%{"current_user_id" => user.id}) + assert user_session.user_id == user.id + end + + test "returns error on invalid or missing session data" do + conn = init_session(build_conn()) + assert {:error, :no_valid_token} = UserAuth.get_user_session(conn) + assert {:error, :no_valid_token} = UserAuth.get_user_session(%{}) + end + + test "returns error on missing session (new token scaffold; to be revised)" do + conn = build_conn() |> init_session() |> put_session(:user_token, "does_not_exist") + + assert {:error, :session_not_found} = UserAuth.get_user_session(conn) + + assert {:error, :session_not_found} = + UserAuth.get_user_session(%{"user_token" => "does_not_exist"}) + end + end + + describe "set_logged_in_cookie/1" do + test "sets logged_in_cookie", %{conn: conn} do + conn = UserAuth.set_logged_in_cookie(conn) + + assert cookie = conn.resp_cookies["logged_in"] + assert cookie.max_age > 0 + assert cookie.value == "true" + end + end +end diff --git a/test/support/test_utils.ex b/test/support/test_utils.ex index 4f3da9d64707..780d84f99911 100644 --- a/test/support/test_utils.ex +++ b/test/support/test_utils.ex @@ -100,7 +100,11 @@ defmodule Plausible.TestUtils do def log_in(%{user: user, conn: conn}) do conn = - init_session(conn) + conn + |> PlausibleWeb.UserAuth.set_logged_in_cookie() + |> Phoenix.ConnTest.recycle() + |> Map.put(:secret_key_base, secret_key_base()) + |> init_session() |> Plug.Conn.put_session(:current_user_id, user.id) {:ok, conn: conn} @@ -288,4 +292,10 @@ defmodule Plausible.TestUtils do :ok end end + + defp secret_key_base() do + :plausible + |> Application.fetch_env!(PlausibleWeb.Endpoint) + |> Keyword.fetch!(:secret_key_base) + end end