user session tracking
This commit is contained in:
parent
c7ca0dcb27
commit
55e9397d59
17 changed files with 1709 additions and 16 deletions
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
137
lib/towerops/accounts/browser_session.ex
Normal file
137
lib/towerops/accounts/browser_session.ex
Normal 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
|
||||
124
lib/towerops/accounts/login_attempt.ex
Normal file
124
lib/towerops/accounts/login_attempt.ex
Normal 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
|
||||
195
lib/towerops/accounts/user_agent_parser.ex
Normal file
195
lib/towerops/accounts/user_agent_parser.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
41
lib/towerops/workers/login_history_cleanup_worker.ex
Normal file
41
lib/towerops/workers/login_history_cleanup_worker.ex
Normal 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
|
||||
17
lib/towerops/workers/session_cleanup_worker.ex
Normal file
17
lib/towerops/workers/session_cleanup_worker.ex
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
​
|
||||
</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
|
||||
|
|
|
|||
47
lib/towerops_web/plugs/update_session_activity.ex
Normal file
47
lib/towerops_web/plugs/update_session_activity.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue