From a680d5ea944fbdeca757481c84f033fe0a79e629 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 30 Apr 2026 14:01:31 -0500 Subject: [PATCH] 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. --- lib/towerops/accounts.ex | 731 ++--------------------- lib/towerops/accounts/consents.ex | 155 +++++ lib/towerops/accounts/emails.ex | 93 +++ lib/towerops/accounts/login_history.ex | 154 +++++ lib/towerops/accounts/magic_links.ex | 102 ++++ lib/towerops/accounts/passwords.ex | 142 +++++ lib/towerops/accounts/policy_versions.ex | 131 ++++ 7 files changed, 832 insertions(+), 676 deletions(-) create mode 100644 lib/towerops/accounts/consents.ex create mode 100644 lib/towerops/accounts/emails.ex create mode 100644 lib/towerops/accounts/login_history.ex create mode 100644 lib/towerops/accounts/magic_links.ex create mode 100644 lib/towerops/accounts/passwords.ex create mode 100644 lib/towerops/accounts/policy_versions.ex diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index f77b838c..a02edab4 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -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 diff --git a/lib/towerops/accounts/consents.ex b/lib/towerops/accounts/consents.ex new file mode 100644 index 00000000..19679839 --- /dev/null +++ b/lib/towerops/accounts/consents.ex @@ -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 diff --git a/lib/towerops/accounts/emails.ex b/lib/towerops/accounts/emails.ex new file mode 100644 index 00000000..2def5931 --- /dev/null +++ b/lib/towerops/accounts/emails.ex @@ -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 diff --git a/lib/towerops/accounts/login_history.ex b/lib/towerops/accounts/login_history.ex new file mode 100644 index 00000000..c9808572 --- /dev/null +++ b/lib/towerops/accounts/login_history.ex @@ -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 diff --git a/lib/towerops/accounts/magic_links.ex b/lib/towerops/accounts/magic_links.ex new file mode 100644 index 00000000..7676d9c2 --- /dev/null +++ b/lib/towerops/accounts/magic_links.ex @@ -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 diff --git a/lib/towerops/accounts/passwords.ex b/lib/towerops/accounts/passwords.ex new file mode 100644 index 00000000..b13aad4a --- /dev/null +++ b/lib/towerops/accounts/passwords.ex @@ -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 diff --git a/lib/towerops/accounts/policy_versions.ex b/lib/towerops/accounts/policy_versions.ex new file mode 100644 index 00000000..0c196f62 --- /dev/null +++ b/lib/towerops/accounts/policy_versions.ex @@ -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