1850 lines
49 KiB
Elixir
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
|