towerops/lib/towerops/accounts.ex

1850 lines
49 KiB
Elixir

defmodule Towerops.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Towerops.Accounts.BrowserSession
alias Towerops.Accounts.LoginAttempt
alias Towerops.Accounts.PolicyVersion
alias Towerops.Accounts.User
alias Towerops.Accounts.UserConsent
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserRecoveryCode
alias Towerops.Accounts.UserToken
alias Towerops.Accounts.UserTotpDevice
alias Towerops.Admin.AuditLogger
alias Towerops.GeoIP
alias Towerops.Repo
require Logger
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Returns `nil` if the User does not exist.
## Examples
iex> get_user("123")
%User{}
iex> get_user("456")
nil
"""
@spec get_user(String.t()) :: User.t() | nil
def get_user(id) when is_binary(id) do
Repo.get(User, id)
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
@spec get_user!(String.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
## User registration
@doc """
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
## Examples
iex> change_user_registration(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
end
@doc """
Registers a user with email and password.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
user_changeset = User.registration_changeset(%User{}, attrs)
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)
# Grant consents if provided
multi = grant_consents_on_registration(multi, attrs)
case Repo.transaction(multi) do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
{:error, _name, _changeset, _} -> {:error, user_changeset}
end
end
@doc """
Registers a user with email and password, and creates a default organization.
## Examples
iex> register_user_with_organization(%{field: value})
{:ok, %User{}}
iex> register_user_with_organization(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
@dialyzer {:nowarn_function, register_user_with_organization: 1}
@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)
multi = Ecto.Multi.new()
multi = Ecto.Multi.insert(multi, :user, user_changeset)
multi =
Ecto.Multi.run(multi, :organization, fn _repo, %{user: user} ->
org_name = attrs["organization_name"] || "Personal"
Towerops.Organizations.create_organization(
%{name: org_name},
user.id
)
end)
# Grant consents if provided
multi = grant_consents_on_registration(multi, attrs)
case Repo.transaction(multi) do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
{:error, :organization, changeset, _} -> {:error, changeset}
{:error, _name, _changeset, _} -> {:error, user_changeset}
end
end
defp grant_consents_on_registration(multi, attrs) do
# Only grant consents if checkboxes were checked
privacy_consent = attrs["privacy_policy_consent"] || attrs[:privacy_policy_consent]
terms_consent = attrs["terms_of_service_consent"] || attrs[:terms_of_service_consent]
multi =
if privacy_consent in ["true", true, "on"] do
Ecto.Multi.run(multi, :privacy_policy_consent, fn _repo, %{user: user} ->
grant_consent(user.id, "privacy_policy")
end)
else
multi
end
if terms_consent in ["true", true, "on"] do
Ecto.Multi.run(multi, :terms_of_service_consent, fn _repo, %{user: user} ->
grant_consent(user.id, "terms_of_service")
end)
else
multi
end
end
## TOTP / Two-Factor Authentication
@doc """
Generates a new TOTP secret for a user.
Returns a binary secret that should be stored temporarily until verified.
"""
def generate_totp_secret do
NimbleTOTP.secret()
end
@doc """
Generates an otpauth URI for displaying as a QR code.
## Examples
iex> generate_totp_uri(user, secret)
"otpauth://totp/Towerops:user@example.com?secret=SECRET&issuer=Towerops"
"""
def generate_totp_uri(%User{} = user, secret) do
NimbleTOTP.otpauth_uri("Towerops:#{user.email}", secret, issuer: "Towerops")
end
@doc """
Generates a QR code as a data URI for the TOTP URI.
Returns a base64-encoded PNG image as a data URI that can be displayed in an img tag.
"""
def generate_totp_qr_code(%User{} = user, secret) do
uri = generate_totp_uri(user, secret)
uri
|> EQRCode.encode()
|> EQRCode.png()
|> Base.encode64()
|> then(&"data:image/png;base64,#{&1}")
end
@doc """
Verifies a TOTP code against a secret.
Returns true if the code is valid within the time window, false otherwise.
Allows for +/- 4 time steps (120 seconds / 2 minutes) to handle clock drift.
"""
def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do
current_time = System.system_time(:second)
# NimbleTOTP expects binary bytes, not base32 strings
# Decode the base32 secret first
secret_bytes =
case Base.decode32(secret, case: :mixed, padding: false) do
{:ok, bytes} -> bytes
# Fallback: treat as raw bytes if not valid base32
:error -> secret
end
# Check multiple time windows to handle clock drift
# Check ±4 time steps (±120 seconds / 2 minutes)
time_windows = [
current_time,
current_time - 30,
current_time + 30,
current_time - 60,
current_time + 60,
current_time - 90,
current_time + 90,
current_time - 120,
current_time + 120
]
result =
Enum.any?(time_windows, fn time ->
NimbleTOTP.valid?(secret_bytes, code, time: time)
end)
if !result do
require Logger
# Log detailed debugging info when TOTP fails
expected_code_current = NimbleTOTP.verification_code(secret_bytes, time: current_time)
expected_code_prev = NimbleTOTP.verification_code(secret_bytes, time: current_time - 30)
expected_code_next = NimbleTOTP.verification_code(secret_bytes, time: current_time + 30)
current_datetime = DateTime.from_unix!(current_time)
Logger.info(
"TOTP verification failed: " <>
"provided=#{code} (len=#{String.length(code)}), " <>
"expected_current=#{expected_code_current}, " <>
"expected_prev=#{expected_code_prev} (-30s), " <>
"expected_next=#{expected_code_next} (+30s), " <>
"server_time=#{current_datetime} (unix=#{current_time}), " <>
"secret_len=#{String.length(secret)}"
)
end
result
end
@doc """
Enables TOTP for a user by saving the secret.
The secret should have been verified before calling this function.
"""
def enable_totp(%User{} = user, secret) when is_binary(secret) do
user
|> Ecto.Changeset.change(totp_secret: secret)
|> Repo.update()
end
@doc """
Checks if a user has TOTP enabled.
Checks both the new multi-device system (user_totp_devices table)
and the legacy totp_secret field for backward compatibility.
"""
def totp_enabled?(%User{id: user_id, totp_secret: secret}) do
# Check new multi-device system first
device_count = count_user_totp_devices(user_id)
cond do
device_count > 0 -> true
is_binary(secret) -> true
true -> false
end
end
def totp_enabled?(_user), do: false
@doc """
Verifies a TOTP code for a user who has TOTP enabled.
Now checks ALL user devices (multi-device support), not just single secret.
Falls back to legacy single secret for backward compatibility.
Returns {:ok, user} if valid, {:error, :invalid_code} otherwise.
"""
def verify_user_totp(%User{} = user, code) when is_binary(code) do
# First try new multi-device approach
case verify_user_totp_any_device(user, code) do
{:ok, _device} ->
{:ok, user}
{:error, :invalid_code} ->
# Fallback to legacy single secret (backward compatibility during migration)
verify_legacy_totp(user, code)
end
end
defp verify_legacy_totp(%User{totp_secret: secret} = user, code) when is_binary(secret) do
if verify_totp(secret, code) do
{:ok, user}
else
{:error, :invalid_code}
end
end
defp verify_legacy_totp(%User{}, _code) do
{:error, :totp_not_enabled}
end
## TOTP Device Management
@doc """
Lists all TOTP devices for a user, ordered by most recently used.
"""
def list_user_totp_devices(user_id) do
UserTotpDevice
|> where([d], d.user_id == ^user_id)
|> order_by([d], desc_nulls_last: d.last_used_at, desc: d.inserted_at)
|> Repo.all()
end
@doc """
Counts TOTP devices for a user.
"""
def count_user_totp_devices(user_id) do
UserTotpDevice
|> where([d], d.user_id == ^user_id)
|> Repo.aggregate(:count, :id)
end
@doc """
Creates a new TOTP device for a user.
Returns {:ok, device, secret} where secret is the plain-text TOTP secret
that should be displayed once to the user as a QR code.
## Examples
iex> create_totp_device(user_id, "iPhone 15 Pro")
{:ok, %UserTotpDevice{}, "base32secret"}
"""
def create_totp_device(user_id, device_name) do
secret = generate_totp_secret()
create_totp_device(user_id, device_name, secret)
end
@doc """
Creates a TOTP device with a pre-existing secret.
Used during initial enrollment when the secret has already been generated and shown to the user.
"""
def create_totp_device(user_id, device_name, secret) when is_binary(secret) do
changeset =
UserTotpDevice.changeset(%UserTotpDevice{}, %{
user_id: user_id,
name: device_name,
totp_secret: secret,
created_at: DateTime.utc_now(:second)
})
result =
case Repo.insert(changeset) do
{:ok, device} -> {:ok, device, secret}
{:error, changeset} -> {:error, changeset}
end
# Log TOTP device addition for security monitoring
case result do
{:ok, _device, _secret} ->
_ = AuditLogger.log_totp_device_added(nil, user_id, device_name)
result
{:error, _changeset} ->
result
end
end
@doc """
Verifies a TOTP code against ANY of the user's devices.
Returns {:ok, device} if valid, updating last_used_at.
Returns {:error, :invalid_code} if no device matches.
"""
def verify_user_totp_any_device(%User{id: user_id}, code) when is_binary(code) do
devices = list_user_totp_devices(user_id)
Enum.find_value(devices, {:error, :invalid_code}, fn device ->
if verify_totp(device.totp_secret, code) do
# Update last_used_at and return updated device
updated_device =
device
|> UserTotpDevice.touch_changeset()
|> Repo.update!()
{:ok, updated_device}
end
end)
end
@doc """
Deletes a TOTP device.
Enforces "at least one device" rule - returns error if last device.
"""
def delete_totp_device(device_id, user_id) do
device = Repo.get(UserTotpDevice, device_id)
result =
cond do
is_nil(device) ->
{:error, :not_found}
device.user_id != user_id ->
{:error, :unauthorized}
count_user_totp_devices(user_id) == 1 ->
{:error, :last_device}
true ->
Repo.delete(device)
end
# Log TOTP device deletion for security monitoring
case result do
{:ok, deleted_device} ->
_ = AuditLogger.log_totp_device_removed(nil, user_id, deleted_device.name)
result
{:error, _reason} ->
result
end
end
@doc """
Renames a TOTP device.
"""
def rename_totp_device(device_id, user_id, new_name) do
device = Repo.get(UserTotpDevice, device_id)
cond do
is_nil(device) ->
{:error, :not_found}
device.user_id != user_id ->
{:error, :unauthorized}
true ->
device
|> UserTotpDevice.changeset(%{name: new_name})
|> Repo.update()
end
end
## Recovery Codes
@doc """
Generates 12 recovery codes for a user.
Returns {:ok, codes} where codes is a list of plain-text codes.
Stores hashed versions in database.
"""
def generate_recovery_codes(user_id) do
# Delete any existing unused recovery codes
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id and is_nil(rc.used_at))
|> Repo.delete_all()
now = DateTime.utc_now(:second)
codes = for _ <- 1..12, do: UserRecoveryCode.generate_code()
# Insert hashed codes
records =
Enum.map(codes, fn code ->
%{
id: Ecto.UUID.generate(),
user_id: user_id,
code_hash: UserRecoveryCode.hash_code(code),
created_at: now,
inserted_at: now
}
end)
result =
case Repo.insert_all(UserRecoveryCode, records) do
{12, _} -> {:ok, codes}
_ -> {:error, :generation_failed}
end
# Log recovery code generation for security monitoring
case result do
{:ok, codes} ->
_ = AuditLogger.log_recovery_codes_generated(nil, user_id, length(codes))
result
{:error, _reason} ->
result
end
end
@doc """
Verifies a recovery code for a user.
Returns {:ok, code_record} if valid and unused.
Marks code as used on successful verification.
"""
def verify_recovery_code(user_id, code) when is_binary(code) do
code_hash = UserRecoveryCode.hash_code(code)
recovery_code =
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> where([rc], rc.code_hash == ^code_hash)
|> where([rc], is_nil(rc.used_at))
|> Repo.one()
case recovery_code do
nil ->
{:error, :invalid_code}
code_record ->
code_record
|> UserRecoveryCode.use_changeset()
|> Repo.update()
end
end
@doc """
Counts unused recovery codes for a user.
"""
def count_unused_recovery_codes(user_id) do
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> where([rc], is_nil(rc.used_at))
|> Repo.aggregate(:count, :id)
end
@doc """
Lists all recovery codes for a user (for display in UI).
Returns list with status (used/unused) and creation date.
"""
def list_user_recovery_codes(user_id) do
UserRecoveryCode
|> where([rc], rc.user_id == ^user_id)
|> order_by([rc], desc: rc.inserted_at)
|> Repo.all()
end
@doc """
Verifies a TOTP code OR recovery code during login.
Tries TOTP first, falls back to recovery code.
Returns {:ok, user, :totp} or {:ok, user, :recovery_code} or {:error, :invalid_code}.
"""
def verify_user_mfa(%User{} = user, code) when is_binary(code) do
case verify_user_totp(user, code) do
{:ok, user} ->
{:ok, user, :totp}
{:error, :invalid_code} ->
# Try recovery code as fallback
case verify_recovery_code(user.id, code) do
{:ok, _code_record} -> {:ok, user, :recovery_code}
{:error, _} -> {:error, :invalid_code}
end
{:error, :totp_not_enabled} ->
{:error, :totp_not_enabled}
end
end
@doc """
Verifies a TOTP code for a user, rejecting recovery codes.
This is used for sudo mode verification where we want to ensure
the user has access to their authenticator device, not just a recovery code.
Returns `{:ok, user}` on success with valid TOTP code.
Returns `{:error, :recovery_code_not_allowed}` if a recovery code is provided.
Returns `{:error, reason}` for invalid TOTP codes.
## Examples
iex> verify_totp_only(user, "123456")
{:ok, %User{}}
iex> verify_totp_only(user, "ABCD-EFGH-IJKL")
{:error, :recovery_code_not_allowed}
iex> verify_totp_only(user, "000000")
{:error, :invalid_code}
"""
def verify_totp_only(%User{} = user, code) when is_binary(code) do
# TOTP codes are exactly 6 numeric digits
# Recovery codes contain letters and/or are longer than 6 digits
cond do
# Reject if code contains any non-numeric characters
not String.match?(code, ~r/^\d+$/) ->
{:error, :recovery_code_not_allowed}
# Reject if longer than 6 digits
String.length(code) > 6 ->
{:error, :recovery_code_not_allowed}
# Looks like a TOTP code, verify it
true ->
case verify_user_mfa(user, code) do
{:ok, user, :totp} -> {:ok, user}
{:ok, _user, :recovery_code} -> {:error, :recovery_code_not_allowed}
{:error, reason} -> {:error, reason}
end
end
end
## Settings
@doc """
Returns an `%Ecto.Changeset{}` for changing the user profile.
## Examples
iex> change_user_profile(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_profile(user, attrs \\ %{}) do
User.profile_changeset(user, attrs)
end
@doc """
Updates the user profile.
## Examples
iex> update_user_profile(user, %{name: ...})
{:ok, %User{}}
iex> update_user_profile(user, %{name: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_user_profile(user, attrs) do
user
|> User.profile_changeset(attrs)
|> Repo.update()
end
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last sudo verification was done no further
than the specified minutes ago. Default is 10 minutes (pass -10 as the minutes arg).
"""
def sudo_mode?(user, minutes \\ -10)
def sudo_mode?(%User{last_sudo_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Grants sudo mode to a user by updating their last_sudo_at timestamp.
Returns `{:ok, user}` on success or `{:error, changeset}` on failure.
## Examples
iex> grant_sudo_mode(user)
{:ok, %User{last_sudo_at: ~U[2026-02-01 12:00:00Z]}}
"""
def grant_sudo_mode(%User{} = user) do
now = DateTime.truncate(DateTime.utc_now(), :second)
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
@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 =
Repo.transact(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)
# 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
Repo.transact(fn ->
changeset = User.password_changeset(user, attrs)
with {:ok, {updated_user, expired_tokens}} <- update_user_and_delete_all_tokens(changeset) do
# 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}}
end
end)
end
## 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} ->
require Logger
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} ->
require Logger
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
@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
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
@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 helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
## User Consent
@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
## Policy Version Management
@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
## Login Attempts
@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
## Browser Sessions
@doc """
Creates a browser session with metadata from conn.
Parses User-Agent header and enriches with GeoIP data.
## Required attributes
- `:user_id` - User who owns the session
- `:user_token_id` - Associated authentication token ID
- `:ip_address` - IP address of the session
- `:user_agent` - User-Agent header string
- `:last_activity_at` - Initial activity timestamp
- `:expires_at` - Session expiration timestamp
## Examples
iex> create_browser_session(%{
...> user_id: "123",
...> user_token_id: "456",
...> ip_address: "192.168.1.1",
...> user_agent: "Mozilla/5.0...",
...> last_activity_at: ~U[2026-01-29 19:00:00Z],
...> expires_at: ~U[2026-02-12 19:00:00Z]
...> })
{:ok, %BrowserSession{}}
"""
def create_browser_session(attrs) do
# Parse User-Agent for device metadata
user_agent = Map.get(attrs, :user_agent)
device_info = :towerops@accounts@user_agent_parser.parse(user_agent)
# Enrich with GeoIP data
ip_address = Map.get(attrs, :ip_address)
location =
case GeoIP.lookup_full(ip_address) do
%{} = loc ->
%{
country_code: loc.country_code,
country_name: loc.country_name,
city_name: loc.city_name,
subdivision_1_name: loc.subdivision_1_name
}
nil ->
%{}
end
# Merge all metadata
attrs_with_metadata =
attrs
|> Map.merge(device_info)
|> Map.merge(location)
%BrowserSession{}
|> BrowserSession.create_changeset(attrs_with_metadata)
|> Repo.insert()
end
@doc """
Lists active (non-expired) browser sessions for a user.
Sessions are ordered by last_activity_at descending (most recent first).
## Examples
iex> list_active_browser_sessions(user_id)
[%BrowserSession{}, ...]
"""
def list_active_browser_sessions(user_id) do
now = DateTime.utc_now()
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], bs.expires_at > ^now)
|> where([bs], is_nil(bs.anonymized_at))
|> order_by([bs], desc: bs.last_activity_at)
|> Repo.all()
end
@doc """
Gets a browser session by user_token_id.
## Examples
iex> get_browser_session_by_token(token_id)
%BrowserSession{}
iex> get_browser_session_by_token(invalid_id)
nil
"""
def get_browser_session_by_token(user_token_id) do
Repo.get_by(BrowserSession, user_token_id: user_token_id)
end
@doc """
Gets a browser session by the token value (binary).
This is used by the UpdateSessionActivity plug to find the session
associated with the current user's token cookie.
## Examples
iex> get_browser_session_by_token_value(<<binary>>)
%BrowserSession{}
iex> get_browser_session_by_token_value(invalid_token)
nil
"""
def get_browser_session_by_token_value(token) when is_binary(token) do
hashed_token = :crypto.hash(:sha256, token)
user_token =
UserToken
|> where([ut], ut.token == ^hashed_token)
|> where([ut], ut.context == "session")
|> Repo.one()
case user_token do
nil -> nil
token -> get_browser_session_by_token(token.id)
end
end
@doc """
Updates the last_activity_at timestamp for a browser session.
## Examples
iex> touch_browser_session(session)
{:ok, %BrowserSession{}}
"""
def touch_browser_session(%BrowserSession{} = session) do
session
|> BrowserSession.touch_changeset()
|> Repo.update()
end
@doc """
Revokes a browser session (prevents self-revoke).
Deletes both the BrowserSession and the associated UserToken.
Returns `{:error, :self_revoke}` if attempting to revoke current session.
## Examples
iex> revoke_browser_session(session_id, current_user_id)
{:ok, %BrowserSession{}}
iex> revoke_browser_session(current_session_id, current_user_id)
{:error, :self_revoke}
"""
def revoke_browser_session(session_id, _user_id, current_token_id) do
session = Repo.get(BrowserSession, session_id)
cond do
is_nil(session) ->
{:error, :not_found}
session.user_token_id == current_token_id ->
{:error, :self_revoke}
true ->
# Delete the user token (this will cascade delete the browser session)
user_token = Repo.get(UserToken, session.user_token_id)
if user_token do
Repo.delete(user_token)
else
{:error, :not_found}
end
end
end
@doc """
Revokes all browser sessions for a user except the current one.
## Examples
iex> revoke_all_other_sessions(user_id, current_token_id)
{3, nil}
"""
def revoke_all_other_sessions(user_id, current_token_id) do
# Get all sessions except current
session_ids =
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], bs.user_token_id != ^current_token_id)
|> select([bs], bs.user_token_id)
|> Repo.all()
# Delete all associated tokens (cascades to sessions)
UserToken
|> where([ut], ut.id in ^session_ids)
|> Repo.delete_all()
end
@doc """
Anonymizes all browser sessions for a user (GDPR compliance).
Sets user_id to NULL and records anonymized_at timestamp.
Device metadata and location data are preserved for security analysis.
## Examples
iex> anonymize_user_browser_sessions(user_id)
{2, nil}
"""
def anonymize_user_browser_sessions(user_id) do
now = DateTime.utc_now()
BrowserSession
|> where([bs], bs.user_id == ^user_id)
|> where([bs], is_nil(bs.anonymized_at))
|> Repo.update_all(set: [user_id: nil, anonymized_at: now])
end
@doc """
Deletes expired browser sessions.
Returns the number of sessions deleted.
## Examples
iex> delete_expired_browser_sessions()
5
"""
def delete_expired_browser_sessions do
now = DateTime.utc_now()
{count, _} =
BrowserSession
|> where([bs], bs.expires_at < ^now)
|> Repo.delete_all()
count
end
@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} ->
require Logger
Logger.error("Failed to insert confirmation token: #{inspect(changeset.errors)}")
{:error, changeset}
end
end
@doc """
Confirms a user by the given token.
"""
def confirm_user(token) do
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
%User{} = user <- Repo.one(query),
{:ok, %{user: user}} <-
Ecto.Multi.new()
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|> Ecto.Multi.delete_all(
:tokens,
UserToken.by_user_and_contexts_query(user, ["confirm"])
)
|> Repo.transaction() do
{:ok, user}
else
_ -> :error
end
end
## Account Deletion (GDPR Right to Erasure)
@doc """
Returns organizations where the user is the sole owner.
Used to check if a user can safely delete their account without
orphaning organizations.
"""
@spec sole_owner_organizations(String.t()) :: [Towerops.Organizations.Organization.t()]
def sole_owner_organizations(user_id) do
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
# Find orgs where user is owner
user_owner_org_ids =
Membership
|> where([m], m.user_id == ^user_id and m.role == :owner)
|> select([m], m.organization_id)
|> Repo.all()
# For each org, check if there are other owners
user_owner_org_ids
|> Enum.filter(fn org_id ->
owner_count =
Membership
|> where([m], m.organization_id == ^org_id and m.role == :owner)
|> Repo.aggregate(:count, :id)
owner_count == 1
end)
|> then(fn org_ids ->
Organization
|> where([o], o.id in ^org_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)}")
Repo.rollback(changeset)
end
end)
end
end