| 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 |
|
|