analytics/lib/plausible/auth/auth.ex

312 lines
8.3 KiB
Elixir

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
@rate_limits %{
login_ip: %{
prefix: "login:ip",
limit: 5,
interval: :timer.seconds(60)
},
login_user: %{
prefix: "login:user",
limit: 5,
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)
}
}
@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 is_super_admin?(nil), do: false
def is_super_admin?(%Plausible.Auth.User{id: id}), do: is_super_admin?(id)
def is_super_admin?(user_id) when is_integer(user_id) do
user_id in Application.get_env(:plausible, :super_admin_user_ids)
end
else
def is_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(), Keyword.t()) ::
{:ok, %{api_key: Auth.ApiKey.t(), team: Teams.Team.t() | nil}}
| {:error, :invalid_api_key | :missing_site_id}
def find_api_key(raw_key, opts \\ []) do
{team_scope, id} = Keyword.get(opts, :team_by, {nil, nil})
find_api_key(raw_key, team_scope, id)
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