1242 lines
36 KiB
Elixir
1242 lines
36 KiB
Elixir
defmodule WhoNeedHelp.Accounts do
|
|
@moduledoc """
|
|
The Accounts context.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
alias WhoNeedHelp.Pagination
|
|
alias WhoNeedHelp.Repo
|
|
|
|
alias WhoNeedHelp.Accounts.{
|
|
AuthIdentity,
|
|
Scope,
|
|
SocialIdentity,
|
|
StaffPermissions,
|
|
StaffRoleAssignment,
|
|
User,
|
|
UserNotifier,
|
|
UserToken
|
|
}
|
|
|
|
## Database getters
|
|
|
|
@public_user_fields [:id, :display_name, :bio, :tip_url, :inserted_at]
|
|
@public_social_identity_fields [
|
|
:id,
|
|
:provider,
|
|
:profile_url,
|
|
:handle,
|
|
:verified_at,
|
|
:user_id,
|
|
:inserted_at
|
|
]
|
|
@registration_key_atoms %{
|
|
"display_name" => :display_name,
|
|
"email" => :email,
|
|
"email_verified" => :email_verified,
|
|
"hosted_domain" => :hosted_domain,
|
|
"locale" => :locale,
|
|
"provider_uid" => :provider_uid,
|
|
"terms_accepted" => :terms_accepted
|
|
}
|
|
|
|
@doc """
|
|
Returns the deliberately small user projection used by public and
|
|
participant-facing views.
|
|
|
|
Authentication, account settings, and moderation queries must continue to
|
|
load the full schema explicitly. In particular, this projection never loads
|
|
email, password hashes, roles, or moderation notes.
|
|
"""
|
|
def public_user_query(options \\ []) do
|
|
query =
|
|
from user in User,
|
|
select: struct(user, ^@public_user_fields)
|
|
|
|
if Keyword.get(options, :social_identities, false) do
|
|
public_social_identity = public_social_identity_query()
|
|
preload(query, social_identities: ^public_social_identity)
|
|
else
|
|
query
|
|
end
|
|
end
|
|
|
|
def public_social_identity_query do
|
|
from identity in SocialIdentity,
|
|
select: struct(identity, ^@public_social_identity_fields)
|
|
end
|
|
|
|
@doc """
|
|
Loads the public profile projection for an account that has not been suspended.
|
|
|
|
The returned struct deliberately contains only `public_user_query/1` fields and
|
|
public social identities. It never loads email, authentication, role, or
|
|
moderation-note fields.
|
|
"""
|
|
def get_public_user(id) do
|
|
with {:ok, id} <- cast_id(id),
|
|
%User{} = user <-
|
|
public_user_query(social_identities: true)
|
|
|> where([user], user.id == ^id and user.moderation_status != :suspended)
|
|
|> Repo.one() do
|
|
{:ok, user}
|
|
else
|
|
_invalid_missing_or_suspended -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets a user by email.
|
|
|
|
## Examples
|
|
|
|
iex> get_user_by_email("foo@example.com")
|
|
%User{}
|
|
|
|
iex> get_user_by_email("unknown@example.com")
|
|
nil
|
|
|
|
"""
|
|
def get_user_by_email(email) when is_binary(email) do
|
|
Repo.get_by(User, email: String.trim(email))
|
|
end
|
|
|
|
@doc """
|
|
Gets a user by email and password.
|
|
|
|
## Examples
|
|
|
|
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
|
%User{}
|
|
|
|
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
|
nil
|
|
|
|
"""
|
|
def get_user_by_email_and_password(email, password)
|
|
when is_binary(email) and is_binary(password) do
|
|
user = get_user_by_email(email)
|
|
|
|
if User.valid_password?(user, password) and user.moderation_status != :suspended,
|
|
do: user
|
|
end
|
|
|
|
@doc """
|
|
Gets a single user.
|
|
|
|
Raises `Ecto.NoResultsError` if the User does not exist.
|
|
|
|
## Examples
|
|
|
|
iex> get_user!(123)
|
|
%User{}
|
|
|
|
iex> get_user!(456)
|
|
** (Ecto.NoResultsError)
|
|
|
|
"""
|
|
def get_user!(id), do: Repo.get!(User, id)
|
|
|
|
def eligible_for_trust_actions?(%User{
|
|
confirmed_at: confirmed_at,
|
|
accepted_terms_at: accepted_terms_at,
|
|
moderation_status: :active
|
|
}) do
|
|
not is_nil(confirmed_at) and not is_nil(accepted_terms_at)
|
|
end
|
|
|
|
def eligible_for_trust_actions?(_user), do: false
|
|
|
|
def eligible_user_id?(user_id) do
|
|
Repo.exists?(
|
|
from user in User,
|
|
where:
|
|
user.id == ^user_id and user.moderation_status == :active and
|
|
not is_nil(user.confirmed_at) and not is_nil(user.accepted_terms_at)
|
|
)
|
|
end
|
|
|
|
def staff_roles(%User{id: id}) do
|
|
StaffRoleAssignment
|
|
|> where([assignment], assignment.user_id == ^id)
|
|
|> order_by([assignment], asc: assignment.role)
|
|
|> select([assignment], assignment.role)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def loaded_staff_roles(%User{staff_role_assignments: %Ecto.Association.NotLoaded{}}), do: []
|
|
|
|
def loaded_staff_roles(%User{staff_role_assignments: assignments}) when is_list(assignments),
|
|
do: Enum.map(assignments, & &1.role)
|
|
|
|
def loaded_staff_roles(_user), do: []
|
|
|
|
def permission?(%User{moderation_status: :active} = user, permission) do
|
|
StaffPermissions.allowed?(loaded_staff_roles(user), permission)
|
|
end
|
|
|
|
def permission?(_user, _permission), do: false
|
|
|
|
def authorized?(%User{id: id}, permission) do
|
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
|
|
|
allowed_roles != [] and
|
|
Repo.exists?(
|
|
from assignment in StaffRoleAssignment,
|
|
join: user in User,
|
|
on: user.id == assignment.user_id,
|
|
where:
|
|
assignment.user_id == ^id and assignment.role in ^allowed_roles and
|
|
user.moderation_status == :active
|
|
)
|
|
end
|
|
|
|
def authorized?(_user, _permission), do: false
|
|
|
|
def authorized_user_id?(user_id, permission) do
|
|
with {:ok, user_id} <- cast_id(user_id) do
|
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
|
|
|
allowed_roles != [] and
|
|
Repo.exists?(
|
|
from assignment in StaffRoleAssignment,
|
|
join: user in User,
|
|
on: user.id == assignment.user_id,
|
|
where:
|
|
assignment.user_id == ^user_id and assignment.role in ^allowed_roles and
|
|
user.moderation_status == :active
|
|
)
|
|
else
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
def staff?(user), do: permission?(user, :staff_access)
|
|
def staff_authorized?(user), do: authorized?(user, :staff_access)
|
|
def moderator?(user), do: permission?(user, :moderation_view)
|
|
def moderator_authorized?(user), do: authorized?(user, :moderation_view)
|
|
def admin?(user), do: :admin in loaded_staff_roles(user)
|
|
def admin_authorized?(user), do: authorized?(user, :staff_manage)
|
|
|
|
def list_users_for_moderation(%Scope{user: user}) do
|
|
paginate_users_for_moderation(%Scope{user: user}).entries
|
|
end
|
|
|
|
def paginate_users_for_moderation(%Scope{user: user}, options \\ []) do
|
|
if authorized?(user, :users_view) do
|
|
limit = Pagination.limit(options)
|
|
cursor = Pagination.cursor(options)
|
|
|
|
User
|
|
|> maybe_user_status(Keyword.get(options, :status))
|
|
|> maybe_user_role(Keyword.get(options, :role))
|
|
|> maybe_user_search(Keyword.get(options, :search))
|
|
|> before_moderation_user(cursor)
|
|
|> order_by([user], desc: user.inserted_at, desc: user.id)
|
|
|> limit(^(limit + 1))
|
|
|> preload(:staff_role_assignments)
|
|
|> Repo.all()
|
|
|> Pagination.page(limit, &{&1.inserted_at, &1.id})
|
|
else
|
|
%Pagination.Page{}
|
|
end
|
|
end
|
|
|
|
def get_user_for_moderation(%Scope{user: actor}, user_id) do
|
|
with true <- authorized?(actor, :users_view),
|
|
{:ok, user_id} <- cast_id(user_id) do
|
|
User
|
|
|> where([user], user.id == ^user_id)
|
|
|> preload(:staff_role_assignments)
|
|
|> Repo.one()
|
|
else
|
|
_unauthorized_or_invalid -> nil
|
|
end
|
|
end
|
|
|
|
def moderate_user(%User{} = moderator, user_id, attrs) do
|
|
with {:ok, user_id} <- cast_id(user_id),
|
|
true <- authorized?(moderator, :users_moderate) do
|
|
Repo.transact(fn ->
|
|
active_admins = lock_active_admins()
|
|
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
|
|
|
if user do
|
|
requested_status = attrs["moderation_status"] || attrs[:moderation_status]
|
|
|
|
cond do
|
|
user_has_staff_roles?(user.id) and not admin_authorized?(moderator) ->
|
|
{:error, :forbidden}
|
|
|
|
user.id == moderator.id and
|
|
requested_status in [:restricted, :suspended, "restricted", "suspended"] ->
|
|
{:error, :cannot_restrict_self}
|
|
|
|
user_has_role?(user.id, :admin) and user.moderation_status == :active and
|
|
requested_status not in [:active, "active"] and length(active_admins) == 1 ->
|
|
{:error, :last_admin}
|
|
|
|
true ->
|
|
with {:ok, user} <- user |> User.moderation_changeset(attrs) |> Repo.update() do
|
|
session_tokens =
|
|
if user.moderation_status == :suspended do
|
|
tokens =
|
|
UserToken
|
|
|> where([token], token.user_id == ^user.id and token.context == "session")
|
|
|> select([token], token.token)
|
|
|> Repo.all()
|
|
|
|
Repo.delete_all(from token in UserToken, where: token.user_id == ^user.id)
|
|
tokens
|
|
else
|
|
[]
|
|
end
|
|
|
|
{:ok, %{user: user, expired_session_tokens: session_tokens}}
|
|
end
|
|
end
|
|
else
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
else
|
|
false -> {:error, :forbidden}
|
|
{:error, :not_found} = error -> error
|
|
end
|
|
end
|
|
|
|
def set_staff_roles(%User{} = admin, user_id, roles) when is_list(roles) do
|
|
with {:ok, user_id} <- cast_id(user_id),
|
|
true <- admin_authorized?(admin),
|
|
true <- sudo_mode?(admin, -10),
|
|
{:ok, roles} <- normalize_staff_roles(roles) do
|
|
Repo.transact(fn ->
|
|
active_admins = lock_active_admins()
|
|
user = User |> where([user], user.id == ^user_id) |> lock("FOR UPDATE") |> Repo.one()
|
|
|
|
if user do
|
|
current_roles = staff_roles(user)
|
|
|
|
if :admin in current_roles and user.moderation_status == :active and
|
|
:admin not in roles and length(active_admins) == 1 do
|
|
{:error, :last_admin}
|
|
else
|
|
if roles == [] do
|
|
Repo.delete_all(
|
|
from assignment in StaffRoleAssignment,
|
|
where: assignment.user_id == ^user.id
|
|
)
|
|
else
|
|
StaffRoleAssignment
|
|
|> where(
|
|
[assignment],
|
|
assignment.user_id == ^user.id and assignment.role not in ^roles
|
|
)
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
roles
|
|
|> Enum.reject(&(&1 in current_roles))
|
|
|> Enum.each(fn role ->
|
|
%StaffRoleAssignment{}
|
|
|> StaffRoleAssignment.changeset(%{
|
|
user_id: user.id,
|
|
role: role,
|
|
assigned_by_id: admin.id
|
|
})
|
|
|> Repo.insert!()
|
|
end)
|
|
|
|
session_tokens =
|
|
UserToken
|
|
|> where([token], token.user_id == ^user.id and token.context == "session")
|
|
|> Repo.all()
|
|
|
|
{:ok, %{user: preload_staff_roles(user), session_tokens: session_tokens}}
|
|
end
|
|
else
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
else
|
|
false -> {:error, :forbidden}
|
|
{:error, :not_found} = error -> error
|
|
{:error, :invalid_roles} = error -> error
|
|
end
|
|
end
|
|
|
|
def set_staff_roles(_admin, _user_id, _roles), do: {:error, :invalid_roles}
|
|
|
|
defp lock_active_admins do
|
|
active_user_ids =
|
|
from user in User,
|
|
where: user.moderation_status == :active,
|
|
select: user.id
|
|
|
|
StaffRoleAssignment
|
|
|> where(
|
|
[assignment],
|
|
assignment.role == :admin and assignment.user_id in subquery(active_user_ids)
|
|
)
|
|
|> order_by([assignment], asc: assignment.user_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.all()
|
|
end
|
|
|
|
def list_staff_for_permission(permission) do
|
|
allowed_roles = StaffPermissions.roles_for_permission(permission)
|
|
|
|
User
|
|
|> join(:inner, [user], assignment in StaffRoleAssignment,
|
|
on: assignment.user_id == user.id and assignment.role in ^allowed_roles
|
|
)
|
|
|> where([user], user.moderation_status == :active)
|
|
|> order_by([user], asc: user.display_name, asc: user.email)
|
|
|> distinct(true)
|
|
|> preload(:staff_role_assignments)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def preload_staff_roles(%User{} = user),
|
|
do: Repo.preload(user, :staff_role_assignments, force: true)
|
|
|
|
defp user_has_staff_roles?(user_id) do
|
|
Repo.exists?(from assignment in StaffRoleAssignment, where: assignment.user_id == ^user_id)
|
|
end
|
|
|
|
defp user_has_role?(user_id, role) do
|
|
Repo.exists?(
|
|
from assignment in StaffRoleAssignment,
|
|
where: assignment.user_id == ^user_id and assignment.role == ^role
|
|
)
|
|
end
|
|
|
|
defp normalize_staff_roles(roles) do
|
|
roles =
|
|
roles
|
|
|> Enum.map(fn
|
|
role when is_atom(role) ->
|
|
role
|
|
|
|
role when is_binary(role) ->
|
|
Enum.find(StaffRoleAssignment.roles(), &(Atom.to_string(&1) == role))
|
|
|
|
_other ->
|
|
nil
|
|
end)
|
|
|> Enum.uniq()
|
|
|
|
if Enum.all?(roles, &StaffPermissions.valid_role?/1),
|
|
do: {:ok, Enum.sort(roles)},
|
|
else: {:error, :invalid_roles}
|
|
end
|
|
|
|
## User registration
|
|
|
|
@doc """
|
|
Registers a user.
|
|
|
|
## Examples
|
|
|
|
iex> register_user(%{field: value})
|
|
{:ok, %User{}}
|
|
|
|
iex> register_user(%{field: bad_value})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
def register_user(attrs) do
|
|
%User{}
|
|
|> User.registration_changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
def change_user_registration(%User{} = user, attrs \\ %{}, opts \\ []) do
|
|
User.registration_changeset(user, attrs, opts)
|
|
end
|
|
|
|
def change_user_profile(%User{} = user, attrs \\ %{}) do
|
|
User.profile_changeset(user, attrs)
|
|
end
|
|
|
|
def update_user_profile(%User{} = user, attrs) do
|
|
user
|
|
|> User.profile_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
def list_social_identities(%User{id: user_id}) do
|
|
SocialIdentity
|
|
|> where([identity], identity.user_id == ^user_id)
|
|
|> order_by([identity], asc: identity.provider, asc: identity.inserted_at)
|
|
|> Repo.all()
|
|
end
|
|
|
|
def change_social_identity(%SocialIdentity{} = identity, attrs \\ %{}) do
|
|
SocialIdentity.changeset(identity, attrs)
|
|
end
|
|
|
|
def add_social_identity(%User{id: user_id}, attrs) do
|
|
%SocialIdentity{user_id: user_id}
|
|
|> SocialIdentity.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
def upsert_verified_social_identity(
|
|
%User{id: user_id},
|
|
%{provider: provider, provider_uid: provider_uid} = attrs
|
|
) do
|
|
Repo.transact(fn ->
|
|
existing =
|
|
SocialIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == ^provider and identity.provider_uid == ^provider_uid
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
case existing do
|
|
%SocialIdentity{user_id: ^user_id} = identity ->
|
|
identity
|
|
|> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
|
|
|> Repo.update()
|
|
|
|
%SocialIdentity{} ->
|
|
{:error, :already_linked}
|
|
|
|
nil ->
|
|
%SocialIdentity{}
|
|
|> SocialIdentity.verified_changeset(Map.put(attrs, :user_id, user_id))
|
|
|> Repo.insert()
|
|
end
|
|
end)
|
|
end
|
|
|
|
def delete_social_identity(%User{id: user_id}, identity_id) do
|
|
with {:ok, identity_id} <- Ecto.UUID.cast(identity_id),
|
|
%SocialIdentity{} = identity <-
|
|
Repo.get_by(SocialIdentity, id: identity_id, user_id: user_id) do
|
|
Repo.delete(identity)
|
|
else
|
|
_invalid_or_missing -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
## Authentication identities
|
|
|
|
def google_auth_connected?(%User{id: user_id}) do
|
|
Repo.exists?(
|
|
from identity in AuthIdentity,
|
|
where: identity.user_id == ^user_id and identity.provider == :google
|
|
)
|
|
end
|
|
|
|
def login_user_by_google(identity_attrs) do
|
|
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
|
Repo.transact(fn ->
|
|
identity =
|
|
AuthIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == :google and
|
|
identity.provider_uid == ^identity_attrs.provider_uid
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
case identity do
|
|
nil ->
|
|
{:error, :not_linked}
|
|
|
|
%AuthIdentity{} = identity ->
|
|
with %User{} = user <-
|
|
User
|
|
|> where([user], user.id == ^identity.user_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one(),
|
|
true <- user.moderation_status != :suspended,
|
|
{:ok, _identity} <-
|
|
identity
|
|
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|
|
|> Repo.update() do
|
|
{:ok, user}
|
|
else
|
|
_missing_or_suspended -> {:error, :not_found}
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
def change_google_registration(identity_attrs, registration_attrs \\ %{})
|
|
when is_map(registration_attrs) do
|
|
case normalize_google_identity(identity_attrs) do
|
|
{:ok, identity_attrs} ->
|
|
%User{}
|
|
|> User.registration_changeset(
|
|
google_registration_attrs(identity_attrs, registration_attrs),
|
|
validate_unique: false
|
|
)
|
|
|
|
{:error, _reason} ->
|
|
User.registration_changeset(%User{}, %{}, validate_unique: false)
|
|
end
|
|
end
|
|
|
|
def register_user_by_google(identity_attrs, registration_attrs)
|
|
when is_map(registration_attrs) do
|
|
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
|
Repo.transact(fn ->
|
|
identity =
|
|
AuthIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == :google and
|
|
identity.provider_uid == ^identity_attrs.provider_uid
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
case identity do
|
|
%AuthIdentity{} = identity ->
|
|
google_identity_user(identity, identity_attrs.email)
|
|
|
|
nil ->
|
|
register_google_identity(identity_attrs, registration_attrs)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
def register_user_by_google(_identity_attrs, _registration_attrs),
|
|
do: {:error, :invalid_registration}
|
|
|
|
def link_google_identity_by_authoritative_email(%User{id: user_id}, identity_attrs) do
|
|
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs),
|
|
true <- WhoNeedHelp.GoogleAuth.authoritative_email?(identity_attrs) do
|
|
Repo.transact(fn ->
|
|
user =
|
|
User
|
|
|> where([user], user.id == ^user_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
cond do
|
|
is_nil(user) or user.moderation_status == :suspended ->
|
|
{:error, :not_found}
|
|
|
|
normalized_email(user.email) != identity_attrs.email ->
|
|
{:error, :email_mismatch}
|
|
|
|
true ->
|
|
with {:ok, user} <- confirm_google_email_owner(user),
|
|
{:ok, identity} <- upsert_google_identity_for_user(user, identity_attrs) do
|
|
{:ok, {user, identity}}
|
|
end
|
|
end
|
|
end)
|
|
else
|
|
false -> {:error, :email_not_authoritative}
|
|
{:error, _reason} = error -> error
|
|
end
|
|
end
|
|
|
|
def link_google_identity(%User{id: user_id}, identity_attrs) do
|
|
with {:ok, identity_attrs} <- normalize_google_identity(identity_attrs) do
|
|
Repo.transact(fn ->
|
|
user =
|
|
User
|
|
|> where([user], user.id == ^user_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
cond do
|
|
is_nil(user) or user.moderation_status == :suspended ->
|
|
{:error, :not_found}
|
|
|
|
true ->
|
|
upsert_google_identity_for_user(user, identity_attrs)
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
def unlink_google_identity(%User{id: user_id}) do
|
|
Repo.transact(fn ->
|
|
identity =
|
|
AuthIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == :google and identity.user_id == ^user_id
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
case identity do
|
|
%AuthIdentity{} = identity -> Repo.delete(identity)
|
|
nil -> {:error, :not_found}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp register_google_identity(identity_attrs, registration_attrs) do
|
|
existing_user =
|
|
User
|
|
|> where([user], user.email == ^identity_attrs.email)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
if existing_user do
|
|
{:error, :email_already_registered}
|
|
else
|
|
attrs = google_registration_attrs(identity_attrs, registration_attrs)
|
|
|
|
with {:ok, user} <- register_user(attrs),
|
|
{:ok, user} <- user |> User.confirm_changeset() |> Repo.update(),
|
|
{:ok, identity} <-
|
|
%AuthIdentity{}
|
|
|> AuthIdentity.changeset(%{
|
|
provider: :google,
|
|
provider_uid: identity_attrs.provider_uid,
|
|
email: identity_attrs.email,
|
|
user_id: user.id
|
|
})
|
|
|> Repo.insert() do
|
|
{:ok, {user, identity}}
|
|
end
|
|
end
|
|
end
|
|
|
|
defp google_identity_user(identity, current_email) do
|
|
user =
|
|
User
|
|
|> where([user], user.id == ^identity.user_id)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
with %User{} = user <- user,
|
|
true <- user.moderation_status != :suspended,
|
|
{:ok, identity} <-
|
|
identity
|
|
|> AuthIdentity.changeset(%{email: current_email})
|
|
|> Repo.update() do
|
|
{:ok, {user, identity}}
|
|
else
|
|
_missing_or_suspended -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp upsert_google_identity_for_user(user, identity_attrs) do
|
|
provider_identity =
|
|
AuthIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == :google and
|
|
identity.provider_uid == ^identity_attrs.provider_uid
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
user_identity =
|
|
AuthIdentity
|
|
|> where(
|
|
[identity],
|
|
identity.provider == :google and identity.user_id == ^user.id
|
|
)
|
|
|> lock("FOR UPDATE")
|
|
|> Repo.one()
|
|
|
|
cond do
|
|
provider_identity && provider_identity.user_id != user.id ->
|
|
{:error, :already_linked}
|
|
|
|
user_identity && user_identity.provider_uid != identity_attrs.provider_uid ->
|
|
{:error, :provider_already_linked}
|
|
|
|
identity = provider_identity || user_identity ->
|
|
identity
|
|
|> AuthIdentity.changeset(%{email: identity_attrs.email})
|
|
|> Repo.update()
|
|
|
|
true ->
|
|
%AuthIdentity{}
|
|
|> AuthIdentity.changeset(%{
|
|
provider: :google,
|
|
provider_uid: identity_attrs.provider_uid,
|
|
email: identity_attrs.email,
|
|
user_id: user.id
|
|
})
|
|
|> Repo.insert()
|
|
end
|
|
end
|
|
|
|
defp confirm_google_email_owner(%User{confirmed_at: nil} = user) do
|
|
user
|
|
|> User.confirm_changeset()
|
|
|> Repo.update()
|
|
end
|
|
|
|
defp confirm_google_email_owner(%User{} = user), do: {:ok, user}
|
|
|
|
defp google_registration_attrs(identity_attrs, registration_attrs) do
|
|
%{
|
|
"email" => identity_attrs.email,
|
|
"display_name" =>
|
|
registration_value(registration_attrs, "display_name", identity_attrs.display_name),
|
|
"locale" => registration_value(registration_attrs, "locale", "en"),
|
|
"terms_accepted" => registration_value(registration_attrs, "terms_accepted", false)
|
|
}
|
|
end
|
|
|
|
defp normalize_google_identity(identity_attrs) when is_map(identity_attrs) do
|
|
provider_uid = registration_value(identity_attrs, "provider_uid", nil)
|
|
email = registration_value(identity_attrs, "email", nil)
|
|
email_verified = registration_value(identity_attrs, "email_verified", false)
|
|
display_name = registration_value(identity_attrs, "display_name", nil)
|
|
hosted_domain = registration_value(identity_attrs, "hosted_domain", nil)
|
|
|
|
cond do
|
|
email_verified != true ->
|
|
{:error, :email_not_verified}
|
|
|
|
not is_binary(provider_uid) or provider_uid == "" or byte_size(provider_uid) > 255 ->
|
|
{:error, :invalid_provider_identity}
|
|
|
|
not is_binary(email) or email == "" or byte_size(email) > 160 ->
|
|
{:error, :invalid_provider_identity}
|
|
|
|
not is_binary(display_name) or String.trim(display_name) == "" ->
|
|
{:error, :invalid_provider_identity}
|
|
|
|
true ->
|
|
{:ok,
|
|
%{
|
|
provider_uid: provider_uid,
|
|
email: email |> String.trim() |> String.downcase(),
|
|
email_verified: true,
|
|
display_name: display_name |> String.trim() |> String.slice(0, 80),
|
|
hosted_domain: normalize_hosted_domain(hosted_domain)
|
|
}}
|
|
end
|
|
end
|
|
|
|
defp normalize_google_identity(_identity_attrs),
|
|
do: {:error, :invalid_provider_identity}
|
|
|
|
defp normalize_hosted_domain(domain) when is_binary(domain) do
|
|
case domain |> String.trim() |> String.downcase() do
|
|
"" -> nil
|
|
normalized -> String.slice(normalized, 0, 255)
|
|
end
|
|
end
|
|
|
|
defp normalize_hosted_domain(_domain), do: nil
|
|
|
|
defp normalized_email(email) when is_binary(email),
|
|
do: email |> String.trim() |> String.downcase()
|
|
|
|
defp registration_value(attrs, key, default) do
|
|
case Map.fetch(attrs, key) do
|
|
{:ok, value} ->
|
|
value
|
|
|
|
:error ->
|
|
case Map.fetch(@registration_key_atoms, key) do
|
|
{:ok, atom_key} -> Map.get(attrs, atom_key, default)
|
|
:error -> default
|
|
end
|
|
end
|
|
end
|
|
|
|
## Settings
|
|
|
|
@doc """
|
|
Checks whether the user is in sudo mode.
|
|
|
|
The user is in sudo mode when the last authentication was done no further
|
|
than 20 minutes ago. The limit can be given as second argument in minutes.
|
|
"""
|
|
def sudo_mode?(user, minutes \\ -20)
|
|
|
|
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
|
|
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
|
|
end
|
|
|
|
def sudo_mode?(_user, _minutes), do: false
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user email.
|
|
|
|
See `WhoNeedHelp.Accounts.User.email_changeset/3` for a list of supported options.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_email(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
def change_user_email(user, attrs \\ %{}, opts \\ []) do
|
|
User.email_changeset(user, attrs, opts)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user email using the given token.
|
|
|
|
If the token matches, the user email is updated and the token is deleted.
|
|
"""
|
|
def update_user_email(user, token) do
|
|
context = "change:#{user.email}"
|
|
|
|
Repo.transact(fn ->
|
|
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
|
%UserToken{sent_to: email} <- query |> lock("FOR UPDATE") |> Repo.one(),
|
|
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
|
{count, _result} when count > 0 <-
|
|
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
|
|
{:ok, user}
|
|
else
|
|
_ -> {:error, :transaction_aborted}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user password.
|
|
|
|
See `WhoNeedHelp.Accounts.User.password_changeset/3` for a list of supported options.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_password(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
def change_user_password(user, attrs \\ %{}, opts \\ []) do
|
|
User.password_changeset(user, attrs, opts)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user password.
|
|
|
|
Returns a tuple with the updated user, as well as a list of expired tokens.
|
|
|
|
## Examples
|
|
|
|
iex> update_user_password(user, %{password: ...})
|
|
{:ok, {%User{}, [...]}}
|
|
|
|
iex> update_user_password(user, %{password: "too short"})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
def update_user_password(user, attrs) do
|
|
user
|
|
|> User.password_changeset(attrs)
|
|
|> update_user_and_delete_all_tokens()
|
|
end
|
|
|
|
## Session
|
|
|
|
@doc """
|
|
Generates a session token.
|
|
"""
|
|
def generate_user_session_token(user) do
|
|
{token, user_token} = UserToken.build_session_token(user)
|
|
Repo.insert!(user_token)
|
|
token
|
|
end
|
|
|
|
@doc """
|
|
Gets the user with the given signed token.
|
|
|
|
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
|
|
"""
|
|
def get_user_by_session_token(token) do
|
|
{:ok, query} = UserToken.verify_session_token_query(token)
|
|
|
|
case Repo.one(query) do
|
|
{%User{} = user, inserted_at} -> {preload_staff_roles(user), inserted_at}
|
|
nil -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the user with the given magic link token.
|
|
"""
|
|
def get_user_by_magic_link_token(token) do
|
|
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
|
|
{%User{moderation_status: status} = user, _token} <- Repo.one(query),
|
|
true <- status != :suspended do
|
|
user
|
|
else
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Logs the user in by magic link.
|
|
|
|
There are three cases to consider:
|
|
|
|
1. The user has already confirmed their email. They are logged in
|
|
and the magic link is expired.
|
|
|
|
2. The user has not confirmed their email and no password is set.
|
|
In this case, the user gets confirmed, logged in, and all tokens -
|
|
including session ones - are expired. In theory, no other tokens
|
|
exist but we delete all of them for best security practices.
|
|
|
|
3. The user has not confirmed their email but a password is set.
|
|
This cannot happen in the default implementation but may be the
|
|
source of security pitfalls. See the "Mixing magic link and password registration" section of
|
|
`mix help phx.gen.auth`.
|
|
"""
|
|
def login_user_by_magic_link(token) do
|
|
with {:ok, query} <- UserToken.verify_magic_link_token_query(token) do
|
|
result =
|
|
Repo.transact(fn ->
|
|
case query |> lock("FOR UPDATE") |> Repo.one() do
|
|
{%User{moderation_status: :suspended}, token} ->
|
|
with {:ok, _token} <- Repo.delete(token) do
|
|
{:ok, {:rejected, :not_found}}
|
|
end
|
|
|
|
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
|
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
|
raise """
|
|
magic link log in is not allowed for unconfirmed users with a password set!
|
|
|
|
This cannot happen with the default implementation, which indicates that you
|
|
might have adapted the code to a different use case. Please make sure to read the
|
|
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
|
"""
|
|
|
|
{%User{confirmed_at: nil} = user, _token} ->
|
|
user
|
|
|> User.confirm_changeset()
|
|
|> update_user_and_delete_all_tokens_in_transaction()
|
|
|
|
{user, token} ->
|
|
with {:ok, _token} <- Repo.delete(token) do
|
|
{:ok, {user, []}}
|
|
end
|
|
|
|
nil ->
|
|
{:error, :not_found}
|
|
end
|
|
end)
|
|
|
|
case result do
|
|
{:ok, {:rejected, reason}} -> {:error, reason}
|
|
other -> other
|
|
end
|
|
else
|
|
_invalid_token -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc ~S"""
|
|
Delivers the update email instructions to the given user.
|
|
|
|
## Examples
|
|
|
|
iex> deliver_user_update_email_instructions(user, current_email, &"https://example.test/users/settings/confirm-email#token=#{&1}")
|
|
{:ok, %{to: ..., body: ...}}
|
|
|
|
"""
|
|
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
|
when is_function(update_email_url_fun, 1) do
|
|
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
|
|
|
persist_email_token_and_deliver(user_token, fn ->
|
|
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Delivers the magic link login instructions to the given user.
|
|
"""
|
|
def deliver_login_instructions(%User{} = user, magic_link_url_fun)
|
|
when is_function(magic_link_url_fun, 1) do
|
|
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
|
|
|
|
persist_email_token_and_deliver(user_token, fn ->
|
|
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
|
|
end)
|
|
end
|
|
|
|
@doc "Delivers a one-time local-account verification link before connecting Google."
|
|
def deliver_google_link_instructions(%User{} = user, delivery_key, magic_link_url_fun)
|
|
when is_binary(delivery_key) and is_function(magic_link_url_fun, 1) do
|
|
context = "login:google:#{short_hash(delivery_key)}"
|
|
{encoded_token, user_token} = UserToken.build_email_token(user, context)
|
|
|
|
persist_email_token_and_deliver(user_token, fn ->
|
|
UserNotifier.deliver_google_link_instructions(user, magic_link_url_fun.(encoded_token))
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Deletes the signed token with the given context.
|
|
"""
|
|
def delete_user_session_token(token) do
|
|
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
|
|
:ok
|
|
end
|
|
|
|
def delete_expired_user_tokens(now \\ DateTime.utc_now(:second)) do
|
|
now
|
|
|> UserToken.expired_tokens_query()
|
|
|> Repo.delete_all()
|
|
end
|
|
|
|
## Token helper
|
|
|
|
defp persist_email_token_and_deliver(user_token, deliver_fun) do
|
|
case reserve_email_token(user_token) do
|
|
{:ok, :already_sent} ->
|
|
{:ok, :already_sent}
|
|
|
|
{:ok, persisted_token} ->
|
|
deliver_reserved_email_token(persisted_token, deliver_fun)
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp reserve_email_token(%UserToken{context: "login"} = user_token),
|
|
do: reserve_auth_email_token(user_token)
|
|
|
|
defp reserve_email_token(%UserToken{context: "login:google:" <> _} = user_token),
|
|
do: reserve_auth_email_token(user_token)
|
|
|
|
defp reserve_email_token(user_token), do: {:ok, Repo.insert!(user_token)}
|
|
|
|
defp reserve_auth_email_token(user_token) do
|
|
cutoff =
|
|
DateTime.utc_now(:second)
|
|
|> DateTime.add(-UserToken.magic_link_validity_in_minutes(), :minute)
|
|
|
|
Repo.transact(fn ->
|
|
# Serialize reservations per account so concurrent requests cannot both send.
|
|
Repo.one!(
|
|
from user in User,
|
|
where: user.id == ^user_token.user_id,
|
|
select: user.id,
|
|
lock: "FOR UPDATE"
|
|
)
|
|
|
|
recent_token? =
|
|
Repo.exists?(
|
|
from token in UserToken,
|
|
where: token.user_id == ^user_token.user_id,
|
|
where: token.context == ^user_token.context,
|
|
where: token.sent_to == ^user_token.sent_to,
|
|
where: token.inserted_at > ^cutoff
|
|
)
|
|
|
|
if recent_token?, do: {:ok, :already_sent}, else: Repo.insert(user_token)
|
|
end)
|
|
end
|
|
|
|
defp deliver_reserved_email_token(persisted_token, deliver_fun) do
|
|
case deliver_fun.() do
|
|
{:ok, _email} = delivered ->
|
|
delivered
|
|
|
|
{:error, _reason} = error ->
|
|
Repo.delete_all(from token in UserToken, where: token.id == ^persisted_token.id)
|
|
error
|
|
end
|
|
end
|
|
|
|
defp short_hash(value) do
|
|
:crypto.hash(:sha256, value)
|
|
|> Base.url_encode64(padding: false)
|
|
|> binary_part(0, 22)
|
|
end
|
|
|
|
defp before_moderation_user(query, nil), do: query
|
|
|
|
defp before_moderation_user(query, {inserted_at, id}) do
|
|
where(
|
|
query,
|
|
[user],
|
|
user.inserted_at < ^inserted_at or
|
|
(user.inserted_at == ^inserted_at and user.id < ^id)
|
|
)
|
|
end
|
|
|
|
defp maybe_user_status(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_user_status(query, value) do
|
|
case Ecto.Enum.cast_value(User, :moderation_status, value) do
|
|
{:ok, status} -> where(query, [user], user.moderation_status == ^status)
|
|
:error -> where(query, [user], false)
|
|
end
|
|
end
|
|
|
|
defp maybe_user_role(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_user_role(query, "user") do
|
|
from user in query,
|
|
left_join: assignment in StaffRoleAssignment,
|
|
on: assignment.user_id == user.id,
|
|
where: is_nil(assignment.id)
|
|
end
|
|
|
|
defp maybe_user_role(query, value) do
|
|
case Enum.find(StaffPermissions.roles(), &(Atom.to_string(&1) == to_string(value))) do
|
|
nil ->
|
|
where(query, [user], false)
|
|
|
|
role ->
|
|
from user in query,
|
|
join: assignment in StaffRoleAssignment,
|
|
on: assignment.user_id == user.id and assignment.role == ^role
|
|
end
|
|
end
|
|
|
|
defp maybe_user_search(query, value) when value in [nil, ""], do: query
|
|
|
|
defp maybe_user_search(query, value) do
|
|
term = value |> String.trim() |> String.replace("%", "") |> String.replace("_", "")
|
|
|
|
if String.length(term) < 3 do
|
|
where(query, [user], false)
|
|
else
|
|
pattern = "%#{String.downcase(term)}%"
|
|
|
|
where(
|
|
query,
|
|
[user],
|
|
fragment("lower(?::text) LIKE ?", user.email, ^pattern) or
|
|
fragment("lower(?) LIKE ?", user.display_name, ^pattern)
|
|
)
|
|
end
|
|
end
|
|
|
|
defp cast_id(value) do
|
|
case Ecto.UUID.cast(value) do
|
|
{:ok, id} -> {:ok, id}
|
|
:error -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
defp update_user_and_delete_all_tokens(changeset) do
|
|
Repo.transact(fn -> update_user_and_delete_all_tokens_in_transaction(changeset) end)
|
|
end
|
|
|
|
defp update_user_and_delete_all_tokens_in_transaction(changeset) do
|
|
with {:ok, user} <- Repo.update(changeset) do
|
|
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
|
|
|
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
|
|
|
{:ok, {user, tokens_to_expire}}
|
|
end
|
|
end
|
|
end
|