- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior - Fix dynamic_extra_test vendor post-processing tests to seed sensor data - Fix activity_controller_test to seed devices for feed data - Fix session_manager_test to create browser session for test - Fix topology_test link creation for connection test - Fix device_monitor/driver_worker tests for unique job constraints - Fix accounts_test expired_tokens assertion (magic link token is expired) - Fix happy_path_test and show_events_test to seed monitor data - Fix admin user_live_test user.name -> user.email (no name field) - Fix schema_test to seed activity data - Fix mobile_qr_live_test to match actual template text - Fix SnmpKit.MIB doctests and tests for enriched return values - Fix onboarding_live, mobile_controller, mib_test weak assertions - Remove dead code and fix credo warnings
521 lines
17 KiB
Elixir
521 lines
17 KiB
Elixir
defmodule Towerops.Accounts do
|
|
@moduledoc """
|
|
The Accounts context.
|
|
"""
|
|
|
|
import Ecto.Query, warn: false
|
|
|
|
alias Towerops.Accounts.Consents
|
|
alias Towerops.Accounts.Emails
|
|
alias Towerops.Accounts.LoginHistory
|
|
alias Towerops.Accounts.MagicLinks
|
|
alias Towerops.Accounts.Passwords
|
|
alias Towerops.Accounts.PolicyVersions
|
|
alias Towerops.Accounts.Sessions
|
|
alias Towerops.Accounts.TOTP
|
|
alias Towerops.Accounts.User
|
|
alias Towerops.Accounts.UserNotifier
|
|
alias Towerops.Accounts.UserToken
|
|
alias Towerops.Admin.AuditLogger
|
|
alias Towerops.Organizations.Membership
|
|
alias Towerops.Organizations.Organization
|
|
alias Towerops.Repo
|
|
|
|
require Logger
|
|
|
|
## Database getters
|
|
|
|
@doc """
|
|
Gets a user by email.
|
|
|
|
## Examples
|
|
|
|
iex> Accounts.get_user_by_email("unknown@example.com")
|
|
nil
|
|
|
|
"""
|
|
@spec get_user_by_email(String.t()) :: User.t() | nil
|
|
def get_user_by_email(email) when is_binary(email) do
|
|
# Case-insensitive match so `User@Example.com` and `user@example.com`
|
|
# resolve to the same account — prevents lookalike registrations and
|
|
# password-reset confusion.
|
|
Repo.one(from u in User, where: fragment("lower(?) = lower(?)", u.email, ^email))
|
|
end
|
|
|
|
@doc """
|
|
Gets a user by email and password.
|
|
|
|
## Examples
|
|
|
|
iex> Accounts.get_user_by_email_and_password("foo@example.com", "correct_password")
|
|
nil
|
|
|
|
iex> Accounts.get_user_by_email_and_password("foo@example.com", "invalid_password")
|
|
nil
|
|
|
|
"""
|
|
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
|
|
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
|
|
user = Repo.get_by(User, email: email)
|
|
if User.valid_password?(user, password), do: user
|
|
end
|
|
|
|
@doc """
|
|
Gets a single user.
|
|
|
|
Returns `nil` if the User does not exist.
|
|
|
|
## Examples
|
|
|
|
iex> Accounts.get_user("00000000-0000-0000-0000-000000000000")
|
|
nil
|
|
|
|
"""
|
|
@spec get_user(String.t()) :: User.t() | nil
|
|
def get_user(id) when is_binary(id) do
|
|
Repo.get(User, id)
|
|
end
|
|
|
|
@doc """
|
|
Gets a single user.
|
|
|
|
Raises `Ecto.NoResultsError` if the User does not exist.
|
|
"""
|
|
@spec get_user!(String.t()) :: User.t()
|
|
def get_user!(id), do: Repo.get!(User, id)
|
|
|
|
## User registration
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
|
|
"""
|
|
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
|
|
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
|
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
|
|
end
|
|
|
|
@doc """
|
|
Registers a user with email and password.
|
|
"""
|
|
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def register_user(attrs) do
|
|
user_changeset = User.registration_changeset(%User{}, attrs)
|
|
|
|
Repo.transaction(fn ->
|
|
with {:ok, user} <- Repo.insert(user_changeset),
|
|
:ok <- Consents.grant_consents_sequential(user, attrs) do
|
|
user
|
|
else
|
|
{:error, changeset} -> Repo.rollback(changeset)
|
|
end
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Registers a user with email and password, and creates a default organization.
|
|
"""
|
|
@spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
def register_user_with_organization(attrs) do
|
|
user_changeset = User.registration_changeset(%User{}, attrs)
|
|
|
|
Repo.transaction(fn ->
|
|
with {:ok, user} <- Repo.insert(user_changeset),
|
|
org_name = attrs["organization_name"] || "Personal",
|
|
{:ok, _org} <- Towerops.Organizations.create_organization(%{name: org_name}, user.id),
|
|
:ok <- Consents.grant_consents_sequential(user, attrs) do
|
|
user
|
|
else
|
|
{:error, %Ecto.Changeset{} = changeset} -> Repo.rollback(changeset)
|
|
{:error, reason} -> Repo.rollback(reason)
|
|
end
|
|
end)
|
|
end
|
|
|
|
## TOTP / Two-Factor Authentication
|
|
## (Implementation lives in `Towerops.Accounts.TOTP`.)
|
|
|
|
defdelegate generate_totp_secret(), to: TOTP, as: :generate_secret
|
|
defdelegate generate_totp_uri(user, secret), to: TOTP, as: :generate_uri
|
|
defdelegate generate_totp_qr_code(user, secret), to: TOTP, as: :generate_qr_code
|
|
defdelegate verify_totp(secret, code), to: TOTP, as: :verify
|
|
defdelegate enable_totp(user, secret), to: TOTP, as: :enable
|
|
defdelegate totp_enabled?(user), to: TOTP, as: :enabled?
|
|
defdelegate verify_user_totp(user, code), to: TOTP, as: :verify_user
|
|
defdelegate list_user_totp_devices(user_id), to: TOTP, as: :list_user_devices
|
|
defdelegate count_user_totp_devices(user_id), to: TOTP, as: :count_user_devices
|
|
defdelegate create_totp_device(user_id, name), to: TOTP, as: :create_device
|
|
defdelegate create_totp_device(user_id, name, secret), to: TOTP, as: :create_device
|
|
defdelegate verify_new_totp_device(device_id, user_id), to: TOTP, as: :verify_new_device
|
|
defdelegate verify_user_totp_any_device(user, code), to: TOTP, as: :verify_user_any_device
|
|
defdelegate delete_totp_device(device_id, user_id), to: TOTP, as: :delete_device
|
|
defdelegate rename_totp_device(device_id, user_id, new_name), to: TOTP, as: :rename_device
|
|
defdelegate generate_recovery_codes(user_id), to: TOTP
|
|
defdelegate verify_recovery_code(user_id, code), to: TOTP
|
|
defdelegate count_unused_recovery_codes(user_id), to: TOTP
|
|
defdelegate list_user_recovery_codes(user_id), to: TOTP
|
|
defdelegate verify_user_mfa(user, code), to: TOTP
|
|
defdelegate verify_totp_only(user, code), to: TOTP
|
|
|
|
## Settings
|
|
|
|
@doc """
|
|
Returns an `%Ecto.Changeset{}` for changing the user profile.
|
|
"""
|
|
def change_user_profile(user, attrs \\ %{}) do
|
|
User.profile_changeset(user, attrs)
|
|
end
|
|
|
|
@doc """
|
|
Updates the user profile.
|
|
"""
|
|
def update_user_profile(user, attrs) do
|
|
user
|
|
|> User.profile_changeset(attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Checks whether the user is in sudo mode.
|
|
|
|
The user is in sudo mode when the last sudo verification was done no further
|
|
than the specified minutes ago. Default is 10 minutes (pass -10 as the minutes arg).
|
|
"""
|
|
def sudo_mode?(user, minutes \\ -10)
|
|
|
|
def sudo_mode?(%User{last_sudo_at: ts}, minutes) when is_struct(ts, DateTime) do
|
|
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
|
|
end
|
|
|
|
def sudo_mode?(_user, _minutes), do: false
|
|
|
|
@doc """
|
|
Grants sudo mode to a user by updating their last_sudo_at timestamp.
|
|
|
|
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
|
|
"""
|
|
def grant_sudo_mode(%User{} = user) do
|
|
now = Towerops.Time.now()
|
|
|
|
with {:ok, updated_user} <-
|
|
user
|
|
|> User.sudo_changeset(%{last_sudo_at: now})
|
|
|> Repo.update() do
|
|
# Log sudo mode grant for security auditing
|
|
AuditLogger.log_event("sudo_mode_granted", nil,
|
|
actor_id: user.id,
|
|
target_user_id: user.id,
|
|
metadata: %{granted_at: now}
|
|
)
|
|
|
|
# Set authenticated_at virtual field for sudo_mode? check
|
|
{:ok, %{updated_user | authenticated_at: now}}
|
|
end
|
|
end
|
|
|
|
## Email management
|
|
## (Implementation lives in `Towerops.Accounts.Emails`.)
|
|
|
|
defdelegate change_user_email(user, attrs \\ %{}, opts \\ []), to: Emails
|
|
defdelegate update_user_email(user, token), to: Emails
|
|
defdelegate deliver_user_update_email_instructions(user, current_email, update_email_url_fun), to: Emails
|
|
|
|
## Password management
|
|
## (Implementation lives in `Towerops.Accounts.Passwords`.)
|
|
|
|
defdelegate change_user_password(user, attrs \\ %{}, opts \\ []), to: Passwords
|
|
defdelegate update_user_password(user, attrs), to: Passwords
|
|
defdelegate deliver_user_reset_password_instructions(user, reset_password_url_fun), to: Passwords
|
|
defdelegate get_user_by_reset_password_token(token), to: Passwords
|
|
defdelegate reset_user_password(user, attrs), to: Passwords
|
|
|
|
## Session
|
|
|
|
@doc """
|
|
Generates a session token.
|
|
"""
|
|
def generate_user_session_token(user) do
|
|
{token, user_token} = UserToken.build_session_token(user)
|
|
|
|
case Repo.insert(user_token) do
|
|
{:ok, _} ->
|
|
token
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to insert session token: #{inspect(changeset.errors)}")
|
|
raise Ecto.InvalidChangesetError, changeset: changeset
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Generates a session token and returns both the token and the user_token record.
|
|
|
|
This is used when creating browser sessions to link them to the token ID.
|
|
"""
|
|
def generate_user_session_token_with_record(user) do
|
|
{token, user_token_changeset} = UserToken.build_session_token(user)
|
|
|
|
case Repo.insert(user_token_changeset) do
|
|
{:ok, user_token} ->
|
|
{token, user_token}
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to insert session token with record: #{inspect(changeset.errors)}")
|
|
raise Ecto.InvalidChangesetError, changeset: changeset
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets the user with the given signed token.
|
|
|
|
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
|
|
"""
|
|
def get_user_by_session_token(token) do
|
|
{:ok, query} = UserToken.verify_session_token_query(token)
|
|
Repo.one(query)
|
|
end
|
|
|
|
@doc """
|
|
Gets the user token ID for a given session token value.
|
|
|
|
Returns the token ID if found, otherwise `nil`.
|
|
"""
|
|
def get_user_token_id_by_value(token) when is_binary(token) do
|
|
hashed_token = :crypto.hash(:sha256, token)
|
|
|
|
Repo.one(from(ut in UserToken, where: ut.token == ^hashed_token and ut.context == "session", select: ut.id))
|
|
end
|
|
|
|
## Magic-link login
|
|
## (Implementation lives in `Towerops.Accounts.MagicLinks`.)
|
|
|
|
defdelegate get_user_by_magic_link_token(token), to: MagicLinks
|
|
defdelegate login_user_by_magic_link(token), to: MagicLinks
|
|
defdelegate deliver_login_instructions(user, magic_link_url_fun), to: MagicLinks
|
|
|
|
@doc """
|
|
Deletes the signed token with the given context.
|
|
"""
|
|
def delete_user_session_token(token) do
|
|
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
|
|
:ok
|
|
end
|
|
|
|
## Token helpers (shared)
|
|
##
|
|
## These are public-but-internal: they are used by extracted submodules
|
|
## (`Passwords`, `MagicLinks`, `Emails`) and must therefore be callable from
|
|
## outside this module. They are not part of the public API.
|
|
|
|
@doc false
|
|
@spec unwrap_transaction_result({:ok, {:ok, term()} | {:error, term()}} | {:error, term()}) ::
|
|
{:ok, term()} | {:error, term()}
|
|
def unwrap_transaction_result(result) do
|
|
case result do
|
|
{:ok, {:ok, value}} -> {:ok, value}
|
|
{:ok, {:error, reason}} -> {:error, reason}
|
|
{:error, reason} -> {:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def update_user_and_delete_all_tokens(changeset) do
|
|
changeset
|
|
|> do_update_user_and_delete_tokens()
|
|
|> Repo.transaction()
|
|
|> unwrap_transaction_result()
|
|
end
|
|
|
|
defp do_update_user_and_delete_tokens(changeset) do
|
|
fn ->
|
|
case Repo.update(changeset) do
|
|
{:ok, user} ->
|
|
tokens_to_expire = Repo.all(from t in UserToken, where: t.user_id == ^user.id)
|
|
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
|
{:ok, {user, tokens_to_expire}}
|
|
|
|
{:error, changeset} ->
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
end
|
|
|
|
## User Consent
|
|
## (Implementation lives in `Towerops.Accounts.Consents`.)
|
|
|
|
defdelegate grant_consent(user_id, consent_type), to: Consents
|
|
defdelegate revoke_consent(consent_id), to: Consents
|
|
defdelegate list_user_consents(user_id), to: Consents
|
|
defdelegate get_active_consent(user_id, consent_type), to: Consents
|
|
defdelegate has_consent?(user_id, consent_type), to: Consents
|
|
defdelegate has_all_required_consents?(user_id), to: Consents
|
|
|
|
## Policy Version Management
|
|
## (Implementation lives in `Towerops.Accounts.PolicyVersions`.)
|
|
|
|
defdelegate create_policy_version(attrs), to: PolicyVersions
|
|
defdelegate get_current_policy_version(policy_type), to: PolicyVersions
|
|
defdelegate get_policy_version(policy_type, version), to: PolicyVersions
|
|
defdelegate list_policy_versions(policy_type), to: PolicyVersions
|
|
defdelegate needs_reconsent?(user_id, policy_type), to: PolicyVersions
|
|
defdelegate policies_needing_reconsent(user_id), to: PolicyVersions
|
|
|
|
## Login Attempts
|
|
## (Implementation lives in `Towerops.Accounts.LoginHistory`.)
|
|
|
|
defdelegate record_login_attempt(attrs), to: LoginHistory
|
|
defdelegate list_user_login_history(user_id, opts \\ []), to: LoginHistory
|
|
defdelegate count_user_login_attempts(user_id, opts \\ []), to: LoginHistory
|
|
defdelegate anonymize_user_login_history(user_id), to: LoginHistory
|
|
|
|
## Browser Sessions
|
|
## (Implementation lives in `Towerops.Accounts.Sessions`.)
|
|
|
|
defdelegate create_browser_session(attrs), to: Sessions, as: :create
|
|
defdelegate list_active_browser_sessions(user_id), to: Sessions, as: :list_active
|
|
defdelegate get_browser_session_by_token(user_token_id), to: Sessions, as: :get_by_token
|
|
defdelegate get_browser_session_by_token_value(token), to: Sessions, as: :get_by_token_value
|
|
defdelegate touch_browser_session(session), to: Sessions, as: :touch
|
|
defdelegate revoke_browser_session(session_id, user_id, current_token_id), to: Sessions, as: :revoke
|
|
defdelegate revoke_all_other_sessions(user_id, current_token_id), to: Sessions, as: :revoke_all_other
|
|
defdelegate anonymize_user_browser_sessions(user_id), to: Sessions, as: :anonymize_for_user
|
|
defdelegate delete_expired_browser_sessions(), to: Sessions, as: :delete_expired
|
|
|
|
@doc """
|
|
Delivers confirmation instructions to the given user.
|
|
"""
|
|
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) do
|
|
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
|
|
|
case Repo.insert(user_token) do
|
|
{:ok, _} ->
|
|
UserNotifier.deliver_confirmation_email(user, confirmation_url_fun.(encoded_token))
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to insert confirmation token: #{inspect(changeset.errors)}")
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Confirms a user by the given token.
|
|
"""
|
|
@spec confirm_user(String.t()) :: {:ok, User.t()} | :error
|
|
def confirm_user(token) do
|
|
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
|
|
%User{} = user <- Repo.one(query),
|
|
{:ok, confirmed_user} <- confirm_user_transaction(user) do
|
|
{:ok, confirmed_user}
|
|
else
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
@spec confirm_user_transaction(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
|
|
defp confirm_user_transaction(user) do
|
|
Repo.transaction(fn ->
|
|
case Repo.update(User.confirm_changeset(user)) do
|
|
{:ok, confirmed_user} ->
|
|
_ = Repo.delete_all(UserToken.by_user_and_contexts_query(user, ["confirm"]))
|
|
confirmed_user
|
|
|
|
{:error, changeset} ->
|
|
Repo.rollback(changeset)
|
|
end
|
|
end)
|
|
end
|
|
|
|
## Account Deletion (GDPR Right to Erasure)
|
|
|
|
@doc """
|
|
Returns organizations where the user is the sole owner.
|
|
|
|
Used to check if a user can safely delete their account without
|
|
orphaning organizations.
|
|
"""
|
|
@spec sole_owner_organizations(String.t()) :: [Organization.t()]
|
|
def sole_owner_organizations(user_id) do
|
|
user_owner_org_ids =
|
|
Membership
|
|
|> where([m], m.user_id == ^user_id and m.role == :owner)
|
|
|> select([m], m.organization_id)
|
|
|> Repo.all()
|
|
|
|
if user_owner_org_ids == [] do
|
|
[]
|
|
else
|
|
sole_owner_ids =
|
|
Membership
|
|
|> where([m], m.organization_id in ^user_owner_org_ids and m.role == :owner)
|
|
|> group_by([m], m.organization_id)
|
|
|> having([m], count(m.id) == 1)
|
|
|> select([m], m.organization_id)
|
|
|> Repo.all()
|
|
|
|
Organization
|
|
|> where([o], o.id in ^sole_owner_ids)
|
|
|> Repo.all()
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Deletes a user's account (self-service, GDPR Article 17).
|
|
|
|
Verifies the user's password before proceeding. Checks for sole-owner
|
|
organizations and blocks deletion if found.
|
|
|
|
Within a transaction:
|
|
1. Creates an audit log entry
|
|
2. Anonymizes login history
|
|
3. Anonymizes browser sessions
|
|
4. Deletes the user (cascades to memberships, tokens)
|
|
|
|
Returns `{:ok, user}` on success, or an error tuple.
|
|
"""
|
|
@spec delete_account(User.t(), String.t()) ::
|
|
{:ok, User.t()}
|
|
| {:error, :invalid_password}
|
|
| {:error, :sole_owner, [String.t()]}
|
|
def delete_account(%User{} = user, password) when is_binary(password) do
|
|
if User.valid_password?(user, password) do
|
|
do_delete_account(user)
|
|
else
|
|
{:error, :invalid_password}
|
|
end
|
|
end
|
|
|
|
defp do_delete_account(user) do
|
|
sole_owner_orgs = sole_owner_organizations(user.id)
|
|
|
|
if sole_owner_orgs == [] do
|
|
delete_account_transaction(user)
|
|
else
|
|
org_names = Enum.map(sole_owner_orgs, & &1.name)
|
|
{:error, :sole_owner, org_names}
|
|
end
|
|
end
|
|
|
|
defp delete_account_transaction(user) do
|
|
Repo.transaction(fn ->
|
|
{:ok, _} =
|
|
Towerops.Admin.create_audit_log(%{
|
|
action: "account_deletion_requested",
|
|
superuser_id: user.id,
|
|
target_user_id: user.id,
|
|
metadata: %{email: user.email, self_service: true}
|
|
})
|
|
|
|
anonymize_user_login_history(user.id)
|
|
anonymize_user_browser_sessions(user.id)
|
|
|
|
case Repo.delete(user) do
|
|
{:ok, deleted_user} ->
|
|
deleted_user
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to delete user account: #{inspect(changeset)}")
|
|
# Raise to trigger rollback - never call Repo.rollback/1 directly
|
|
raise Ecto.InvalidChangesetError, action: :delete, changeset: changeset
|
|
end
|
|
end)
|
|
end
|
|
end
|