towerops/lib/towerops/accounts.ex
Graham McIntire a680d5ea94 refactor: split accounts.ex into cohesive submodules
Continues the extraction begun with Accounts.TOTP and Accounts.Sessions.
The context module shrinks from ~1198 to 577 lines by moving cohesive
sub-domains into dedicated submodules under lib/towerops/accounts/:

  * Consents          - grant/revoke/list user consents (+ private
                        grant_consents_sequential helper used by
                        register_user/1)
  * PolicyVersions    - policy version CRUD + reconsent detection
  * LoginHistory      - record/list/count login attempts, GDPR anonymize
  * MagicLinks        - magic-link login flow
  * Passwords         - change/update/reset + reset-instruction email
  * Emails            - change/update + update-instruction email

The Accounts module retains a defdelegate facade so existing callers
across lib/towerops_web and other contexts continue to work unchanged.

Two shared transaction helpers (update_user_and_delete_all_tokens/1 and
unwrap_transaction_result/1) are used by multiple extracted modules; they
remain in Accounts but are now public with @doc false rather than
duplicated.
2026-04-30 14:26:30 -05:00

577 lines
18 KiB
Elixir

defmodule Towerops.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.Consents
alias Towerops.Accounts.Emails
alias Towerops.Accounts.LoginHistory
alias Towerops.Accounts.MagicLinks
alias Towerops.Accounts.Passwords
alias Towerops.Accounts.PolicyVersions
alias Towerops.Accounts.Sessions
alias Towerops.Accounts.TOTP
alias Towerops.Accounts.User
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Admin.AuditLogger
alias Towerops.Repo
require Logger
## 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
"""
@spec get_user_by_email(String.t()) :: User.t() | 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
"""
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | 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
"""
@spec get_user(String.t()) :: User.t() | 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)
"""
@spec get_user!(String.t()) :: User.t()
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{}}
"""
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
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{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs)
Repo.transaction(fn ->
with {:ok, user} <- Repo.insert(user_changeset),
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, changeset} -> Repo.rollback(changeset)
end
end)
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{}}
"""
@spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user_with_organization(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs)
Repo.transaction(fn ->
with {:ok, user} <- Repo.insert(user_changeset),
org_name = attrs["organization_name"] || "Personal",
{:ok, _org} <- Towerops.Organizations.create_organization(%{name: org_name}, user.id),
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, %Ecto.Changeset{} = changeset} -> Repo.rollback(changeset)
{:error, reason} -> Repo.rollback(reason)
end
end)
end
## TOTP / Two-Factor Authentication
## (Implementation lives in `Towerops.Accounts.TOTP`.)
defdelegate generate_totp_secret(), to: TOTP, as: :generate_secret
defdelegate generate_totp_uri(user, secret), to: TOTP, as: :generate_uri
defdelegate generate_totp_qr_code(user, secret), to: TOTP, as: :generate_qr_code
defdelegate verify_totp(secret, code), to: TOTP, as: :verify
defdelegate enable_totp(user, secret), to: TOTP, as: :enable
defdelegate totp_enabled?(user), to: TOTP, as: :enabled?
defdelegate verify_user_totp(user, code), to: TOTP, as: :verify_user
defdelegate list_user_totp_devices(user_id), to: TOTP, as: :list_user_devices
defdelegate count_user_totp_devices(user_id), to: TOTP, as: :count_user_devices
defdelegate create_totp_device(user_id, name), to: TOTP, as: :create_device
defdelegate create_totp_device(user_id, name, secret), to: TOTP, as: :create_device
defdelegate verify_user_totp_any_device(user, code), to: TOTP, as: :verify_user_any_device
defdelegate delete_totp_device(device_id, user_id), to: TOTP, as: :delete_device
defdelegate rename_totp_device(device_id, user_id, new_name), to: TOTP, as: :rename_device
defdelegate generate_recovery_codes(user_id), to: TOTP
defdelegate verify_recovery_code(user_id, code), to: TOTP
defdelegate count_unused_recovery_codes(user_id), to: TOTP
defdelegate list_user_recovery_codes(user_id), to: TOTP
defdelegate verify_user_mfa(user, code), to: TOTP
defdelegate verify_totp_only(user, code), to: TOTP
## 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 sudo verification was done no further
than the specified minutes ago. Default is 10 minutes (pass -10 as the minutes arg).
"""
def sudo_mode?(user, minutes \\ -10)
def sudo_mode?(%User{last_sudo_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 """
Grants sudo mode to a user by updating their last_sudo_at timestamp.
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
## Examples
iex> grant_sudo_mode(user)
{:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
"""
def grant_sudo_mode(%User{} = user) do
now = Towerops.Time.now()
with {:ok, updated_user} <-
user
|> User.sudo_changeset(%{last_sudo_at: now})
|> Repo.update() do
# Log sudo mode grant for security auditing
AuditLogger.log_event("sudo_mode_granted", nil,
actor_id: user.id,
target_user_id: user.id,
metadata: %{granted_at: now}
)
# Set authenticated_at virtual field for sudo_mode? check
{:ok, %{updated_user | authenticated_at: now}}
end
end
## Email management
## (Implementation lives in `Towerops.Accounts.Emails`.)
defdelegate change_user_email(user, attrs \\ %{}, opts \\ []), to: Emails
defdelegate update_user_email(user, token), to: Emails
defdelegate deliver_user_update_email_instructions(user, current_email, update_email_url_fun), to: Emails
## Password management
## (Implementation lives in `Towerops.Accounts.Passwords`.)
defdelegate change_user_password(user, attrs \\ %{}, opts \\ []), to: Passwords
defdelegate update_user_password(user, attrs), to: Passwords
defdelegate deliver_user_reset_password_instructions(user, reset_password_url_fun), to: Passwords
defdelegate get_user_by_reset_password_token(token), to: Passwords
defdelegate reset_user_password(user, attrs), to: Passwords
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
case Repo.insert(user_token) do
{:ok, _} ->
token
{:error, changeset} ->
Logger.error("Failed to insert session token: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
end
@doc """
Generates a session token and returns both the token and the user_token record.
This is used when creating browser sessions to link them to the token ID.
"""
def generate_user_session_token_with_record(user) do
{token, user_token_changeset} = UserToken.build_session_token(user)
case Repo.insert(user_token_changeset) do
{:ok, user_token} ->
{token, user_token}
{:error, changeset} ->
Logger.error("Failed to insert session token with record: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
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 token ID for a given session token value.
Returns the token ID if found, otherwise `nil`.
"""
def get_user_token_id_by_value(token) when is_binary(token) do
hashed_token = :crypto.hash(:sha256, token)
Repo.one(from(ut in UserToken, where: ut.token == ^hashed_token and ut.context == "session", select: ut.id))
end
## Magic-link login
## (Implementation lives in `Towerops.Accounts.MagicLinks`.)
defdelegate get_user_by_magic_link_token(token), to: MagicLinks
defdelegate login_user_by_magic_link(token), to: MagicLinks
defdelegate deliver_login_instructions(user, magic_link_url_fun), to: MagicLinks
@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 helpers (shared)
##
## These are public-but-internal: they are used by extracted submodules
## (`Passwords`, `MagicLinks`, `Emails`) and must therefore be callable from
## outside this module. They are not part of the public API.
@doc false
@spec unwrap_transaction_result({:ok, {:ok, term()} | {:error, term()}} | {:error, term()}) ::
{:ok, term()} | {:error, term()}
def unwrap_transaction_result(result) do
case result do
{:ok, {:ok, value}} -> {:ok, value}
{:ok, {:error, reason}} -> {:error, reason}
{:error, reason} -> {:error, reason}
end
end
@doc false
def update_user_and_delete_all_tokens(changeset) do
changeset
|> do_update_user_and_delete_tokens()
|> Repo.transaction()
|> unwrap_transaction_result()
end
defp do_update_user_and_delete_tokens(changeset) do
fn ->
case Repo.update(changeset) do
{:ok, user} ->
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}}
{:error, changeset} ->
{:error, changeset}
end
end
end
## User Consent
## (Implementation lives in `Towerops.Accounts.Consents`.)
defdelegate grant_consent(user_id, consent_type), to: Consents
defdelegate revoke_consent(consent_id), to: Consents
defdelegate list_user_consents(user_id), to: Consents
defdelegate get_active_consent(user_id, consent_type), to: Consents
defdelegate has_consent?(user_id, consent_type), to: Consents
defdelegate has_all_required_consents?(user_id), to: Consents
## Policy Version Management
## (Implementation lives in `Towerops.Accounts.PolicyVersions`.)
defdelegate create_policy_version(attrs), to: PolicyVersions
defdelegate get_current_policy_version(policy_type), to: PolicyVersions
defdelegate get_policy_version(policy_type, version), to: PolicyVersions
defdelegate list_policy_versions(policy_type), to: PolicyVersions
defdelegate needs_reconsent?(user_id, policy_type), to: PolicyVersions
defdelegate policies_needing_reconsent(user_id), to: PolicyVersions
## Login Attempts
## (Implementation lives in `Towerops.Accounts.LoginHistory`.)
defdelegate record_login_attempt(attrs), to: LoginHistory
defdelegate list_user_login_history(user_id, opts \\ []), to: LoginHistory
defdelegate count_user_login_attempts(user_id, opts \\ []), to: LoginHistory
defdelegate anonymize_user_login_history(user_id), to: LoginHistory
## Browser Sessions
## (Implementation lives in `Towerops.Accounts.Sessions`.)
defdelegate create_browser_session(attrs), to: Sessions, as: :create
defdelegate list_active_browser_sessions(user_id), to: Sessions, as: :list_active
defdelegate get_browser_session_by_token(user_token_id), to: Sessions, as: :get_by_token
defdelegate get_browser_session_by_token_value(token), to: Sessions, as: :get_by_token_value
defdelegate touch_browser_session(session), to: Sessions, as: :touch
defdelegate revoke_browser_session(session_id, user_id, current_token_id), to: Sessions, as: :revoke
defdelegate revoke_all_other_sessions(user_id, current_token_id), to: Sessions, as: :revoke_all_other
defdelegate anonymize_user_browser_sessions(user_id), to: Sessions, as: :anonymize_for_user
defdelegate delete_expired_browser_sessions(), to: Sessions, as: :delete_expired
@doc """
Delivers confirmation instructions to the given user.
"""
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) do
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_confirmation_email(user, confirmation_url_fun.(encoded_token))
{:error, changeset} ->
Logger.error("Failed to insert confirmation token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
@doc """
Confirms a user by the given token.
"""
@spec confirm_user(String.t()) :: {:ok, User.t()} | :error
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, confirmed_user} <- confirm_user_transaction(user) do
{:ok, confirmed_user}
else
_ -> :error
end
end
@spec confirm_user_transaction(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
defp confirm_user_transaction(user) do
Repo.transaction(fn ->
case Repo.update(User.confirm_changeset(user)) do
{:ok, confirmed_user} ->
_ = Repo.delete_all(UserToken.by_user_and_contexts_query(user, ["confirm"]))
confirmed_user
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
end
## Account Deletion (GDPR Right to Erasure)
@doc """
Returns organizations where the user is the sole owner.
Used to check if a user can safely delete their account without
orphaning organizations.
"""
@spec sole_owner_organizations(String.t()) :: [Towerops.Organizations.Organization.t()]
def sole_owner_organizations(user_id) do
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
user_owner_org_ids =
Membership
|> where([m], m.user_id == ^user_id and m.role == :owner)
|> select([m], m.organization_id)
|> Repo.all()
if user_owner_org_ids == [] do
[]
else
sole_owner_ids =
Membership
|> where([m], m.organization_id in ^user_owner_org_ids and m.role == :owner)
|> group_by([m], m.organization_id)
|> having([m], count(m.id) == 1)
|> select([m], m.organization_id)
|> Repo.all()
Organization
|> where([o], o.id in ^sole_owner_ids)
|> Repo.all()
end
end
@doc """
Deletes a user's account (self-service, GDPR Article 17).
Verifies the user's password before proceeding. Checks for sole-owner
organizations and blocks deletion if found.
Within a transaction:
1. Creates an audit log entry
2. Anonymizes login history
3. Anonymizes browser sessions
4. Deletes the user (cascades to memberships, tokens)
Returns `{:ok, user}` on success, or an error tuple.
"""
@spec delete_account(User.t(), String.t()) ::
{:ok, User.t()}
| {:error, :invalid_password}
| {:error, :sole_owner, [String.t()]}
def delete_account(%User{} = user, password) when is_binary(password) do
if User.valid_password?(user, password) do
do_delete_account(user)
else
{:error, :invalid_password}
end
end
defp do_delete_account(user) do
sole_owner_orgs = sole_owner_organizations(user.id)
if sole_owner_orgs == [] do
delete_account_transaction(user)
else
org_names = Enum.map(sole_owner_orgs, & &1.name)
{:error, :sole_owner, org_names}
end
end
defp delete_account_transaction(user) do
Repo.transaction(fn ->
{:ok, _} =
Towerops.Admin.create_audit_log(%{
action: "account_deletion_requested",
superuser_id: user.id,
target_user_id: user.id,
metadata: %{email: user.email, self_service: true}
})
anonymize_user_login_history(user.id)
anonymize_user_browser_sessions(user.id)
case Repo.delete(user) do
{:ok, deleted_user} ->
deleted_user
{:error, changeset} ->
Logger.error("Failed to delete user account: #{inspect(changeset)}")
# Raise to trigger rollback - never call Repo.rollback/1 directly
raise Ecto.InvalidChangesetError, action: :delete, changeset: changeset
end
end)
end
end