defmodule Plausible.Auth do @moduledoc """ Functions for user authentication context. """ use Plausible use Plausible.Repo import Ecto.Query alias Plausible.Auth alias Plausible.Billing alias Plausible.RateLimit alias Plausible.Teams require Logger case Mix.env() do :e2e_test -> @ip_rate_limit 100_000 @user_rate_limit 100_000 @activation_limit 100_000 @activation_ip_limit 100_000 @activation_request_limit 100_000 @totp_setup_limit 100_000 @totp_setup_ip_limit 100_000 env when env in [:test, :ce_test] -> @ip_rate_limit 5 @user_rate_limit 5 @activation_limit 10 @totp_setup_limit 10 @activation_ip_limit 100_000 @totp_setup_ip_limit 100_000 @activation_request_limit 100_000 _ -> @ip_rate_limit 5 @user_rate_limit 5 @activation_limit 10 @totp_setup_limit 10 @activation_ip_limit 2 @totp_setup_ip_limit 2 @activation_request_limit 5 end @rate_limits %{ login_ip: %{ prefix: "login:ip", limit: @ip_rate_limit, interval: :timer.seconds(60) }, login_user: %{ prefix: "login:user", limit: @user_rate_limit, interval: :timer.seconds(60) }, email_change_user: %{ prefix: "email-change:user", limit: 2, interval: :timer.hours(1) }, password_change_user: %{ prefix: "password-change:user", limit: 5, interval: :timer.minutes(20) }, activation_ip: %{ prefix: "activation:ip", limit: @activation_ip_limit, interval: :timer.minutes(1) }, activation_user: %{ prefix: "activation:user", limit: @activation_limit, interval: :timer.minutes(5) }, activation_request_ip: %{ prefix: "activation-request:ip", limit: @activation_request_limit, interval: :timer.minutes(1) }, activation_request_user: %{ prefix: "activation-request:user", limit: @activation_request_limit, interval: :timer.minutes(10) }, totp_setup_ip: %{ prefix: "totp-setup:ip", limit: @totp_setup_ip_limit, interval: :timer.minutes(1) }, totp_setup_user: %{ prefix: "totp-setup:user", limit: @totp_setup_limit, interval: :timer.minutes(5) } } @rate_limit_types Map.keys(@rate_limits) @type rate_limit_type() :: unquote(Enum.reduce(@rate_limit_types, &{:|, [], [&1, &2]})) @spec rate_limit(rate_limit_type(), Auth.User.t() | Plug.Conn.t()) :: :ok | {:error, {:rate_limit, rate_limit_type()}} def rate_limit(limit_type, key) when limit_type in @rate_limit_types do %{prefix: prefix, limit: limit, interval: interval} = @rate_limits[limit_type] full_key = "#{prefix}:#{rate_limit_key(key)}" case RateLimit.check_rate(full_key, interval, limit) do {:allow, _} -> :ok {:deny, _} -> {:error, {:rate_limit, limit_type}} end end @spec log_failed_login_attempt(String.t()) :: :ok def log_failed_login_attempt(message) do if Application.get_env(:plausible, :log_failed_login_attempts) do Logger.warning("[login] #{message}") end :ok end @spec find_user_by(Keyword.t()) :: Auth.User.t() | nil def find_user_by(opts) do Repo.get_by(Auth.User, opts) end @spec lookup(String.t()) :: {:ok, Auth.User.t()} | {:error, :user_not_found} def lookup(email) do query = on_ee do from( u in Auth.User, left_join: tm in assoc(u, :team_memberships), on: u.type == :sso and tm.role == :owner, left_join: t in assoc(tm, :team), where: u.email == ^email, where: u.type == :standard or (u.type == :sso and t.setup_complete == true) ) else from(u in Auth.User, where: u.email == ^email) end case Repo.one(query) do %Auth.User{} = user -> {:ok, user} nil -> {:error, :user_not_found} end end @spec check_password(Auth.User.t(), String.t()) :: :ok | {:error, :wrong_password} def check_password(user, password) do if Plausible.Auth.Password.match?(password, user.password_hash || "") do :ok else {:error, :wrong_password} end end @spec delete_user(Auth.User.t()) :: {:ok, :deleted} | {:error, :is_only_team_owner | :active_subscription} def delete_user(user) do case Teams.get_by_owner(user) do {:ok, %{setup_complete: false} = team} -> delete_team_and_user(team, user) {:ok, team} -> with :ok <- check_can_leave_team(team) do delete_user!(user) {:ok, :deleted} end {:error, :multiple_teams} -> teams = Teams.Users.owned_teams(user) with :ok <- check_can_leave_teams(teams) do personal_team = Enum.find(teams, &(not Teams.setup?(&1))) delete_team_and_user(personal_team, user) end {:error, :no_team} -> delete_user!(user) {:ok, :deleted} end end defp delete_team_and_user(nil, user) do delete_user!(user) {:ok, :deleted} end defp delete_team_and_user(team, user) do Repo.transaction(fn -> case Teams.delete(team) do {:ok, :deleted} -> delete_user!(user) :deleted {:error, error} -> Repo.rollback(error) end end) end defp delete_user!(user) do Repo.transaction(fn -> Plausible.Segments.user_removed(user) Repo.delete!(user) end) :ok end defp check_can_leave_teams(teams) do teams |> Enum.filter(& &1.setup_complete) |> Enum.reduce_while(:ok, fn team, :ok -> case check_can_leave_team(team) do :ok -> {:cont, :ok} {:error, error} -> {:halt, {:error, error}} end end) end defp check_can_leave_team(team) do if Teams.Memberships.owners_count(team) > 1 do :ok else {:error, :is_only_team_owner} end end on_ee do def super_admin?(nil), do: false def super_admin?(%Plausible.Auth.User{id: id}), do: super_admin?(id) def super_admin?(user_id) when is_integer(user_id) do user_id in Application.get_env(:plausible, :super_admin_user_ids) end else def super_admin?(_), do: always(false) end @spec list_api_keys(Auth.User.t(), Teams.Team.t() | nil) :: [Auth.ApiKey.t()] def list_api_keys(user, team) do query = from(a in Auth.ApiKey, where: a.user_id == ^user.id, order_by: [desc: a.id], select: %{ a | type: fragment( "CASE WHEN ? = ANY(?) THEN ? ELSE ? END", "sites:provision:*", a.scopes, "sites_api", "stats_api" ) } ) |> scope_api_keys_by_team(team) Repo.all(query) end @spec create_stats_api_key(Auth.User.t(), Teams.Team.t(), String.t(), String.t()) :: {:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required} def create_stats_api_key(user, team, name, key) do params = %{name: name, user_id: user.id, key: key} changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, team, params) with :ok <- Billing.Feature.StatsAPI.check_availability(team) do Repo.insert(changeset) end end @spec create_sites_api_key(Auth.User.t(), Teams.Team.t(), String.t(), String.t()) :: {:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t() | :upgrade_required} def create_sites_api_key(user, team, name, key) do params = %{name: name, user_id: user.id, key: key, scopes: ["sites:provision:*"]} changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, team, params) with :ok <- Billing.Feature.StatsAPI.check_availability(team), :ok <- Billing.Feature.SitesAPI.check_availability(team) do Repo.insert(changeset) end end @spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found} def delete_api_key(user, id) do query = from(api_key in Auth.ApiKey, where: api_key.id == ^id and api_key.user_id == ^user.id) case Repo.delete_all(query) do {1, _} -> :ok {0, _} -> {:error, :not_found} end end @spec find_api_key(String.t()) :: {:ok, %{api_key: Auth.ApiKey.t(), team: Teams.Team.t() | nil}} | {:error, :invalid_api_key} def find_api_key(raw_key) do find_api_key(raw_key, nil, nil) end @spec find_api_key_for_team_of_site(String.t(), String.t() | nil) :: {:ok, %{api_key: Auth.ApiKey.t(), team: Teams.Team.t() | nil}} | {:error, :invalid_api_key | :missing_site_id} def find_api_key_for_team_of_site(_raw_key, site_domain) when site_domain in [nil, ""] do {:error, :missing_site_id} end def find_api_key_for_team_of_site(raw_key, site_domain) do find_api_key(raw_key, :site, site_domain) end defp scope_api_keys_by_team(query, nil) do query end defp scope_api_keys_by_team(query, team) do where(query, [a], is_nil(a.team_id) or a.team_id == ^team.id) end defp find_api_key(raw_key, nil, _) do hashed_key = Auth.ApiKey.do_hash(raw_key) query = from(api_key in Auth.ApiKey, join: user in assoc(api_key, :user), left_join: team in assoc(api_key, :team), where: api_key.key_hash == ^hashed_key, preload: [user: user, team: team] ) case Repo.one(query) do nil -> {:error, :invalid_api_key} %{team: %{} = team} = api_key -> {:ok, %{api_key: api_key, team: team}} api_key -> {:ok, %{api_key: api_key, team: nil}} end end defp find_api_key(raw_key, :site, nil) do find_api_key(raw_key, nil, nil) end defp find_api_key(raw_key, :site, domain) do case find_api_key(raw_key, nil, nil) do {:ok, %{api_key: api_key, team: nil}} -> team = find_team_by_site(domain) {:ok, %{api_key: api_key, team: team}} {:ok, %{api_key: api_key, team: team}} -> {:ok, %{api_key: api_key, team: team}} {:error, _} = error -> error end end defp find_team_by_site(domain) do Repo.one( from s in Plausible.Site.regular(), inner_join: t in assoc(s, :team), where: s.domain == ^domain or s.domain_changed_from == ^domain, select: t ) end defp rate_limit_key(%Auth.User{id: id}), do: id defp rate_limit_key(%Plug.Conn{} = conn), do: PlausibleWeb.RemoteIP.get(conn) end