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.
This commit is contained in:
Graham McIntire 2026-04-30 14:01:31 -05:00
parent 247bbe58da
commit a680d5ea94
7 changed files with 832 additions and 676 deletions

View file

@ -5,16 +5,18 @@ defmodule Towerops.Accounts do
import Ecto.Query, warn: false
alias Towerops.Accounts.LoginAttempt
alias Towerops.Accounts.PolicyVersion
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.UserConsent
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Admin.AuditLogger
alias Towerops.GeoIP
alias Towerops.Repo
require Logger
@ -126,7 +128,7 @@ defmodule Towerops.Accounts do
Repo.transaction(fn ->
with {:ok, user} <- Repo.insert(user_changeset),
:ok <- grant_consents_sequential(user, attrs) do
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, changeset} -> Repo.rollback(changeset)
@ -154,7 +156,7 @@ defmodule Towerops.Accounts do
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 <- grant_consents_sequential(user, attrs) do
:ok <- Consents.grant_consents_sequential(user, attrs) do
user
else
{:error, %Ecto.Changeset{} = changeset} -> Repo.rollback(changeset)
@ -163,25 +165,6 @@ defmodule Towerops.Accounts do
end)
end
@spec grant_consents_sequential(User.t(), map()) :: :ok | {:error, Ecto.Changeset.t()}
defp grant_consents_sequential(user, attrs) do
privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]
with :ok <- maybe_grant_consent(user, privacy_consent, "privacy_policy") do
maybe_grant_consent(user, terms_consent, "terms_of_service")
end
end
defp maybe_grant_consent(user, flag, consent_type) when flag in ["true", true, "on"] do
case grant_consent(user.id, consent_type) do
{:ok, _} -> :ok
{:error, _} = err -> err
end
end
defp maybe_grant_consent(_user, _flag, _consent_type), do: :ok
## TOTP / Two-Factor Authentication
## (Implementation lives in `Towerops.Accounts.TOTP`.)
@ -282,177 +265,21 @@ defmodule Towerops.Accounts do
end
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
## Email management
## (Implementation lives in `Towerops.Accounts.Emails`.)
See `Towerops.Accounts.User.email_changeset/3` for a list of supported options.
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
## Examples
## Password management
## (Implementation lives in `Towerops.Accounts.Passwords`.)
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}"
old_email = user.email
result =
fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, updated_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, updated_user}
else
_ -> {:error, :transaction_aborted}
end
end
|> Repo.transaction()
|> unwrap_transaction_result()
# Log email change for security monitoring
case result do
{:ok, updated_user} ->
_ = AuditLogger.log_email_changed(nil, user.id, old_email, updated_user.email)
result
{:error, _reason} ->
result
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
result =
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
# Log password change for security monitoring
case result do
{:ok, _updated_user} ->
_ = AuditLogger.log_password_changed(nil, user.id)
result
{:error, _changeset} ->
result
end
end
@doc """
Delivers the reset password email to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
case Repo.insert(user_token) do
{:ok, _token} ->
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
{:error, changeset} ->
require Logger
Logger.error("Failed to insert reset password token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
@doc """
Gets the user by reset password token.
## Examples
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_reset_password_token_query(token),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
fn ->
changeset = User.password_changeset(user, attrs)
case update_user_and_delete_all_tokens(changeset) do
{:ok, {updated_user, expired_tokens}} ->
# Delete all reset_password tokens after successful password update
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id and t.context == "reset_password"))
{:ok, {updated_user, expired_tokens}}
{:error, changeset} ->
{:error, changeset}
end
end
|> Repo.transaction()
|> unwrap_transaction_result()
end
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
@ -467,8 +294,6 @@ defmodule Towerops.Accounts do
token
{:error, changeset} ->
require Logger
Logger.error("Failed to insert session token: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
@ -487,8 +312,6 @@ defmodule Towerops.Accounts do
{token, user_token}
{:error, changeset} ->
require Logger
Logger.error("Failed to insert session token with record: #{inspect(changeset.errors)}")
raise Ecto.InvalidChangesetError, changeset: changeset
end
@ -515,115 +338,12 @@ defmodule Towerops.Accounts do
Repo.one(from(ut in UserToken, where: ut.token == ^hashed_token and ut.context == "session", select: ut.id))
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
## Magic-link login
## (Implementation lives in `Towerops.Accounts.MagicLinks`.)
@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),
result when not is_nil(result) <- Repo.one(query) do
handle_magic_link_login(result)
else
nil -> {:error, :not_found}
:error -> {:error, :not_found}
end
end
defp handle_magic_link_login({%User{confirmed_at: nil, hashed_password: hash}, _token}) when not is_nil(hash) do
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`.
"""
end
defp handle_magic_link_login({%User{confirmed_at: nil} = user, _token}) do
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
end
defp handle_magic_link_login({user, token}) do
case Repo.delete(token) do
{:ok, _} ->
{:ok, {user, []}}
{:error, changeset} ->
Logger.error("Failed to delete magic link token: #{inspect(changeset)}")
{:ok, {user, []}}
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}")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
{:error, changeset} ->
require Logger
Logger.error("Failed to insert update email token: #{inspect(changeset.errors)}")
{:error, changeset}
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")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
{:error, changeset} ->
require Logger
Logger.error("Failed to insert login token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
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.
@ -633,10 +353,16 @@ defmodule Towerops.Accounts do
:ok
end
## Token helper
## 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.
# Helper function to unwrap nested transaction results
defp unwrap_transaction_result(result) do
@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}
@ -644,7 +370,8 @@ defmodule Towerops.Accounts do
end
end
defp update_user_and_delete_all_tokens(changeset) do
@doc false
def update_user_and_delete_all_tokens(changeset) do
changeset
|> do_update_user_and_delete_tokens()
|> Repo.transaction()
@ -666,378 +393,32 @@ defmodule Towerops.Accounts do
end
## User Consent
## (Implementation lives in `Towerops.Accounts.Consents`.)
@doc """
Grants consent for a user.
## Examples
iex> grant_consent(user_id, "privacy_policy")
{:ok, %UserConsent{}}
iex> grant_consent(user_id, "invalid_type")
{:error, %Ecto.Changeset{}}
"""
def grant_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
version = UserConsent.current_version(consent_type)
granted_at = DateTime.utc_now(:second)
%UserConsent{}
|> UserConsent.grant_changeset(%{
user_id: user_id,
consent_type: consent_type,
version: version,
granted_at: granted_at
})
|> Repo.insert()
end
@doc """
Revokes consent for a user.
## Examples
iex> revoke_consent(consent_id)
{:ok, %UserConsent{}}
iex> revoke_consent("invalid")
{:error, :not_found}
"""
def revoke_consent(consent_id) when is_binary(consent_id) do
case Repo.get(UserConsent, consent_id) do
nil ->
{:error, :not_found}
consent ->
consent
|> UserConsent.revoke_changeset(%{revoked_at: DateTime.utc_now(:second)})
|> Repo.update()
end
end
@doc """
Lists all consents for a user.
## Examples
iex> list_user_consents(user_id)
[%UserConsent{}, ...]
"""
def list_user_consents(user_id) when is_binary(user_id) do
UserConsent
|> where([c], c.user_id == ^user_id)
|> order_by([c], desc: c.granted_at)
|> Repo.all()
end
@doc """
Gets the active consent for a user and consent type.
Returns the most recent non-revoked consent for the given type.
## Examples
iex> get_active_consent(user_id, "privacy_policy")
%UserConsent{}
iex> get_active_consent(user_id, "terms_of_service")
nil
"""
def get_active_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
UserConsent
|> where([c], c.user_id == ^user_id)
|> where([c], c.consent_type == ^consent_type)
|> where([c], is_nil(c.revoked_at))
|> order_by([c], desc: c.granted_at)
|> limit(1)
|> Repo.one()
end
@doc """
Checks if a user has granted consent for a specific type.
## Examples
iex> has_consent?(user_id, "privacy_policy")
true
iex> has_consent?(user_id, "terms_of_service")
false
"""
def has_consent?(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
get_active_consent(user_id, consent_type) != nil
end
@doc """
Checks if a user has granted all required consents.
## Examples
iex> has_all_required_consents?(user_id)
true
"""
def has_all_required_consents?(user_id) when is_binary(user_id) do
Enum.all?(UserConsent.consent_types(), fn consent_type ->
has_consent?(user_id, consent_type)
end)
end
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`.)
@doc """
Creates a new policy version.
## Examples
iex> create_policy_version(%{policy_type: "privacy_policy", version: "2.0", ...})
{:ok, %PolicyVersion{}}
iex> create_policy_version(%{policy_type: "invalid", ...})
{:error, %Ecto.Changeset{}}
"""
def create_policy_version(attrs) do
%PolicyVersion{}
|> PolicyVersion.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets the current (most recent) version of a policy.
## Examples
iex> get_current_policy_version("privacy_policy")
%PolicyVersion{}
iex> get_current_policy_version("terms_of_service")
nil
"""
def get_current_policy_version(policy_type) when is_binary(policy_type) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> where([p], p.effective_date <= ^DateTime.utc_now(:second))
|> order_by([p], desc: p.effective_date)
|> limit(1)
|> Repo.one()
end
@doc """
Gets a specific version of a policy.
## Examples
iex> get_policy_version("privacy_policy", "1.0")
%PolicyVersion{}
iex> get_policy_version("privacy_policy", "999.0")
nil
"""
def get_policy_version(policy_type, version) when is_binary(policy_type) and is_binary(version) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> where([p], p.version == ^version)
|> Repo.one()
end
@doc """
Lists all versions of a policy.
## Examples
iex> list_policy_versions("privacy_policy")
[%PolicyVersion{}, ...]
"""
def list_policy_versions(policy_type) when is_binary(policy_type) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> order_by([p], desc: p.effective_date)
|> Repo.all()
end
@doc """
Checks if a user needs to re-consent to a policy.
Returns true if the current policy version is newer than the user's consent.
## Examples
iex> needs_reconsent?(user_id, "privacy_policy")
false
iex> needs_reconsent?(user_id, "terms_of_service")
true
"""
def needs_reconsent?(user_id, policy_type) when is_binary(user_id) and is_binary(policy_type) do
current_policy = get_current_policy_version(policy_type)
user_consent = get_active_consent(user_id, policy_type)
cond do
is_nil(current_policy) -> false
is_nil(user_consent) -> true
user_consent.version != current_policy.version -> true
true -> false
end
end
@doc """
Checks if a user needs to re-consent to any policies.
Returns a list of policy types that require re-consent.
## Examples
iex> policies_needing_reconsent(user_id)
[]
iex> policies_needing_reconsent(user_id)
["privacy_policy", "terms_of_service"]
"""
def policies_needing_reconsent(user_id) when is_binary(user_id) do
Enum.filter(UserConsent.consent_types(), fn policy_type ->
needs_reconsent?(user_id, policy_type)
end)
end
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`.)
@doc """
Records a login attempt with GeoIP enrichment.
## Examples
iex> record_login_attempt(%{
...> user_id: "123",
...> email: "user@example.com",
...> success: true,
...> method: "password",
...> ip_address: "192.168.1.1"
...> })
{:ok, %LoginAttempt{}}
"""
def record_login_attempt(attrs) do
# Enrich with GeoIP data if IP address is present
attrs_with_location =
case Map.get(attrs, :ip_address) do
nil ->
attrs
ip_address ->
case GeoIP.lookup_full(ip_address) do
%{} = location ->
Map.merge(attrs, %{
country_code: location.country_code,
country_name: location.country_name,
city_name: location.city_name,
subdivision_1_name: location.subdivision_1_name
})
nil ->
attrs
end
end
%LoginAttempt{}
|> LoginAttempt.changeset(attrs_with_location)
|> Repo.insert()
end
@doc """
Lists login history for a user with pagination.
## Options
- `:limit` - Maximum number of records to return (default: 50)
- `:offset` - Number of records to skip (default: 0)
- `:success` - Filter by success status (true/false, optional)
## Examples
iex> list_user_login_history(user_id)
[%LoginAttempt{}, ...]
iex> list_user_login_history(user_id, limit: 20, success: false)
[%LoginAttempt{}, ...]
"""
def list_user_login_history(user_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
success_filter = Keyword.get(opts, :success)
query =
LoginAttempt
|> where([la], la.user_id == ^user_id)
|> order_by([la], desc: la.inserted_at)
|> limit(^limit)
|> offset(^offset)
query =
if is_boolean(success_filter) do
where(query, [la], la.success == ^success_filter)
else
query
end
Repo.all(query)
end
@doc """
Counts login attempts for a user.
## Options
- `:success` - Filter by success status (true/false, optional)
- `:since` - Only count attempts after this datetime (optional)
## Examples
iex> count_user_login_attempts(user_id)
42
iex> count_user_login_attempts(user_id, success: false, since: ~U[2026-01-29 00:00:00Z])
3
"""
def count_user_login_attempts(user_id, opts \\ []) do
success_filter = Keyword.get(opts, :success)
since = Keyword.get(opts, :since)
query = where(LoginAttempt, [la], la.user_id == ^user_id)
query =
if is_boolean(success_filter) do
where(query, [la], la.success == ^success_filter)
else
query
end
query =
if since do
where(query, [la], la.inserted_at >= ^since)
else
query
end
Repo.aggregate(query, :count, :id)
end
@doc """
Anonymizes all login attempts for a user (GDPR compliance).
Sets user_id to NULL and records anonymized_at timestamp.
IP addresses, locations, and other metadata are preserved for security analysis.
## Examples
iex> anonymize_user_login_history(user_id)
{5, nil}
"""
def anonymize_user_login_history(user_id) do
now = DateTime.utc_now()
LoginAttempt
|> where([la], la.user_id == ^user_id)
|> where([la], is_nil(la.anonymized_at))
|> Repo.update_all(set: [user_id: nil, anonymized_at: now])
end
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`.)
@ -1063,8 +444,6 @@ defmodule Towerops.Accounts do
UserNotifier.deliver_confirmation_email(user, confirmation_url_fun.(encoded_token))
{:error, changeset} ->
require Logger
Logger.error("Failed to insert confirmation token: #{inspect(changeset.errors)}")
{:error, changeset}
end

View file

@ -0,0 +1,155 @@
defmodule Towerops.Accounts.Consents do
@moduledoc """
User consent management: grant / revoke / list / query active consents.
Extracted from `Towerops.Accounts` to keep the context module focused. Operates
on `Towerops.Accounts.UserConsent`.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.UserConsent
alias Towerops.Repo
@doc """
Grants consent for a user.
## Examples
iex> grant_consent(user_id, "privacy_policy")
{:ok, %UserConsent{}}
iex> grant_consent(user_id, "invalid_type")
{:error, %Ecto.Changeset{}}
"""
def grant_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
version = UserConsent.current_version(consent_type)
granted_at = DateTime.utc_now(:second)
%UserConsent{}
|> UserConsent.grant_changeset(%{
user_id: user_id,
consent_type: consent_type,
version: version,
granted_at: granted_at
})
|> Repo.insert()
end
@doc """
Revokes consent for a user.
## Examples
iex> revoke_consent(consent_id)
{:ok, %UserConsent{}}
iex> revoke_consent("invalid")
{:error, :not_found}
"""
def revoke_consent(consent_id) when is_binary(consent_id) do
case Repo.get(UserConsent, consent_id) do
nil ->
{:error, :not_found}
consent ->
consent
|> UserConsent.revoke_changeset(%{revoked_at: DateTime.utc_now(:second)})
|> Repo.update()
end
end
@doc """
Lists all consents for a user.
## Examples
iex> list_user_consents(user_id)
[%UserConsent{}, ...]
"""
def list_user_consents(user_id) when is_binary(user_id) do
UserConsent
|> where([c], c.user_id == ^user_id)
|> order_by([c], desc: c.granted_at)
|> Repo.all()
end
@doc """
Gets the active consent for a user and consent type.
Returns the most recent non-revoked consent for the given type.
## Examples
iex> get_active_consent(user_id, "privacy_policy")
%UserConsent{}
iex> get_active_consent(user_id, "terms_of_service")
nil
"""
def get_active_consent(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
UserConsent
|> where([c], c.user_id == ^user_id)
|> where([c], c.consent_type == ^consent_type)
|> where([c], is_nil(c.revoked_at))
|> order_by([c], desc: c.granted_at)
|> limit(1)
|> Repo.one()
end
@doc """
Checks if a user has granted consent for a specific type.
## Examples
iex> has_consent?(user_id, "privacy_policy")
true
iex> has_consent?(user_id, "terms_of_service")
false
"""
def has_consent?(user_id, consent_type) when is_binary(user_id) and is_binary(consent_type) do
get_active_consent(user_id, consent_type) != nil
end
@doc """
Checks if a user has granted all required consents.
## Examples
iex> has_all_required_consents?(user_id)
true
"""
def has_all_required_consents?(user_id) when is_binary(user_id) do
Enum.all?(UserConsent.consent_types(), fn consent_type ->
has_consent?(user_id, consent_type)
end)
end
@doc """
Sequentially grants the privacy policy and terms of service consents based on
the registration attrs. Returns `:ok` or `{:error, changeset}`.
Used by `Towerops.Accounts.register_user/1` and
`Towerops.Accounts.register_user_with_organization/1`.
"""
@spec grant_consents_sequential(Towerops.Accounts.User.t(), map()) ::
:ok | {:error, Ecto.Changeset.t()}
def grant_consents_sequential(user, attrs) do
privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]
with :ok <- maybe_grant_consent(user, privacy_consent, "privacy_policy") do
maybe_grant_consent(user, terms_consent, "terms_of_service")
end
end
defp maybe_grant_consent(user, flag, consent_type) when flag in ["true", true, "on"] do
case grant_consent(user.id, consent_type) do
{:ok, _} -> :ok
{:error, _} = err -> err
end
end
defp maybe_grant_consent(_user, _flag, _consent_type), do: :ok
end

View file

@ -0,0 +1,93 @@
defmodule Towerops.Accounts.Emails do
@moduledoc """
User email management: change / update via token / deliver update instructions.
Extracted from `Towerops.Accounts` to keep the context module focused.
Operates on `Towerops.Accounts.User` and `Towerops.Accounts.UserToken`,
delegating shared transaction helpers back to `Towerops.Accounts`.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts
alias Towerops.Accounts.User
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Admin.AuditLogger
alias Towerops.Repo
require Logger
@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}"
old_email = user.email
result =
fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, updated_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, updated_user}
else
_ -> {:error, :transaction_aborted}
end
end
|> Repo.transaction()
|> Accounts.unwrap_transaction_result()
# Log email change for security monitoring
case result do
{:ok, updated_user} ->
_ = AuditLogger.log_email_changed(nil, user.id, old_email, updated_user.email)
result
{:error, _reason} ->
result
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}")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
{:error, changeset} ->
Logger.error("Failed to insert update email token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
end

View file

@ -0,0 +1,154 @@
defmodule Towerops.Accounts.LoginHistory do
@moduledoc """
Login attempt recording and history queries.
Extracted from `Towerops.Accounts` to keep the context module focused.
Operates on `Towerops.Accounts.LoginAttempt` and enriches records with GeoIP
data on insert.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.LoginAttempt
alias Towerops.GeoIP
alias Towerops.Repo
@doc """
Records a login attempt with GeoIP enrichment.
## Examples
iex> record_login_attempt(%{
...> user_id: "123",
...> email: "user@example.com",
...> success: true,
...> method: "password",
...> ip_address: "192.168.1.1"
...> })
{:ok, %LoginAttempt{}}
"""
def record_login_attempt(attrs) do
# Enrich with GeoIP data if IP address is present
attrs_with_location =
case Map.get(attrs, :ip_address) do
nil ->
attrs
ip_address ->
case GeoIP.lookup_full(ip_address) do
%{} = location ->
Map.merge(attrs, %{
country_code: location.country_code,
country_name: location.country_name,
city_name: location.city_name,
subdivision_1_name: location.subdivision_1_name
})
nil ->
attrs
end
end
%LoginAttempt{}
|> LoginAttempt.changeset(attrs_with_location)
|> Repo.insert()
end
@doc """
Lists login history for a user with pagination.
## Options
- `:limit` - Maximum number of records to return (default: 50)
- `:offset` - Number of records to skip (default: 0)
- `:success` - Filter by success status (true/false, optional)
## Examples
iex> list_user_login_history(user_id)
[%LoginAttempt{}, ...]
iex> list_user_login_history(user_id, limit: 20, success: false)
[%LoginAttempt{}, ...]
"""
def list_user_login_history(user_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
success_filter = Keyword.get(opts, :success)
query =
LoginAttempt
|> where([la], la.user_id == ^user_id)
|> order_by([la], desc: la.inserted_at)
|> limit(^limit)
|> offset(^offset)
query =
if is_boolean(success_filter) do
where(query, [la], la.success == ^success_filter)
else
query
end
Repo.all(query)
end
@doc """
Counts login attempts for a user.
## Options
- `:success` - Filter by success status (true/false, optional)
- `:since` - Only count attempts after this datetime (optional)
## Examples
iex> count_user_login_attempts(user_id)
42
iex> count_user_login_attempts(user_id, success: false, since: ~U[2026-01-29 00:00:00Z])
3
"""
def count_user_login_attempts(user_id, opts \\ []) do
success_filter = Keyword.get(opts, :success)
since = Keyword.get(opts, :since)
query = where(LoginAttempt, [la], la.user_id == ^user_id)
query =
if is_boolean(success_filter) do
where(query, [la], la.success == ^success_filter)
else
query
end
query =
if since do
where(query, [la], la.inserted_at >= ^since)
else
query
end
Repo.aggregate(query, :count, :id)
end
@doc """
Anonymizes all login attempts for a user (GDPR compliance).
Sets user_id to NULL and records anonymized_at timestamp.
IP addresses, locations, and other metadata are preserved for security analysis.
## Examples
iex> anonymize_user_login_history(user_id)
{5, nil}
"""
def anonymize_user_login_history(user_id) do
now = DateTime.utc_now()
LoginAttempt
|> where([la], la.user_id == ^user_id)
|> where([la], is_nil(la.anonymized_at))
|> Repo.update_all(set: [user_id: nil, anonymized_at: now])
end
end

View file

@ -0,0 +1,102 @@
defmodule Towerops.Accounts.MagicLinks do
@moduledoc """
Magic-link login flow: token lookup, login, and instruction email delivery.
Extracted from `Towerops.Accounts` to keep the context module focused.
Operates on `Towerops.Accounts.User` and `Towerops.Accounts.UserToken`,
delegating shared transaction helpers back to `Towerops.Accounts`.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts
alias Towerops.Accounts.User
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Repo
require Logger
@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
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
result when not is_nil(result) <- Repo.one(query) do
handle_magic_link_login(result)
else
nil -> {:error, :not_found}
:error -> {:error, :not_found}
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")
case Repo.insert(user_token) do
{:ok, _} ->
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
{:error, changeset} ->
Logger.error("Failed to insert login token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
defp handle_magic_link_login({%User{confirmed_at: nil, hashed_password: hash}, _token}) when not is_nil(hash) do
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`.
"""
end
defp handle_magic_link_login({%User{confirmed_at: nil} = user, _token}) do
user
|> User.confirm_changeset()
|> Accounts.update_user_and_delete_all_tokens()
end
defp handle_magic_link_login({user, token}) do
case Repo.delete(token) do
{:ok, _} ->
{:ok, {user, []}}
{:error, changeset} ->
Logger.error("Failed to delete magic link token: #{inspect(changeset)}")
{:ok, {user, []}}
end
end
end

View file

@ -0,0 +1,142 @@
defmodule Towerops.Accounts.Passwords do
@moduledoc """
Password management: change / update / reset, plus reset-instruction email
delivery.
Extracted from `Towerops.Accounts` to keep the context module focused.
Shared helpers (`update_user_and_delete_all_tokens/1`,
`unwrap_transaction_result/1`) live in `Towerops.Accounts` because they are
used by multiple sub-domains (passwords, magic links, ...).
"""
import Ecto.Query, warn: false
alias Towerops.Accounts
alias Towerops.Accounts.User
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Admin.AuditLogger
alias Towerops.Repo
require Logger
@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
result =
user
|> User.password_changeset(attrs)
|> Accounts.update_user_and_delete_all_tokens()
# Log password change for security monitoring
case result do
{:ok, _updated_user} ->
_ = AuditLogger.log_password_changed(nil, user.id)
result
{:error, _changeset} ->
result
end
end
@doc """
Delivers the reset password email to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/\#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
case Repo.insert(user_token) do
{:ok, _token} ->
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
{:error, changeset} ->
Logger.error("Failed to insert reset password token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
@doc """
Gets the user by reset password token.
## Examples
iex> get_user_by_reset_password_token("validtoken")
%User{}
iex> get_user_by_reset_password_token("invalidtoken")
nil
"""
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_reset_password_token_query(token),
%User{} = user <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Resets the user password.
## Examples
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
{:ok, %User{}}
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
{:error, %Ecto.Changeset{}}
"""
def reset_user_password(user, attrs) do
fn ->
changeset = User.password_changeset(user, attrs)
case Accounts.update_user_and_delete_all_tokens(changeset) do
{:ok, {updated_user, expired_tokens}} ->
# Delete all reset_password tokens after successful password update
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id and t.context == "reset_password"))
{:ok, {updated_user, expired_tokens}}
{:error, changeset} ->
{:error, changeset}
end
end
|> Repo.transaction()
|> Accounts.unwrap_transaction_result()
end
end

View file

@ -0,0 +1,131 @@
defmodule Towerops.Accounts.PolicyVersions do
@moduledoc """
Policy version management: create / lookup / list versions and detect when a
user needs to re-consent.
Extracted from `Towerops.Accounts` to keep the context module focused.
Operates on `Towerops.Accounts.PolicyVersion` and delegates to
`Towerops.Accounts.Consents` for the user-side consent lookup.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.Consents
alias Towerops.Accounts.PolicyVersion
alias Towerops.Accounts.UserConsent
alias Towerops.Repo
@doc """
Creates a new policy version.
## Examples
iex> create_policy_version(%{policy_type: "privacy_policy", version: "2.0", ...})
{:ok, %PolicyVersion{}}
iex> create_policy_version(%{policy_type: "invalid", ...})
{:error, %Ecto.Changeset{}}
"""
def create_policy_version(attrs) do
%PolicyVersion{}
|> PolicyVersion.changeset(attrs)
|> Repo.insert()
end
@doc """
Gets the current (most recent) version of a policy.
## Examples
iex> get_current_policy_version("privacy_policy")
%PolicyVersion{}
iex> get_current_policy_version("terms_of_service")
nil
"""
def get_current_policy_version(policy_type) when is_binary(policy_type) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> where([p], p.effective_date <= ^DateTime.utc_now(:second))
|> order_by([p], desc: p.effective_date)
|> limit(1)
|> Repo.one()
end
@doc """
Gets a specific version of a policy.
## Examples
iex> get_policy_version("privacy_policy", "1.0")
%PolicyVersion{}
iex> get_policy_version("privacy_policy", "999.0")
nil
"""
def get_policy_version(policy_type, version) when is_binary(policy_type) and is_binary(version) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> where([p], p.version == ^version)
|> Repo.one()
end
@doc """
Lists all versions of a policy.
## Examples
iex> list_policy_versions("privacy_policy")
[%PolicyVersion{}, ...]
"""
def list_policy_versions(policy_type) when is_binary(policy_type) do
PolicyVersion
|> where([p], p.policy_type == ^policy_type)
|> order_by([p], desc: p.effective_date)
|> Repo.all()
end
@doc """
Checks if a user needs to re-consent to a policy.
Returns true if the current policy version is newer than the user's consent.
## Examples
iex> needs_reconsent?(user_id, "privacy_policy")
false
iex> needs_reconsent?(user_id, "terms_of_service")
true
"""
def needs_reconsent?(user_id, policy_type) when is_binary(user_id) and is_binary(policy_type) do
current_policy = get_current_policy_version(policy_type)
user_consent = Consents.get_active_consent(user_id, policy_type)
cond do
is_nil(current_policy) -> false
is_nil(user_consent) -> true
user_consent.version != current_policy.version -> true
true -> false
end
end
@doc """
Checks if a user needs to re-consent to any policies.
Returns a list of policy types that require re-consent.
## Examples
iex> policies_needing_reconsent(user_id)
[]
iex> policies_needing_reconsent(user_id)
["privacy_policy", "terms_of_service"]
"""
def policies_needing_reconsent(user_id) when is_binary(user_id) do
Enum.filter(UserConsent.consent_types(), fn policy_type ->
needs_reconsent?(user_id, policy_type)
end)
end
end