After "mix phx.gen.auth Admins Admin admins" with added working register and login path.

This commit is contained in:
2026-02-20 13:09:55 +01:00
parent a47931f40e
commit 53d19a3a18
28 changed files with 2830 additions and 0 deletions

View File

@ -0,0 +1,328 @@
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

View File

@ -0,0 +1,134 @@
defmodule BeetRoundServer.Admins.Admin do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "admins" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc """
A admin changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(admin, attrs, opts \\ []) do
admin
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, BeetRoundServer.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A admin changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(admin, attrs, opts \\ []) do
admin
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# If using Bcrypt, then further validate it is at most 72 bytes long
|> validate_length(:password, max: 72, count: :bytes)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Confirms the account by setting `confirmed_at`.
"""
def confirm_changeset(admin) do
now = DateTime.utc_now(:second)
change(admin, confirmed_at: now)
end
@doc """
Verifies the password.
If there is no admin or the admin doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%BeetRoundServer.Admins.Admin{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View File

@ -0,0 +1,84 @@
defmodule BeetRoundServer.Admins.AdminNotifier do
import Swoosh.Email
alias BeetRoundServer.Mailer
alias BeetRoundServer.Admins.Admin
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"BeetRoundServer", "contact@example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
@doc """
Deliver instructions to update a admin email.
"""
def deliver_update_email_instructions(admin, url) do
deliver(admin.email, "Update email instructions", """
==============================
Hi #{admin.email},
You can change your email by visiting the URL below:
#{url}
If you didn't request this change, please ignore this.
==============================
""")
end
@doc """
Deliver instructions to log in with a magic link.
"""
def deliver_login_instructions(admin, url) do
case admin do
%Admin{confirmed_at: nil} -> deliver_confirmation_instructions(admin, url)
_ -> deliver_magic_link_instructions(admin, url)
end
end
defp deliver_magic_link_instructions(admin, url) do
deliver(admin.email, "Log in instructions", """
==============================
Hi #{admin.email},
You can log into your account by visiting the URL below:
#{url}
If you didn't request this email, please ignore this.
==============================
""")
end
defp deliver_confirmation_instructions(admin, url) do
deliver(admin.email, "Confirmation instructions", """
==============================
Hi #{admin.email},
You can confirm your account by visiting the URL below:
#{url}
If you didn't create an account with us, please ignore this.
==============================
""")
end
end

View File

@ -0,0 +1,195 @@
defmodule BeetRoundServer.Admins.AdminToken do
use Ecto.Schema
import Ecto.Query
alias BeetRoundServer.Admins.AdminToken
@hash_algorithm :sha256
@rand_size 32
# It is very important to keep the magic link token expiry short,
# since someone with access to the email may take over the account.
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 14
@api_validity_in_days 30
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "admins_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :admin, BeetRoundServer.Admins.Admin
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual admin
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(admin) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = admin.authenticated_at || DateTime.utc_now(:second)
{token,
%AdminToken{token: token, context: "session", admin_id: admin.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{admin | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
@doc """
Builds a token and its hash to be delivered to the admin's email.
The non-hashed token is sent to the admin email while the
hashed part is stored in the database. The original token cannot be reconstructed,
which means anyone with read-only access to the database cannot directly use
the token in the application to gain access. Furthermore, if the admin changes
their email in the system, the tokens sent to the previous email are no longer
valid.
Users can easily adapt the existing code to provide other types of delivery methods,
for example, by phone numbers.
"""
def build_email_token(admin, context) do
build_hashed_token(admin, context, admin.email)
end
defp build_hashed_token(admin, context, sent_to) do
token = :crypto.strong_rand_bytes(@rand_size)
hashed_token = :crypto.hash(@hash_algorithm, token)
{Base.url_encode64(token, padding: false),
%AdminToken{
token: hashed_token,
context: context,
sent_to: sent_to,
admin_id: admin.id
}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
If found, the query returns a tuple of the form `{admin, token}`.
The given token is valid if it matches its hashed counterpart in the
database. This function also checks if the token is being used within
15 minutes. The context of a magic link token is always "login".
"""
def verify_magic_link_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "login"),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
where: token.sent_to == admin.email,
select: {admin, token}
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin_token found by the token, if any.
This is used to validate requests to change the admin
email.
The given token is valid if it matches its hashed counterpart in the
database and if it has not expired (after @change_email_validity_in_days).
The context must always start with "change:".
"""
def verify_change_email_token_query(token, "change:" <> _ = context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the admin found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within a certain period, depending on the
context. The default contexts supported by this function are either
"confirm", for account confirmation emails, and "reset_password",
for resetting the password. For verifying requests to change the email,
see `verify_change_email_token_query/2`.
"""
def verify_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
days = days_for_context(context)
query =
from token in by_token_and_context_query(hashed_token, context),
join: admin in assoc(token, :admin),
where: token.inserted_at > ago(^days, "day") and token.sent_to == admin.email,
select: admin
{:ok, query}
:error ->
:error
end
end
defp days_for_context("api-token"), do: @api_validity_in_days
defp by_token_and_context_query(token, context) do
from AdminToken, where: [token: ^token, context: ^context]
end
end

View File

@ -0,0 +1,33 @@
defmodule BeetRoundServer.Admins.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `BeetRoundServer.Admins.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias BeetRoundServer.Admins.Admin
defstruct admin: nil
@doc """
Creates a scope for the given admin.
Returns nil if no admin is given.
"""
def for_admin(%Admin{} = admin) do
%__MODULE__{admin: admin}
end
def for_admin(nil), do: nil
end