diff --git a/config/runtime.exs b/config/runtime.exs index 13a6c387..bfe63e3a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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}, diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 0bd0f8aa..4d22bc0b 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -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(<>) + %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 diff --git a/lib/towerops/accounts/browser_session.ex b/lib/towerops/accounts/browser_session.ex new file mode 100644 index 00000000..38047d3d --- /dev/null +++ b/lib/towerops/accounts/browser_session.ex @@ -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 diff --git a/lib/towerops/accounts/login_attempt.ex b/lib/towerops/accounts/login_attempt.ex new file mode 100644 index 00000000..4477df22 --- /dev/null +++ b/lib/towerops/accounts/login_attempt.ex @@ -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 diff --git a/lib/towerops/accounts/user_agent_parser.ex b/lib/towerops/accounts/user_agent_parser.ex new file mode 100644 index 00000000..74238a83 --- /dev/null +++ b/lib/towerops/accounts/user_agent_parser.ex @@ -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 diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex index 51f11604..70eca437 100644 --- a/lib/towerops/admin.ex +++ b/lib/towerops/admin.ex @@ -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) diff --git a/lib/towerops/workers/login_history_cleanup_worker.ex b/lib/towerops/workers/login_history_cleanup_worker.ex new file mode 100644 index 00000000..f607acbb --- /dev/null +++ b/lib/towerops/workers/login_history_cleanup_worker.ex @@ -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 diff --git a/lib/towerops/workers/session_cleanup_worker.ex b/lib/towerops/workers/session_cleanup_worker.ex new file mode 100644 index 00000000..f6f6a397 --- /dev/null +++ b/lib/towerops/workers/session_cleanup_worker.ex @@ -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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 14adc2d1..0ce8931a 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -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 <%= 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 <% 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 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 role="menuitem" diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex index c486d910..79c06315 100644 --- a/lib/towerops_web/live/account_live/my_data.ex +++ b/lib/towerops_web/live/account_live/my_data.ex @@ -68,10 +68,89 @@ defmodule ToweropsWeb.AccountLive.MyData do current_scope={@current_scope} active_page="settings" > -
+
+

+ Account Settings +

+

+ Manage your account email address, password, and security settings +

+
+ +
+ +
+ +
-

My Data

-

+

This page shows all personal data we have stored about you in compliance with GDPR Article 15 (Right to Access).

diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index dfd7cd7d..88f72a89 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -4,7 +4,12 @@ active_page="alerts" > <.header> - Alerts + + Alerts + + Experimental + + <:subtitle>Monitor and acknowledge system alerts diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index 64de6b5e..c17db543 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -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 +
  • + + Sessions + +
  • <% end %> + + + <%= if @active_tab == "sessions" do %> + + <%= if @show_security_alert do %> +
    +
    +
    +
    + <.icon name="hero-exclamation-triangle" class="h-5 w-5 text-amber-400" /> +
    +
    +

    + Security Alert +

    +
    +

    + 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. +

    +
    +
    +
    +
    +
    + <% end %> + + +
    +
    +

    + Active Sessions +

    +

    + Manage browser sessions where you're currently logged in. +

    +
    + +
    + <%= if Enum.empty?(@browser_sessions) do %> +
    + <.icon name="hero-computer-desktop" class="mx-auto h-12 w-12 text-gray-400" /> +

    + No active sessions +

    +

    + Your current session will appear here after your next login. +

    +
    + <% else %> +
      + <%= for session <- @browser_sessions do %> + <% is_current = current_session?(session, get_current_token_id(assigns)) %> +
    • +
      +
      +

      + {format_browser_info(session)} +

      + <%= if is_current do %> + + Current Session + + <% end %> +
      +
      +
      + <.icon name="hero-map-pin" class="h-3 w-3" /> + {format_location(session)} + + {session.ip_address} +
      +
      + <.icon name="hero-clock" class="h-3 w-3" /> + Last active {format_relative_time(session.last_activity_at)} +
      +
      +
      + +
    • + <% end %> +
    + + <%= if length(@browser_sessions) > 1 do %> +
    + +
    + <% end %> + <% end %> +
    +
    + + +
    +
    +

    + Login History +

    +

    + Recent login attempts to your account, including successful and failed attempts. +

    +
    + +
    + <%= if Enum.empty?(@login_history) do %> +
    + <.icon name="hero-clock" class="mx-auto h-12 w-12 text-gray-400" /> +

    + No login history +

    +

    + Your login attempts will appear here. +

    +
    + <% else %> +
    + + + + + + + + + + + + <%= for attempt <- @login_history do %> + + + + + + + + <% end %> + +
    + Date & Time + + Status + + Method + + Location + + IP Address +
    + {Calendar.strftime(attempt.inserted_at, "%b %d, %Y %I:%M %p")} + + <%= if attempt.success do %> + + <.icon name="hero-check-circle" class="h-3 w-3" /> Success + + <% else %> + + <.icon name="hero-x-circle" class="h-3 w-3" /> Failed + + <% end %> + + <% {icon, text} = login_method_info(attempt.method) %> + + <.icon name={icon} class="h-3 w-3" /> {text} + + + {format_location(attempt)} + + {attempt.ip_address} +
    +
    + + <%= if @has_more_history do %> +
    + +
    + <% end %> + <% end %> +
    +
    + <% end %>
    + + <%= if @show_revoke_all_modal do %> + + <% end %> + <%= if @show_add_token_modal do %>
    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 diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 4a8a5bb8..940c7335 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index a9ed3645..0de69030 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -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 diff --git a/priv/repo/migrations/20260129194732_create_login_attempts.exs b/priv/repo/migrations/20260129194732_create_login_attempts.exs new file mode 100644 index 00000000..d817e580 --- /dev/null +++ b/priv/repo/migrations/20260129194732_create_login_attempts.exs @@ -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 diff --git a/priv/repo/migrations/20260129194802_create_browser_sessions.exs b/priv/repo/migrations/20260129194802_create_browser_sessions.exs new file mode 100644 index 00000000..5735e1ab --- /dev/null +++ b/priv/repo/migrations/20260129194802_create_browser_sessions.exs @@ -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