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:
parent
247bbe58da
commit
a680d5ea94
7 changed files with 832 additions and 676 deletions
|
|
@ -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
|
||||
|
|
|
|||
155
lib/towerops/accounts/consents.ex
Normal file
155
lib/towerops/accounts/consents.ex
Normal 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
|
||||
93
lib/towerops/accounts/emails.ex
Normal file
93
lib/towerops/accounts/emails.ex
Normal 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
|
||||
154
lib/towerops/accounts/login_history.ex
Normal file
154
lib/towerops/accounts/login_history.ex
Normal 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
|
||||
102
lib/towerops/accounts/magic_links.ex
Normal file
102
lib/towerops/accounts/magic_links.ex
Normal 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
|
||||
142
lib/towerops/accounts/passwords.ex
Normal file
142
lib/towerops/accounts/passwords.ex
Normal 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
|
||||
131
lib/towerops/accounts/policy_versions.ex
Normal file
131
lib/towerops/accounts/policy_versions.ex
Normal 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
|
||||
Loading…
Add table
Reference in a new issue