615 lines
16 KiB
Elixir
615 lines
16 KiB
Elixir
defmodule Towerops.Accounts do
|
|
@moduledoc """
|
|
The Accounts context.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Towerops.Accounts.User
|
|
alias Towerops.Accounts.UserCredential
|
|
alias Towerops.Accounts.UserNotifier
|
|
alias Towerops.Accounts.UserToken
|
|
alias Towerops.Accounts.WebAuthn
|
|
alias Towerops.Repo
|
|
|
|
## Database getters
|
|
|
|
@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: 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 = Repo.get_by(User, email: email)
|
|
if User.valid_password?(user, password), do: user
|
|
end
|
|
|
|
@doc """
|
|
Gets a single user.
|
|
|
|
Returns `nil` if the User does not exist.
|
|
|
|
## Examples
|
|
|
|
iex> get_user("123")
|
|
%User{}
|
|
|
|
iex> get_user("456")
|
|
nil
|
|
|
|
"""
|
|
def get_user(id) when is_binary(id) do
|
|
Repo.get(User, id)
|
|
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)
|
|
|
|
## User registration
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_registration(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
|
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
|
|
end
|
|
|
|
@doc """
|
|
Registers a user with email and password.
|
|
|
|
## 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)
|
|
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Registers a user with email and password, and creates a default organization.
|
|
|
|
## Examples
|
|
|
|
iex> register_user_with_organization(%{field: value})
|
|
{:ok, %User{}}
|
|
|
|
iex> register_user_with_organization(%{field: bad_value})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
@dialyzer {:nowarn_function, register_user_with_organization: 1}
|
|
def register_user_with_organization(attrs) do
|
|
user_changeset =
|
|
%User{}
|
|
|> User.registration_changeset(attrs)
|
|
|> Ecto.Changeset.put_change(:confirmed_at, DateTime.utc_now(:second))
|
|
|
|
multi = Ecto.Multi.new()
|
|
multi = Ecto.Multi.insert(multi, :user, user_changeset)
|
|
|
|
multi =
|
|
Ecto.Multi.run(multi, :organization, fn _repo, %{user: user} ->
|
|
org_name = attrs["organization_name"] || "Personal"
|
|
|
|
Towerops.Organizations.create_organization(
|
|
%{name: org_name},
|
|
user.id
|
|
)
|
|
end)
|
|
|
|
case Repo.transaction(multi) do
|
|
{:ok, %{user: user}} -> {:ok, user}
|
|
{:error, :user, changeset, _} -> {:error, changeset}
|
|
{:error, :organization, changeset, _} -> {:error, changeset}
|
|
end
|
|
end
|
|
|
|
## Settings
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user profile.
|
|
|
|
## Examples
|
|
|
|
iex> change_user_profile(user)
|
|
%Ecto.Changeset{data: %User{}}
|
|
|
|
"""
|
|
def change_user_profile(user, attrs \\ %{}) do
|
|
User.profile_changeset(user, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user profile.
|
|
|
|
## Examples
|
|
|
|
iex> update_user_profile(user, %{name: ...})
|
|
{:ok, %User{}}
|
|
|
|
iex> update_user_profile(user, %{name: bad_value})
|
|
{:error, %Ecto.Changeset{}}
|
|
|
|
"""
|
|
def update_user_profile(user, attrs) do
|
|
user
|
|
|> User.profile_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@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.add(DateTime.utc_now(), minutes, :minute))
|
|
end
|
|
|
|
def sudo_mode?(_user, _minutes), do: false
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user email.
|
|
|
|
See `Towerops.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} <- Repo.one(query),
|
|
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
|
{_count, _result} <-
|
|
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 `Towerops.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)
|
|
Repo.one(query)
|
|
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, _token} <- Repo.one(query) 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
|
|
case UserToken.verify_magic_link_token_query(token) do
|
|
{:ok, query} ->
|
|
case Repo.one(query) do
|
|
# 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()
|
|
|
|
{user, token} ->
|
|
Repo.delete!(token)
|
|
{:ok, {user, []}}
|
|
|
|
nil ->
|
|
{:error, :not_found}
|
|
end
|
|
|
|
:error ->
|
|
{: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, &url(~p"/users/settings/confirm-email/#{&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}")
|
|
|
|
Repo.insert!(user_token)
|
|
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
|
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")
|
|
Repo.insert!(user_token)
|
|
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
|
|
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
|
|
|
|
## Token helper
|
|
|
|
defp update_user_and_delete_all_tokens(changeset) do
|
|
Repo.transact(fn ->
|
|
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
|
|
|
|
## WebAuthn / Passkey Authentication
|
|
|
|
@doc """
|
|
Checks if a user is allowed to register passkeys.
|
|
Requires the user's email to be confirmed.
|
|
"""
|
|
def passkey_registration_allowed?(%User{confirmed_at: nil}), do: false
|
|
def passkey_registration_allowed?(%User{}), do: true
|
|
|
|
@doc """
|
|
Lists all credentials for a user.
|
|
"""
|
|
def list_user_credentials(user_id) do
|
|
Repo.all(from c in UserCredential, where: c.user_id == ^user_id, order_by: [desc: c.last_used_at])
|
|
end
|
|
|
|
@doc """
|
|
Gets a credential by its credential_id (the WebAuthn credential identifier).
|
|
"""
|
|
def get_credential_by_credential_id(credential_id) do
|
|
Repo.get_by(UserCredential, credential_id: credential_id)
|
|
end
|
|
|
|
@doc """
|
|
Generates a WebAuthn registration challenge for a user.
|
|
|
|
Returns challenge data that should be sent to the client and stored
|
|
temporarily in the session.
|
|
"""
|
|
def generate_registration_challenge(user) do
|
|
challenge = :crypto.strong_rand_bytes(32)
|
|
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
|
|
|
%{
|
|
challenge: challenge_base64,
|
|
user: %{
|
|
id: Base.url_encode64(user.id, padding: false),
|
|
name: user.email,
|
|
displayName: user.email
|
|
},
|
|
rp: %{
|
|
name: "Towerops",
|
|
id: get_rp_id()
|
|
},
|
|
timeout: 60_000,
|
|
attestation: "none",
|
|
pubKeyCredParams: [
|
|
%{type: "public-key", alg: -7},
|
|
%{type: "public-key", alg: -257}
|
|
],
|
|
authenticatorSelection: %{
|
|
authenticatorAttachment: "platform",
|
|
requireResidentKey: false,
|
|
residentKey: "preferred",
|
|
userVerification: "preferred"
|
|
}
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Generates a WebAuthn authentication challenge for a user.
|
|
|
|
Returns challenge data with allowed credentials that should be sent
|
|
to the client.
|
|
"""
|
|
def generate_authentication_challenge(user) do
|
|
challenge = :crypto.strong_rand_bytes(32)
|
|
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
|
|
|
credentials = list_user_credentials(user.id)
|
|
|
|
%{
|
|
challenge: challenge_base64,
|
|
timeout: 60_000,
|
|
rpId: get_rp_id(),
|
|
allowCredentials:
|
|
Enum.map(credentials, fn cred ->
|
|
%{
|
|
type: "public-key",
|
|
id: Base.url_encode64(cred.credential_id, padding: false),
|
|
transports: cred.transports
|
|
}
|
|
end),
|
|
userVerification: "preferred"
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Generates a WebAuthn authentication challenge for discoverable credentials.
|
|
|
|
This allows usernameless authentication where the authenticator presents
|
|
available credentials for the RP ID.
|
|
"""
|
|
def generate_discoverable_authentication_challenge do
|
|
challenge = :crypto.strong_rand_bytes(32)
|
|
challenge_base64 = Base.url_encode64(challenge, padding: false)
|
|
|
|
%{
|
|
challenge: challenge_base64,
|
|
timeout: 60_000,
|
|
rpId: get_rp_id(),
|
|
# Empty allowCredentials allows any credential for this RP ID
|
|
allowCredentials: [],
|
|
userVerification: "preferred"
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Registers a new WebAuthn credential for a user.
|
|
|
|
Verifies the attestation response and creates the credential record.
|
|
"""
|
|
def register_credential(user, params, challenge) do
|
|
origin = get_origin()
|
|
rp_id = get_rp_id()
|
|
|
|
with {:ok, _} <- verify_passkey_registration_allowed(user),
|
|
{:ok, credential_data} <-
|
|
WebAuthn.verify_attestation(
|
|
params["attestation"]["response"],
|
|
challenge,
|
|
origin,
|
|
rp_id
|
|
) do
|
|
create_credential(user, credential_data, params["name"])
|
|
end
|
|
end
|
|
|
|
defp verify_passkey_registration_allowed(user) do
|
|
if passkey_registration_allowed?(user) do
|
|
{:ok, :allowed}
|
|
else
|
|
{:error, :email_not_confirmed}
|
|
end
|
|
end
|
|
|
|
defp create_credential(user, credential_data, name) do
|
|
%UserCredential{}
|
|
|> UserCredential.changeset(%{
|
|
user_id: user.id,
|
|
credential_id: credential_data.credential_id,
|
|
public_key: credential_data.public_key,
|
|
sign_count: credential_data.sign_count,
|
|
name: name || "Passkey",
|
|
aaguid: credential_data.aaguid,
|
|
attestation_format: credential_data.attestation_format
|
|
})
|
|
|> Repo.insert()
|
|
end
|
|
|
|
@doc """
|
|
Authenticates a user using a WebAuthn credential.
|
|
|
|
Verifies the assertion response and returns the user if valid.
|
|
"""
|
|
def authenticate_with_credential(params, challenge) do
|
|
origin = get_origin()
|
|
rp_id = get_rp_id()
|
|
|
|
with {:ok, credential_id_base64} <- Map.fetch(params["assertion"], "rawId"),
|
|
{:ok, credential_id} <- Base.url_decode64(credential_id_base64, padding: false),
|
|
%UserCredential{} = credential <- get_credential_by_credential_id(credential_id),
|
|
{:ok, new_sign_count} <-
|
|
WebAuthn.verify_assertion(
|
|
params["assertion"]["response"],
|
|
challenge,
|
|
origin,
|
|
rp_id,
|
|
credential.public_key,
|
|
credential.sign_count
|
|
),
|
|
{:ok, _credential} <- update_credential_usage(credential, new_sign_count) do
|
|
user = Repo.get!(User, credential.user_id)
|
|
{:ok, user}
|
|
else
|
|
:error -> {:error, :invalid_credential}
|
|
nil -> {:error, :invalid_credential}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
defp update_credential_usage(credential, new_sign_count) do
|
|
credential
|
|
|> Ecto.Changeset.change(%{
|
|
last_used_at: DateTime.utc_now(:second),
|
|
sign_count: new_sign_count
|
|
})
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Deletes a credential.
|
|
|
|
Only allows deletion if the credential belongs to the specified user.
|
|
"""
|
|
def delete_credential(credential_id, user_id) do
|
|
case Repo.get_by(UserCredential, id: credential_id, user_id: user_id) do
|
|
nil -> {:error, :not_found}
|
|
credential -> Repo.delete(credential)
|
|
end
|
|
end
|
|
|
|
defp get_origin do
|
|
Application.get_env(:towerops, :webauthn_origin, "http://localhost:4000")
|
|
end
|
|
|
|
defp get_rp_id do
|
|
Application.get_env(:towerops, :webauthn_rp_id, "localhost")
|
|
end
|
|
end
|