Files
BeetRoundServer/lib/beet_round_server/admins.ex

329 lines
8.7 KiB
Elixir

defmodule BeetRoundServer.Admins do
@moduledoc """
The Admins context.
"""
import Ecto.Query, warn: false
alias BeetRoundServer.Repo
alias BeetRoundServer.Admins.{Admin, AdminToken, AdminNotifier}
## Database getters
@doc """
Gets a admin by email.
## Examples
iex> get_admin_by_email("foo@example.com")
%Admin{}
iex> get_admin_by_email("unknown@example.com")
nil
"""
def get_admin_by_email(email) when is_binary(email) do
Repo.get_by(Admin, email: email)
end
@doc """
Gets a admin by email and password.
## Examples
iex> get_admin_by_email_and_password("foo@example.com", "correct_password")
%Admin{}
iex> get_admin_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_admin_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
admin = Repo.get_by(Admin, email: email)
if Admin.valid_password?(admin, password), do: admin
end
@doc """
Gets a single admin.
Raises `Ecto.NoResultsError` if the Admin does not exist.
## Examples
iex> get_admin!(123)
%Admin{}
iex> get_admin!(456)
** (Ecto.NoResultsError)
"""
def get_admin!(id), do: Repo.get!(Admin, id)
## Admin registration
@doc """
Registers a admin.
## Examples
iex> register_admin(%{field: value})
{:ok, %Admin{}}
iex> register_admin(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_admin(attrs) do
%Admin{}
|> Admin.email_changeset(attrs)
|> Admin.password_changeset(attrs)
|> Repo.insert()
end
## Settings
@doc """
Checks whether the admin is in sudo mode.
The admin 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?(admin, minutes \\ -20)
def sudo_mode?(%Admin{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_admin, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the admin email.
See `BeetRoundServer.Admins.Admin.email_changeset/3` for a list of supported options.
## Examples
iex> change_admin_email(admin)
%Ecto.Changeset{data: %Admin{}}
"""
def change_admin_email(admin, attrs \\ %{}, opts \\ []) do
Admin.email_changeset(admin, attrs, opts)
end
@doc """
Updates the admin email using the given token.
If the token matches, the admin email is updated and the token is deleted.
"""
def update_admin_email(admin, token) do
context = "change:#{admin.email}"
Repo.transact(fn ->
with {:ok, query} <- AdminToken.verify_change_email_token_query(token, context),
%AdminToken{sent_to: email} <- Repo.one(query),
{:ok, admin} <- Repo.update(Admin.email_changeset(admin, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(AdminToken, where: [admin_id: ^admin.id, context: ^context])) do
{:ok, admin}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the admin password.
See `BeetRoundServer.Admins.Admin.password_changeset/3` for a list of supported options.
## Examples
iex> change_admin_password(admin)
%Ecto.Changeset{data: %Admin{}}
"""
def change_admin_password(admin, attrs \\ %{}, opts \\ []) do
Admin.password_changeset(admin, attrs, opts)
end
@doc """
Updates the admin password.
Returns a tuple with the updated admin, as well as a list of expired tokens.
## Examples
iex> update_admin_password(admin, %{password: ...})
{:ok, {%Admin{}, [...]}}
iex> update_admin_password(admin, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_admin_password(admin, attrs) do
admin
|> Admin.password_changeset(attrs)
|> update_admin_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_admin_session_token(admin) do
{token, admin_token} = AdminToken.build_session_token(admin)
Repo.insert!(admin_token)
token
end
@doc """
Gets the admin with the given signed token.
If the token is valid `{admin, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_admin_by_session_token(token) do
{:ok, query} = AdminToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Gets the admin with the given magic link token.
"""
def get_admin_by_magic_link_token(token) do
with {:ok, query} <- AdminToken.verify_magic_link_token_query(token),
{admin, _token} <- Repo.one(query) do
admin
else
_ -> nil
end
end
@doc """
Logs the admin in by magic link.
There are three cases to consider:
1. The admin has already confirmed their email. They are logged in
and the magic link is expired.
2. The admin has not confirmed their email and no password is set.
In this case, the admin 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 admin 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_admin_by_magic_link(token) do
{:ok, query} = AdminToken.verify_magic_link_token_query(token)
case Repo.one(query) do
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
{%Admin{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`.
"""
{%Admin{confirmed_at: nil} = admin, _token} ->
admin
|> Admin.confirm_changeset()
|> update_admin_and_delete_all_tokens()
{admin, token} ->
Repo.delete!(token)
{:ok, {admin, []}}
nil ->
{:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given admin.
## Examples
iex> deliver_admin_update_email_instructions(admin, current_email, &url(~p"/admins/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_admin_update_email_instructions(
%Admin{} = admin,
current_email,
update_email_url_fun
)
when is_function(update_email_url_fun, 1) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "change:#{current_email}")
Repo.insert!(admin_token)
AdminNotifier.deliver_update_email_instructions(admin, update_email_url_fun.(encoded_token))
end
@doc """
Delivers the magic link login instructions to the given admin.
"""
def deliver_login_instructions(%Admin{} = admin, magic_link_url_fun)
when is_function(magic_link_url_fun, 1) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "login")
Repo.insert!(admin_token)
AdminNotifier.deliver_login_instructions(admin, magic_link_url_fun.(encoded_token))
end
@doc """
Deletes the signed token with the given context.
"""
def delete_admin_session_token(token) do
Repo.delete_all(from(AdminToken, where: [token: ^token, context: "session"]))
:ok
end
@doc """
Creates a new api token for an admin.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_admin_api_token(admin) do
{encoded_token, admin_token} = AdminToken.build_email_token(admin, "api-token")
Repo.insert!(admin_token)
encoded_token
end
@doc """
Fetches the admin by API token.
"""
def fetch_admin_by_api_token(token) do
with {:ok, query} <- AdminToken.verify_email_token_query(token, "api-token"),
%Admin{} = admin <- Repo.one(query) do
{:ok, admin}
else
_ -> :error
end
end
## Token helper
defp update_admin_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, admin} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(AdminToken, admin_id: admin.id)
Repo.delete_all(
from(t in AdminToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id))
)
{:ok, {admin, tokens_to_expire}}
end
end)
end
end