user session tracking

This commit is contained in:
Graham McIntire 2026-01-29 14:14:21 -06:00
parent c7ca0dcb27
commit 55e9397d59
17 changed files with 1709 additions and 16 deletions

View file

@ -123,7 +123,11 @@ if config_env() == :prod do
# Evaluate latency-based agent reassignment every 5 minutes
{"*/5 * * * *", Towerops.Workers.AgentLatencyEvaluator},
# Health check for missing jobs every 10 minutes
{"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker}
{"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker},
# Clean up old login history daily at 2 AM
{"0 2 * * *", Towerops.Workers.LoginHistoryCleanupWorker},
# Clean up expired browser sessions daily at 3 AM
{"0 3 * * *", Towerops.Workers.SessionCleanupWorker}
]},
# Automatically delete completed jobs after 60 seconds
{Oban.Plugins.Pruner, max_age: 60},

View file

@ -5,13 +5,17 @@ defmodule Towerops.Accounts do
import Ecto.Query, warn: false
alias Towerops.Accounts.BrowserSession
alias Towerops.Accounts.LoginAttempt
alias Towerops.Accounts.PolicyVersion
alias Towerops.Accounts.User
alias Towerops.Accounts.UserAgentParser
alias Towerops.Accounts.UserConsent
alias Towerops.Accounts.UserCredential
alias Towerops.Accounts.UserNotifier
alias Towerops.Accounts.UserToken
alias Towerops.Accounts.WebAuthn
alias Towerops.GeoIP
alias Towerops.Repo
## Database getters
@ -446,6 +450,17 @@ defmodule Towerops.Accounts do
token
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)
user_token = Repo.insert!(user_token_changeset)
{token, user_token}
end
@doc """
Gets the user with the given signed token.
@ -456,6 +471,17 @@ defmodule Towerops.Accounts do
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.
"""
@ -1008,4 +1034,386 @@ defmodule Towerops.Accounts do
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
{:ok, 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
})
{:error, _} ->
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 = UserAgentParser.parse(user_agent)
# Enrich with GeoIP data
ip_address = Map.get(attrs, :ip_address)
location =
case GeoIP.lookup_full(ip_address) do
{:ok, loc} ->
%{
country_code: loc.country_code,
country_name: loc.country_name,
city_name: loc.city_name,
subdivision_1_name: loc.subdivision_1_name
}
{:error, _} ->
%{}
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
end

View file

@ -0,0 +1,137 @@
defmodule Towerops.Accounts.BrowserSession do
@moduledoc """
Schema for tracking active browser sessions.
This schema is used for session management and GDPR compliance:
- Each browser session is linked to a user_token (one-to-one relationship)
- Device and browser metadata parsed from User-Agent header
- Location data enriched via GeoIP lookup
- Activity tracking via last_activity_at timestamp
- On user deletion, user_id is set to NULL (anonymization) but metadata preserved
## Lifecycle
- Created when user logs in (linked to user_token)
- Updated on each request via UpdateSessionActivity plug
- Deleted when user logs out or revokes session
- Automatically cleaned up after expires_at timestamp
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "browser_sessions" do
# Device/browser metadata
field :device_name, :string
field :browser_name, :string
field :browser_version, :string
field :os_name, :string
field :os_version, :string
field :device_type, :string
# Network/location data
field :ip_address, :string
field :country_code, :string
field :country_name, :string
field :city_name, :string
field :subdivision_1_name, :string
# Activity tracking
field :last_activity_at, :utc_datetime
field :expires_at, :utc_datetime
# GDPR tracking
field :anonymized_at, :utc_datetime
belongs_to :user, Towerops.Accounts.User
belongs_to :user_token, Towerops.Accounts.UserToken
timestamps(type: :utc_datetime)
end
@doc """
Creates a changeset for a new browser session.
## Required fields
- `:user_id` - User who owns the session
- `:user_token_id` - Associated authentication token
- `:ip_address` - IP address of the session
- `:last_activity_at` - Initial activity timestamp
- `:expires_at` - Session expiration timestamp
## Optional fields
- `:device_name` - Formatted device name (e.g., "Chrome on macOS")
- `:browser_name`, `:browser_version` - Parsed from User-Agent
- `:os_name`, `:os_version` - Parsed from User-Agent
- `:device_type` - desktop, mobile, or tablet
- `:country_code`, `:country_name`, `:city_name`, `:subdivision_1_name` - From GeoIP lookup
## Examples
iex> changeset(%BrowserSession{}, %{
...> user_id: "123",
...> user_token_id: "456",
...> ip_address: "192.168.1.1",
...> device_name: "Chrome on macOS",
...> browser_name: "Chrome",
...> os_name: "macOS",
...> device_type: "desktop",
...> last_activity_at: ~U[2026-01-29 19:00:00Z],
...> expires_at: ~U[2026-02-12 19:00:00Z],
...> country_name: "United States",
...> city_name: "San Francisco"
...> })
"""
def create_changeset(browser_session, attrs) do
browser_session
|> cast(attrs, [
:user_id,
:user_token_id,
:device_name,
:browser_name,
:browser_version,
:os_name,
:os_version,
:device_type,
:ip_address,
:country_code,
:country_name,
:city_name,
:subdivision_1_name,
:last_activity_at,
:expires_at
])
|> validate_required([:user_id, :user_token_id, :ip_address, :last_activity_at, :expires_at])
|> validate_length(:ip_address, max: 45)
|> validate_length(:device_name, max: 255)
|> validate_length(:browser_name, max: 100)
|> validate_length(:os_name, max: 100)
|> validate_inclusion(:device_type, ["desktop", "mobile", "tablet"], allow_nil: true)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:user_token_id)
|> unique_constraint(:user_token_id)
end
@doc """
Creates a changeset for updating the last_activity_at timestamp.
This is called by the UpdateSessionActivity plug on each request
to keep track of when the session was last used.
"""
def touch_changeset(browser_session) do
change(browser_session, %{last_activity_at: DateTime.utc_now()})
end
@doc """
Anonymizes a browser session by setting user_id to nil and recording timestamp.
This is used for GDPR compliance when a user is deleted. The IP address,
location data, and device metadata are preserved for security analysis.
"""
def anonymize_changeset(browser_session) do
change(browser_session, %{user_id: nil, anonymized_at: DateTime.utc_now()})
end
end

View file

@ -0,0 +1,124 @@
defmodule Towerops.Accounts.LoginAttempt do
@moduledoc """
Schema for tracking login attempts (successful and failed).
This schema is used for security auditing and GDPR compliance:
- All login attempts are recorded with IP address, location, and device metadata
- On user deletion, user_id is set to NULL (anonymization) but IP/location preserved
- Email addresses are preserved after anonymization for pattern analysis
- Data retention: 365 days for active records, 90 days for anonymized records
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_methods ~w(password magic_link webauthn mobile_qr)
@valid_failure_reasons ~w(invalid_credentials invalid_totp invalid_magic_link invalid_passkey account_disabled)
schema "login_attempts" do
field :email, :string
field :success, :boolean
field :method, :string
field :failure_reason, :string
# Network/location data
field :ip_address, :string
field :user_agent, :string
field :country_code, :string
field :country_name, :string
field :city_name, :string
field :subdivision_1_name, :string
# GDPR tracking
field :anonymized_at, :utc_datetime
belongs_to :user, Towerops.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Creates a changeset for recording a login attempt.
## Required fields
- `:success` - Boolean indicating if login was successful
- `:method` - Login method (password, magic_link, webauthn, mobile_qr)
- `:ip_address` - IP address of the login attempt
## Conditional validation
- If `success` is false, `:failure_reason` is required
- If `success` is true, `:failure_reason` must be nil
## Examples
iex> changeset(%LoginAttempt{}, %{
...> user_id: "123",
...> email: "user@example.com",
...> success: true,
...> method: "password",
...> ip_address: "192.168.1.1",
...> country_name: "United States"
...> })
"""
def changeset(login_attempt, attrs) do
login_attempt
|> cast(attrs, [
:user_id,
:email,
:success,
:method,
:failure_reason,
:ip_address,
:user_agent,
:country_code,
:country_name,
:city_name,
:subdivision_1_name
])
|> validate_required([:success, :method, :ip_address])
|> validate_inclusion(:method, @valid_methods)
|> validate_failure_reason()
|> validate_length(:email, max: 160)
|> validate_length(:ip_address, max: 45)
|> validate_length(:user_agent, max: 1000)
end
@doc """
Anonymizes a login attempt by setting user_id to nil and recording timestamp.
This is used for GDPR compliance when a user is deleted. The IP address,
location data, and other metadata are preserved for security analysis.
"""
def anonymize_changeset(login_attempt) do
change(login_attempt, %{user_id: nil, anonymized_at: DateTime.utc_now()})
end
# Private helpers
defp validate_failure_reason(changeset) do
success = get_field(changeset, :success)
failure_reason = get_field(changeset, :failure_reason)
cond do
# If success is true, failure_reason must be nil
success == true && !is_nil(failure_reason) ->
add_error(changeset, :failure_reason, "must be nil when login is successful")
# If success is false, failure_reason is required
success == false && is_nil(failure_reason) ->
add_error(changeset, :failure_reason, "is required when login fails")
# If success is false and failure_reason is present, validate it's in the allowed list
success == false && !is_nil(failure_reason) ->
validate_inclusion(changeset, :failure_reason, @valid_failure_reasons)
# Otherwise, it's valid
true ->
changeset
end
end
end

View file

@ -0,0 +1,195 @@
defmodule Towerops.Accounts.UserAgentParser do
@moduledoc """
Simple regex-based User-Agent parser for extracting browser, OS, and device information.
This module provides basic device identification without external dependencies.
It covers ~95% of common user agents but may not detect obscure browsers or devices.
## Examples
iex> parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
%{
browser_name: "Chrome",
browser_version: "120.0.0.0",
os_name: "macOS",
os_version: "10.15.7",
device_type: "desktop",
device_name: "Chrome on macOS"
}
iex> parse("Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1")
%{
browser_name: "Safari",
browser_version: "17.2",
os_name: "iOS",
os_version: "17.2",
device_type: "mobile",
device_name: "Safari on iOS"
}
"""
@doc """
Parses a User-Agent string and returns device metadata.
Returns a map with the following fields:
- `:browser_name` - Browser name (Chrome, Safari, Firefox, Edge, Opera, or "Unknown")
- `:browser_version` - Browser version string (or nil)
- `:os_name` - Operating system name (macOS, Windows, Linux, iOS, Android, or "Unknown")
- `:os_version` - OS version string (or nil)
- `:device_type` - "desktop", "mobile", or "tablet"
- `:device_name` - Formatted string like "Chrome on macOS"
If the User-Agent is nil or cannot be parsed, returns a map with "Unknown" values.
"""
def parse(nil), do: unknown_device()
def parse(""), do: unknown_device()
def parse(user_agent) when is_binary(user_agent) do
browser = detect_browser(user_agent)
os = detect_os(user_agent)
device_type = detect_device_type(user_agent, os)
%{
browser_name: browser.name,
browser_version: browser.version,
os_name: os.name,
os_version: os.version,
device_type: device_type,
device_name: format_device_name(browser.name, os.name)
}
end
# Browser detection
defp detect_browser(ua) do
cond do
# Edge must be checked before Chrome (Edge includes Chrome in UA)
Regex.match?(~r/Edg(e|A|iOS)?\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Edg(?:e|A|iOS)?\/(\d+\.\d+)/)
%{name: "Edge", version: version}
# Chrome must be checked before Safari (Chrome includes Safari in UA)
Regex.match?(~r/Chrome\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Chrome\/(\d+\.\d+)/)
%{name: "Chrome", version: version}
# Firefox
Regex.match?(~r/Firefox\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Firefox\/(\d+\.\d+)/)
%{name: "Firefox", version: version}
# Opera
Regex.match?(~r/OPR\/(\d+\.\d+)/, ua) || Regex.match?(~r/Opera\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/(?:OPR|Opera)\/(\d+\.\d+)/)
%{name: "Opera", version: version}
# Safari (check last because many browsers include Safari in UA)
Regex.match?(~r/Safari\//, ua) && Regex.match?(~r/Version\/(\d+\.\d+)/, ua) ->
version = extract_version(ua, ~r/Version\/(\d+\.\d+)/)
%{name: "Safari", version: version}
true ->
%{name: "Unknown", version: nil}
end
end
# OS detection
defp detect_os(ua) do
cond do
# iOS (must check before macOS because iOS UA contains "Mac OS X")
Regex.match?(~r/iPhone|iPad|iPod/, ua) ->
version = extract_version(ua, ~r/OS (\d+[_\d]*)/)
%{name: "iOS", version: format_ios_version(version)}
# macOS
Regex.match?(~r/Macintosh|Mac OS X/, ua) ->
version = extract_version(ua, ~r/Mac OS X (\d+[_\d]*)/)
%{name: "macOS", version: format_mac_version(version)}
# Android
Regex.match?(~r/Android/, ua) ->
version = extract_version(ua, ~r/Android (\d+\.\d+)/)
%{name: "Android", version: version}
# Windows
Regex.match?(~r/Windows/, ua) ->
version = detect_windows_version(ua)
%{name: "Windows", version: version}
# Linux
Regex.match?(~r/Linux/, ua) ->
%{name: "Linux", version: nil}
true ->
%{name: "Unknown", version: nil}
end
end
# Device type detection
defp detect_device_type(ua, os) do
cond do
# Tablets
Regex.match?(~r/iPad/, ua) || Regex.match?(~r/Android.*Tablet/, ua) ->
"tablet"
# Mobile devices
Regex.match?(~r/iPhone|iPod|Android.*Mobile|Mobile/, ua) || os.name == "iOS" ->
"mobile"
# Default to desktop
true ->
"desktop"
end
end
# Helper functions
defp extract_version(ua, regex) do
case Regex.run(regex, ua) do
[_, version] -> version
_ -> nil
end
end
defp format_ios_version(nil), do: nil
defp format_ios_version(version) do
# Convert iOS version from "17_2_1" to "17.2.1"
String.replace(version, "_", ".")
end
defp format_mac_version(nil), do: nil
defp format_mac_version(version) do
# Convert macOS version from "10_15_7" to "10.15.7"
String.replace(version, "_", ".")
end
defp detect_windows_version(ua) do
cond do
Regex.match?(~r/Windows NT 10\.0/, ua) -> "10"
Regex.match?(~r/Windows NT 6\.3/, ua) -> "8.1"
Regex.match?(~r/Windows NT 6\.2/, ua) -> "8"
Regex.match?(~r/Windows NT 6\.1/, ua) -> "7"
Regex.match?(~r/Windows NT/, ua) -> extract_version(ua, ~r/Windows NT (\d+\.\d+)/)
true -> nil
end
end
defp format_device_name(browser, os) do
"#{browser} on #{os}"
end
defp unknown_device do
%{
browser_name: "Unknown",
browser_version: nil,
os_name: "Unknown",
os_version: nil,
device_type: "desktop",
device_name: "Unknown on Unknown"
}
end
end

View file

@ -77,6 +77,13 @@ defmodule Towerops.Admin do
ip_address: ip_address
})
# Anonymize login history (GDPR compliance)
# Preserves IP/location for security analysis, removes user link
Towerops.Accounts.anonymize_user_login_history(user_id)
# Anonymize browser sessions (GDPR compliance)
Towerops.Accounts.anonymize_user_browser_sessions(user_id)
# Delete user (cascades to memberships and tokens)
Repo.delete!(user)
end)

View file

@ -0,0 +1,41 @@
defmodule Towerops.Workers.LoginHistoryCleanupWorker do
@moduledoc """
Oban worker for cleaning up old login attempt records.
Data retention policy:
- Active records: 365 days (1 year)
- Anonymized records: 90 days after anonymization
This worker runs daily at 2 AM via Oban cron.
"""
use Oban.Worker, queue: :maintenance
import Ecto.Query
alias Towerops.Accounts.LoginAttempt
alias Towerops.Repo
@impl Oban.Worker
def perform(_job) do
# Delete login attempts older than retention period (365 days)
retention_days = Application.get_env(:towerops, :login_history_retention_days, 365)
cutoff = DateTime.add(DateTime.utc_now(), -retention_days, :day)
{count, _} =
LoginAttempt
|> where([la], la.inserted_at < ^cutoff)
|> where([la], is_nil(la.anonymized_at))
|> Repo.delete_all()
# Delete anonymized records older than 90 days
anon_cutoff = DateTime.add(DateTime.utc_now(), -90, :day)
{anon_count, _} =
LoginAttempt
|> where([la], not is_nil(la.anonymized_at))
|> where([la], la.anonymized_at < ^anon_cutoff)
|> Repo.delete_all()
{:ok, %{deleted: count, anonymized_deleted: anon_count}}
end
end

View file

@ -0,0 +1,17 @@
defmodule Towerops.Workers.SessionCleanupWorker do
@moduledoc """
Oban worker for cleaning up expired browser sessions.
Deletes browser sessions that have passed their expiration timestamp.
This worker runs daily at 3 AM via Oban cron.
"""
use Oban.Worker, queue: :maintenance
alias Towerops.Accounts
@impl Oban.Worker
def perform(_job) do
count = Accounts.delete_expired_browser_sessions()
{:ok, %{sessions_deleted: count}}
end
end

View file

@ -311,36 +311,36 @@ defmodule ToweropsWeb.Layouts do
:if={@current_organization}
role="menuitem"
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4" /> Organization Settings
Organization Settings
</.link>
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
<.link
role="menuitem"
navigate={~p"/admin"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-shield-check" class="w-4 h-4" /> Admin Panel
Admin Panel
</.link>
<% end %>
<.link
role="menuitem"
navigate={~p"/users/settings"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-user-circle" class="w-4 h-4" /> User Settings
User Settings
</.link>
<.link
role="menuitem"
navigate={~p"/account/my-data"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-document-arrow-down" class="w-4 h-4" /> My Data
My Data
</.link>
<.link
role="menuitem"

View file

@ -68,10 +68,89 @@ defmodule ToweropsWeb.AccountLive.MyData do
current_scope={@current_scope}
active_page="settings"
>
<div class="space-y-8">
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Account Settings
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage your account email address, password, and security settings
</p>
</div>
<header class="border-b border-gray-200 dark:border-white/5">
<nav class="flex overflow-x-auto py-4">
<ul
role="list"
class="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-500 sm:px-6 lg:px-8 dark:text-gray-400"
>
<li>
<.link
navigate={~p"/users/settings"}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Personal
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings"}
phx-click={JS.push("switch_tab", value: %{tab: "account"})}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Account
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings"}
phx-click={JS.push("switch_tab", value: %{tab: "security"})}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Security
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings"}
phx-click={JS.push("switch_tab", value: %{tab: "sessions"})}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Sessions
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings"}
phx-click={JS.push("switch_tab", value: %{tab: "api"})}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
API
</.link>
</li>
<li>
<.link
navigate={~p"/users/settings"}
phx-click={JS.push("switch_tab", value: %{tab: "notifications"})}
class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400"
>
Notifications
</.link>
</li>
<li>
<.link
navigate={~p"/account/my-data"}
class="text-indigo-600 dark:text-indigo-400"
>
My Data
</.link>
</li>
</ul>
</nav>
</header>
<div class="space-y-8 py-8">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Data</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<p class="text-sm text-gray-600 dark:text-gray-400">
This page shows all personal data we have stored about you in compliance with GDPR Article 15 (Right to Access).
</p>
</div>

View file

@ -4,7 +4,12 @@
active_page="alerts"
>
<.header>
Alerts
<span class="flex items-center gap-2">
Alerts
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
Experimental
</span>
</span>
<:subtitle>Monitor and acknowledge system alerts</:subtitle>
</.header>

View file

@ -10,11 +10,14 @@ defmodule ToweropsWeb.UserSettingsLive do
alias Towerops.MobileSessions
@impl true
def mount(_params, _session, socket) do
def mount(_params, session, socket) do
current_token = session["user_token"]
socket =
socket
|> assign(:page_title, "Account Settings")
|> assign(:active_tab, "personal")
|> assign(:current_token, current_token)
|> assign_changesets()
|> assign_profile_form()
|> assign_credentials()
@ -22,9 +25,14 @@ defmodule ToweropsWeb.UserSettingsLive do
|> assign_api_tokens()
|> assign_organizations()
|> assign_default_organization()
|> assign_browser_sessions()
|> assign_login_history()
|> assign_security_alerts()
|> assign(:show_add_token_modal, false)
|> assign(:show_token_modal, false)
|> assign(:created_token, nil)
|> assign(:show_revoke_all_modal, false)
|> assign(:login_history_page, 1)
{:ok, socket}
end
@ -211,6 +219,77 @@ defmodule ToweropsWeb.UserSettingsLive do
end
end
@impl true
def handle_event("revoke_session", %{"session-id" => session_id}, socket) do
user = socket.assigns.current_scope.user
current_token_id = get_current_token_id(socket)
case Accounts.revoke_browser_session(session_id, user.id, current_token_id) do
{:ok, _} ->
socket =
socket
|> put_flash(:info, "Session revoked successfully.")
|> assign_browser_sessions()
{:noreply, socket}
{:error, :self_revoke} ->
{:noreply, put_flash(socket, :error, "You cannot revoke your current session.")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Session not found.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to revoke session.")}
end
end
@impl true
def handle_event("show_revoke_all_modal", _params, socket) do
{:noreply, assign(socket, :show_revoke_all_modal, true)}
end
@impl true
def handle_event("cancel_revoke_all", _params, socket) do
{:noreply, assign(socket, :show_revoke_all_modal, false)}
end
@impl true
def handle_event("confirm_revoke_all", _params, socket) do
user = socket.assigns.current_scope.user
current_token_id = get_current_token_id(socket)
{count, _} = Accounts.revoke_all_other_sessions(user.id, current_token_id)
socket =
socket
|> put_flash(:info, "Revoked #{count} other session(s) successfully.")
|> assign(:show_revoke_all_modal, false)
|> assign_browser_sessions()
{:noreply, socket}
end
@impl true
def handle_event("load_more_history", _params, socket) do
next_page = socket.assigns.login_history_page + 1
socket =
socket
|> assign(:login_history_page, next_page)
|> assign_login_history()
{:noreply, socket}
end
defp get_current_token_id(socket) do
token = socket.assigns.current_token
if token do
Accounts.get_user_token_id_by_value(token)
end
end
defp assign_profile_form(socket) do
user = socket.assigns.current_scope.user
assign(socket, :profile_form, user |> Accounts.change_user_profile() |> to_form())
@ -253,6 +332,94 @@ defmodule ToweropsWeb.UserSettingsLive do
assign(socket, :default_organization, default_org)
end
defp assign_browser_sessions(socket) do
user = socket.assigns.current_scope.user
sessions = Accounts.list_active_browser_sessions(user.id)
assign(socket, :browser_sessions, sessions)
end
defp assign_login_history(socket) do
user = socket.assigns.current_scope.user
page = socket.assigns[:login_history_page] || 1
limit = 20
offset = (page - 1) * limit
history = Accounts.list_user_login_history(user.id, limit: limit, offset: offset)
has_more = length(history) == limit
socket
|> assign(:login_history, history)
|> assign(:has_more_history, has_more)
end
defp assign_security_alerts(socket) do
user = socket.assigns.current_scope.user
since = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second)
failed_count = Accounts.count_user_login_attempts(user.id, success: false, since: since)
socket
|> assign(:failed_login_count, failed_count)
|> assign(:show_security_alert, failed_count >= 3)
end
# Sessions tab helper functions
defp current_session?(session, current_token_id) do
session.user_token_id == current_token_id
end
defp format_browser_info(session) do
case {session.browser_name, session.browser_version, session.os_name} do
{nil, _, _} -> session.device_name || "Unknown Browser"
{browser, nil, nil} -> browser
{browser, version, nil} -> "#{browser} #{version}"
{browser, nil, os} -> "#{browser} on #{os}"
{browser, version, os} -> "#{browser} #{version} on #{os}"
end
end
defp format_location(record) do
case {record.city_name, record.subdivision_1_name, record.country_name} do
{nil, nil, nil} ->
"Unknown Location"
{city, state, country} when not is_nil(city) and not is_nil(state) and not is_nil(country) ->
"#{city}, #{state}, #{country}"
{city, nil, country} when not is_nil(city) and not is_nil(country) ->
"#{city}, #{country}"
{nil, nil, country} when not is_nil(country) ->
country
_ ->
"Unknown Location"
end
end
defp format_relative_time(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
cond do
diff < 60 -> "Just now"
diff < 3600 -> "#{div(diff, 60)} minutes ago"
diff < 86_400 -> "#{div(diff, 3600)} hours ago"
diff < 604_800 -> "#{div(diff, 86_400)} days ago"
true -> Calendar.strftime(datetime, "%b %d, %Y")
end
end
defp login_method_info(method) do
case method do
"password" -> {"hero-lock-closed", "Password"}
"magic_link" -> {"hero-envelope", "Magic Link"}
"webauthn" -> {"hero-key", "Passkey"}
"mobile_qr" -> {"hero-device-phone-mobile", "Mobile QR"}
_ -> {"hero-question-mark-circle", method}
end
end
@impl true
def render(assigns) do
~H"""
@ -317,6 +484,20 @@ defmodule ToweropsWeb.UserSettingsLive do
Security
</a>
</li>
<li>
<a
href="#"
phx-click="switch_tab"
phx-value-tab="sessions"
class={
if @active_tab == "sessions",
do: "text-indigo-600 dark:text-indigo-400",
else: ""
}
>
Sessions
</a>
</li>
<li>
<a
href="#"
@ -909,8 +1090,299 @@ defmodule ToweropsWeb.UserSettingsLive do
</div>
</div>
<% end %>
<!-- Sessions Tab -->
<%= if @active_tab == "sessions" do %>
<!-- Security Alert Banner -->
<%= if @show_security_alert do %>
<div class="px-4 py-6 sm:px-6 lg:px-8">
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex">
<div class="flex-shrink-0">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-amber-800 dark:text-amber-200">
Security Alert
</h3>
<div class="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p>
We detected {@failed_login_count} failed login attempts in the last 24 hours.
Please review your login history below and ensure all activity is authorized.
</p>
</div>
</div>
</div>
</div>
</div>
<% end %>
<!-- Active Sessions Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Active Sessions
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Manage browser sessions where you're currently logged in.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@browser_sessions) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-computer-desktop" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No active sessions
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your current session will appear here after your next login.
</p>
</div>
<% else %>
<ul role="list" class="divide-y divide-gray-200 dark:divide-white/10">
<%= for session <- @browser_sessions do %>
<% is_current = current_session?(session, get_current_token_id(assigns)) %>
<li class="flex items-center justify-between gap-x-6 py-5">
<div class="min-w-0 flex-1">
<div class="flex items-start gap-x-3">
<p class="text-sm/6 font-semibold text-gray-900 dark:text-white">
{format_browser_info(session)}
</p>
<%= if is_current do %>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
Current Session
</span>
<% end %>
</div>
<div class="mt-1 flex flex-col gap-y-1 text-xs/5 text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-x-2">
<.icon name="hero-map-pin" class="h-3 w-3" />
<span>{format_location(session)}</span>
<span class="text-gray-400 dark:text-gray-600"></span>
<span class="font-mono">{session.ip_address}</span>
</div>
<div class="flex items-center gap-x-2">
<.icon name="hero-clock" class="h-3 w-3" />
<span>Last active {format_relative_time(session.last_activity_at)}</span>
</div>
</div>
</div>
<button
type="button"
phx-click="revoke_session"
phx-value-session-id={session.id}
disabled={is_current}
data-confirm={
unless is_current,
do:
"Are you sure you want to revoke this session? You will be logged out from that device."
}
class={"rounded-md px-2.5 py-1.5 text-sm font-semibold shadow-xs inset-ring-1 " <>
if is_current do
"bg-gray-100 text-gray-400 inset-ring-gray-200 cursor-not-allowed dark:bg-gray-800 dark:text-gray-600 dark:inset-ring-gray-700"
else
"bg-white text-gray-900 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
end
}
>
Revoke
</button>
</li>
<% end %>
</ul>
<%= if length(@browser_sessions) > 1 do %>
<div class="mt-6 flex">
<button
type="button"
phx-click="show_revoke_all_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-red-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:bg-red-500 dark:hover:bg-red-400"
>
<.icon name="hero-x-mark" class="-ml-0.5 h-5 w-5" /> Revoke All Other Sessions
</button>
</div>
<% end %>
<% end %>
</div>
</div>
<!-- Login History Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8 border-t border-gray-200 dark:border-white/10">
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">
Login History
</h2>
<p class="mt-1 text-sm/6 text-gray-500 dark:text-gray-400">
Recent login attempts to your account, including successful and failed attempts.
</p>
</div>
<div class="md:col-span-2">
<%= if Enum.empty?(@login_history) do %>
<div class="text-center rounded-lg border border-dashed border-gray-300 px-6 py-10 dark:border-gray-700">
<.icon name="hero-clock" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No login history
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Your login attempts will appear here.
</p>
</div>
<% else %>
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white/10 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-white sm:pl-6"
>
Date & Time
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Status
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Method
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Location
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
IP Address
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900">
<%= for attempt <- @login_history do %>
<tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm text-gray-900 dark:text-white sm:pl-6">
{Calendar.strftime(attempt.inserted_at, "%b %d, %Y %I:%M %p")}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<%= if attempt.success do %>
<span class="inline-flex items-center gap-x-1 rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 inset-ring-1 inset-ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:inset-ring-green-500/20">
<.icon name="hero-check-circle" class="h-3 w-3" /> Success
</span>
<% else %>
<span class="inline-flex items-center gap-x-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 inset-ring-1 inset-ring-red-600/20 dark:bg-red-500/10 dark:text-red-400 dark:inset-ring-red-500/20">
<.icon name="hero-x-circle" class="h-3 w-3" /> Failed
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% {icon, text} = login_method_info(attempt.method) %>
<span class="inline-flex items-center gap-x-1">
<.icon name={icon} class="h-3 w-3" /> {text}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
{format_location(attempt)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm font-mono text-gray-500 dark:text-gray-400">
{attempt.ip_address}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<%= if @has_more_history do %>
<div class="mt-6 flex">
<button
type="button"
phx-click="load_more_history"
class="rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-100 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/10 dark:hover:bg-white/20"
>
Load More
</button>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Revoke All Sessions Confirmation Modal -->
<%= if @show_revoke_all_modal do %>
<div
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="revoke-all-modal-title"
role="dialog"
aria-modal="true"
>
<div class="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
<div
phx-click="cancel_revoke_all"
class="fixed inset-0 z-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-950 dark:bg-opacity-75"
aria-hidden="true"
>
</div>
<span
class="relative z-10 hidden sm:inline-block sm:h-screen sm:align-middle"
aria-hidden="true"
>
&#8203;
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<.icon
name="hero-exclamation-triangle"
class="h-6 w-6 text-red-600 dark:text-red-400"
/>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3
class="text-lg font-semibold leading-6 text-gray-900 dark:text-white"
id="revoke-all-modal-title"
>
Revoke All Other Sessions?
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
This will log you out from all other browsers and devices. Your current session will remain active.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<button
type="button"
phx-click="confirm_revoke_all"
class="inline-flex w-full justify-center rounded-lg bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 sm:col-start-2"
>
Revoke All
</button>
<button
type="button"
phx-click="cancel_revoke_all"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
>
Cancel
</button>
</div>
</div>
</div>
</div>
<% end %>
<!-- API Token Creation Modal -->
<%= if @show_add_token_modal do %>
<div

View file

@ -0,0 +1,47 @@
defmodule ToweropsWeb.Plugs.UpdateSessionActivity do
@moduledoc """
Updates the last_activity_at timestamp for the current browser session on each request.
This plug should be added to the browser pipeline to keep track of when
each session was last used. The activity timestamp is used in the Sessions
tab of User Settings to show when each session was last active.
The update happens in a background task to avoid slowing down requests.
"""
import Plug.Conn
alias Towerops.Accounts
@doc """
Initializes the plug with options (currently unused).
"""
def init(opts), do: opts
@doc """
Updates the last_activity_at timestamp for the current browser session.
Looks up the session by the token stored in the session cookie, then
updates the timestamp in a background task.
"""
def call(conn, _opts) do
token = get_session(conn, :user_token)
if token do
Task.start(fn -> update_session_activity(token) end)
end
conn
end
defp update_session_activity(token) do
case Accounts.get_browser_session_by_token_value(token) do
nil ->
# No browser session found (might be an old session from before this feature)
:ok
session ->
# Update activity timestamp
Accounts.touch_browser_session(session)
end
end
end

View file

@ -18,6 +18,7 @@ defmodule ToweropsWeb.Router do
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
plug ToweropsWeb.Plugs.UpdateSessionActivity
plug :store_return_to_for_liveview
plug ToweropsWeb.Plugs.DetectEUUser
plug ToweropsWeb.Plugs.CheckPolicyConsent
@ -219,7 +220,7 @@ defmodule ToweropsWeb.Router do
pipe_through [:browser, :require_authenticated_user]
live "/users/settings", UserSettingsLive, :index
live "/account/my-data", AccountLive.MyData, :index
live "/users/my-data", AccountLive.MyData, :index
end
end

View file

@ -157,9 +157,19 @@ defmodule ToweropsWeb.UserAuth do
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
{token, user_token} = Accounts.generate_user_session_token_with_record(user)
remember_me = get_session(conn, :user_remember_me)
# Create browser session in background with metadata
Task.start(fn ->
create_browser_session_from_conn(conn, user, user_token)
end)
# Record successful login attempt in background
Task.start(fn ->
record_successful_login(conn, user, params)
end)
conn
|> renew_session(user)
|> put_token_in_session(token)
@ -778,4 +788,65 @@ defmodule ToweropsWeb.UserAuth do
|> put_flash(:info, "Stopped impersonating")
|> redirect(to: redirect_path)
end
## Login tracking helpers
defp create_browser_session_from_conn(conn, user, user_token) do
ip_address = extract_ip_address(conn)
user_agent = extract_user_agent(conn)
now = DateTime.utc_now()
expires_at = DateTime.add(now, @max_cookie_age_in_days * 24 * 60 * 60, :second)
Accounts.create_browser_session(%{
user_id: user.id,
user_token_id: user_token.id,
ip_address: ip_address,
user_agent: user_agent,
last_activity_at: now,
expires_at: expires_at
})
end
defp record_successful_login(conn, user, params) do
ip_address = extract_ip_address(conn)
user_agent = extract_user_agent(conn)
method = determine_login_method(conn, params)
Accounts.record_login_attempt(%{
user_id: user.id,
email: user.email,
success: true,
method: method,
ip_address: ip_address,
user_agent: user_agent
})
end
defp extract_ip_address(conn) do
# Try X-Forwarded-For first (for proxies/load balancers)
case get_req_header(conn, "x-forwarded-for") do
[forwarded | _] ->
# Take the first IP in the chain
forwarded
|> String.split(",")
|> List.first()
|> String.trim()
[] ->
# Fall back to remote_ip
conn.remote_ip
|> :inet_parse.ntoa()
|> to_string()
end
end
defp extract_user_agent(conn) do
case get_req_header(conn, "user-agent") do
[ua | _] -> ua
[] -> nil
end
end
defp determine_login_method(_conn, %{"remember_me" => _}), do: "password"
defp determine_login_method(_conn, _params), do: "password"
end

View file

@ -0,0 +1,39 @@
defmodule Towerops.Repo.Migrations.CreateLoginAttempts do
use Ecto.Migration
def change do
create table(:login_attempts, primary_key: false) do
add :id, :binary_id, primary_key: true
# User association (nullable for GDPR anonymization)
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
# Login metadata
add :email, :string
add :success, :boolean, null: false
add :method, :string, null: false
add :failure_reason, :string
# Network/location data (preserved for security analysis)
add :ip_address, :string, null: false
add :user_agent, :text
add :country_code, :string
add :country_name, :string
add :city_name, :string
add :subdivision_1_name, :string
# GDPR tracking
add :anonymized_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:login_attempts, [:user_id])
create index(:login_attempts, [:email])
create index(:login_attempts, [:ip_address])
create index(:login_attempts, [:inserted_at])
create index(:login_attempts, [:success])
create index(:login_attempts, [:anonymized_at])
create index(:login_attempts, [:user_id, :inserted_at])
end
end

View file

@ -0,0 +1,46 @@
defmodule Towerops.Repo.Migrations.CreateBrowserSessions do
use Ecto.Migration
def change do
create table(:browser_sessions, primary_key: false) do
add :id, :binary_id, primary_key: true
# User association (nullable for GDPR)
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
# Link to users_tokens for authentication
add :user_token_id, references(:users_tokens, type: :binary_id, on_delete: :delete_all),
null: false
# Device/browser metadata (from User-Agent parsing)
add :device_name, :string
add :browser_name, :string
add :browser_version, :string
add :os_name, :string
add :os_version, :string
add :device_type, :string
# Network/location data
add :ip_address, :string, null: false
add :country_code, :string
add :country_name, :string
add :city_name, :string
add :subdivision_1_name, :string
# Activity tracking
add :last_activity_at, :utc_datetime, null: false
add :expires_at, :utc_datetime, null: false
# GDPR tracking
add :anonymized_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create index(:browser_sessions, [:user_id])
create unique_index(:browser_sessions, [:user_token_id])
create index(:browser_sessions, [:expires_at])
create index(:browser_sessions, [:last_activity_at])
create index(:browser_sessions, [:anonymized_at])
end
end