Shows a red badge on the bell icon with the unresolved alert count. Displays the number when <= 100, blank red dot when > 100 (Apple-style). Count is loaded at mount and updated live when alerts change via PubSub. Uses process dictionary pattern (same as cookie consent) so no template changes needed across the 41 LiveView layouts.
1026 lines
32 KiB
Elixir
1026 lines
32 KiB
Elixir
defmodule ToweropsWeb.UserAuth do
|
|
@moduledoc """
|
|
Authentication plugs and helpers for user session management.
|
|
|
|
Provides Plug functions for:
|
|
- Fetching current user from session
|
|
- Requiring authenticated users
|
|
- Redirecting guests and authenticated users
|
|
- Managing remember-me tokens
|
|
- User session lifecycle (login, logout)
|
|
"""
|
|
use ToweropsWeb, :verified_routes
|
|
use Gettext, backend: ToweropsWeb.Gettext
|
|
|
|
import Phoenix.Controller
|
|
import Plug.Conn
|
|
import ToweropsWeb.GettextHelpers
|
|
|
|
alias Phoenix.LiveView
|
|
alias Towerops.Accounts
|
|
alias Towerops.Accounts.Scope
|
|
alias ToweropsWeb.Helpers.StatusHelpers
|
|
|
|
# Make the remember me cookie valid for 14 days. This should match
|
|
# the session validity setting in UserToken.
|
|
@max_cookie_age_in_days 14
|
|
@remember_me_cookie "_towerops_web_user_remember_me"
|
|
@remember_me_options [
|
|
sign: true,
|
|
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
|
same_site: "Lax"
|
|
]
|
|
|
|
# How old the session token should be before a new one is issued. When a request is made
|
|
# with a session token older than this value, then a new session token will be created
|
|
# and the session and remember-me cookies (if set) will be updated with the new token.
|
|
# Lowering this value will result in more tokens being created by active users. Increasing
|
|
# it will result in less time before a session token expires for a user to get issued a new
|
|
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
|
|
# the reissuing of tokens completely.
|
|
@session_reissue_age_in_days 7
|
|
|
|
@doc """
|
|
Logs the user in.
|
|
|
|
Redirects to the session's `:user_return_to` path
|
|
or falls back to the `signed_in_path/1`.
|
|
"""
|
|
def log_in_user(conn, user, params \\ %{}) do
|
|
user_return_to = get_session(conn, :user_return_to)
|
|
# Validate return path - ignore if it's an invalid destination
|
|
valid_return_to = if valid_return_path?(user_return_to), do: user_return_to
|
|
|
|
conn =
|
|
conn
|
|
|> create_or_extend_session(user, params)
|
|
|> maybe_set_default_organization(user)
|
|
|> delete_session(:user_return_to)
|
|
|
|
redirect(conn, to: valid_return_to || signed_in_path(user))
|
|
end
|
|
|
|
# Validates if a return path is a valid application destination
|
|
defp valid_return_path?(nil), do: false
|
|
|
|
defp valid_return_path?(path) when is_binary(path) do
|
|
invalid_prefixes = [
|
|
"/users/log-in",
|
|
"/users/register",
|
|
"/users/reset-password",
|
|
"/users/confirm",
|
|
"/dev/",
|
|
"/assets/",
|
|
"/health"
|
|
]
|
|
|
|
# Path must not start with any invalid prefix and must not be root
|
|
path != "/" && !Enum.any?(invalid_prefixes, &String.starts_with?(path, &1))
|
|
end
|
|
|
|
defp valid_return_path?(_), do: false
|
|
|
|
@doc """
|
|
Logs the user out.
|
|
|
|
It clears all session data for safety. See renew_session.
|
|
"""
|
|
def log_out_user(conn) do
|
|
user_token = get_session(conn, :user_token)
|
|
user_token && Accounts.delete_user_session_token(user_token)
|
|
|
|
_ =
|
|
if live_socket_id = get_session(conn, :live_socket_id) do
|
|
ToweropsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
|
end
|
|
|
|
conn
|
|
|> renew_session(nil)
|
|
|> delete_resp_cookie(@remember_me_cookie)
|
|
|> redirect(to: ~p"/users/log-in")
|
|
end
|
|
|
|
@doc """
|
|
Authenticates the user by looking into the session and remember me token.
|
|
|
|
Will reissue the session token if it is older than the configured age.
|
|
Handles impersonation by checking session state.
|
|
"""
|
|
def fetch_current_scope_for_user(conn, _opts) do
|
|
# Check if currently impersonating
|
|
if get_session(conn, :impersonating) do
|
|
superuser_id = get_session(conn, :superuser_id)
|
|
target_user_id = get_session(conn, :target_user_id)
|
|
|
|
fetch_impersonation_scope(conn, superuser_id, target_user_id)
|
|
else
|
|
# Normal authentication flow
|
|
with {token, conn} <- ensure_user_token(conn),
|
|
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
|
|
conn
|
|
|> assign(:current_scope, Scope.for_user(user))
|
|
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
|
else
|
|
nil -> assign(conn, :current_scope, Scope.for_user(nil))
|
|
end
|
|
end
|
|
end
|
|
|
|
defp fetch_impersonation_scope(conn, superuser_id, target_user_id)
|
|
when is_binary(superuser_id) and is_binary(target_user_id) do
|
|
with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id),
|
|
target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do
|
|
assign(conn, :current_scope, Scope.for_impersonation(superuser, target_user))
|
|
else
|
|
_ ->
|
|
# Impersonation invalid, clear it
|
|
clear_impersonation_session(conn)
|
|
end
|
|
end
|
|
|
|
defp fetch_impersonation_scope(conn, _superuser_id, _target_user_id) do
|
|
# Missing IDs, clear invalid impersonation state
|
|
clear_impersonation_session(conn)
|
|
end
|
|
|
|
defp clear_impersonation_session(conn) do
|
|
conn
|
|
|> delete_session(:superuser_id)
|
|
|> delete_session(:target_user_id)
|
|
|> delete_session(:impersonating)
|
|
|> assign(:current_scope, Scope.for_user(nil))
|
|
end
|
|
|
|
defp ensure_user_token(conn) do
|
|
if token = get_session(conn, :user_token) do
|
|
{token, conn}
|
|
else
|
|
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
|
|
|
if token = conn.cookies[@remember_me_cookie] do
|
|
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
|
|
end
|
|
end
|
|
end
|
|
|
|
# Reissue the session token if it is older than the configured reissue age.
|
|
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
|
|
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
|
|
|
|
if token_age >= @session_reissue_age_in_days do
|
|
create_or_extend_session(conn, user, %{})
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
# This function is the one responsible for creating session tokens
|
|
# and storing them safely in the session and cookies. It may be called
|
|
# either when logging in, during sudo mode, or to renew a session which
|
|
# will soon expire.
|
|
#
|
|
# When the session is created, rather than extended, the renew_session
|
|
# 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, 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)
|
|
|> maybe_write_remember_me_cookie(token, params, remember_me)
|
|
end
|
|
|
|
# Do not renew session if the user is already logged in
|
|
# to prevent CSRF errors or data being lost in tabs that are still open
|
|
defp renew_session(conn, user)
|
|
when is_map_key(conn.assigns, :current_scope) and not is_nil(conn.assigns.current_scope) and
|
|
not is_nil(conn.assigns.current_scope.user) and conn.assigns.current_scope.user.id == user.id do
|
|
conn
|
|
end
|
|
|
|
# This function renews the session ID and erases the whole
|
|
# session to avoid fixation attacks. If there is any data
|
|
# in the session you may want to preserve after log in/log out,
|
|
# you must explicitly fetch the session data before clearing
|
|
# and then immediately set it after clearing, for example:
|
|
#
|
|
# defp renew_session(conn, _user) do
|
|
# delete_csrf_token()
|
|
# preferred_locale = get_session(conn, :preferred_locale)
|
|
#
|
|
# conn
|
|
# |> configure_session(renew: true)
|
|
# |> clear_session()
|
|
# |> put_session(:preferred_locale, preferred_locale)
|
|
# end
|
|
#
|
|
defp renew_session(conn, _user) do
|
|
delete_csrf_token()
|
|
|
|
# Preserve return path across session renewal for sudo mode re-auth
|
|
user_return_to = get_session(conn, :user_return_to)
|
|
|
|
conn =
|
|
conn
|
|
|> configure_session(renew: true)
|
|
|> clear_session()
|
|
|
|
if user_return_to do
|
|
put_session(conn, :user_return_to, user_return_to)
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
|
do: write_remember_me_cookie(conn, token)
|
|
|
|
defp maybe_write_remember_me_cookie(conn, token, _params, true), do: write_remember_me_cookie(conn, token)
|
|
|
|
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
|
|
|
|
defp maybe_set_default_organization(conn, user) do
|
|
# Set the first organization in the session to support routes that require organization context
|
|
case Towerops.Organizations.list_user_organizations(user.id) do
|
|
[first_org | _] ->
|
|
put_session(conn, :current_organization_id, first_org.id)
|
|
|
|
[] ->
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp write_remember_me_cookie(conn, token) do
|
|
conn
|
|
|> put_session(:user_remember_me, true)
|
|
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
|
|
end
|
|
|
|
defp put_token_in_session(conn, token) do
|
|
put_session(conn, :user_token, token)
|
|
end
|
|
|
|
@doc """
|
|
Plug for routes that require sudo mode.
|
|
|
|
Checks if the user's sudo mode is active (within 10 minutes).
|
|
If TOTP is enabled, redirects to TOTP-only verification page.
|
|
If TOTP is not enabled, redirects to enrollment.
|
|
"""
|
|
def require_sudo_mode(conn, _opts) do
|
|
user = conn.assigns.current_scope.user
|
|
|
|
case {Accounts.sudo_mode?(user, -10), Accounts.totp_enabled?(user)} do
|
|
{true, _} ->
|
|
conn
|
|
|
|
{false, true} ->
|
|
conn
|
|
|> put_flash(:error, t_auth("Please verify your identity to continue."))
|
|
|> maybe_store_return_to()
|
|
|> redirect(to: ~p"/users/sudo-verify")
|
|
|> halt()
|
|
|
|
{false, false} ->
|
|
conn
|
|
|> put_flash(:error, t_auth("Two-factor authentication is required for this action."))
|
|
|> redirect(to: ~p"/account/totp-enrollment")
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Plug for routes that require the user to not be authenticated.
|
|
"""
|
|
def redirect_if_user_is_authenticated(conn, _opts) do
|
|
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
|
user = conn.assigns.current_scope.user
|
|
|
|
conn
|
|
|> redirect(to: signed_in_path(user))
|
|
|> halt()
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp signed_in_path(user) when is_struct(user) do
|
|
# Check if user has TOTP enabled (mandatory for all users)
|
|
if Accounts.totp_enabled?(user) do
|
|
# Get user's organizations (ordered by most recently joined first)
|
|
case Towerops.Organizations.list_user_organizations(user.id) do
|
|
[_first_org | _] ->
|
|
# Dashboard page
|
|
~p"/dashboard"
|
|
|
|
[] ->
|
|
# No organizations yet, go to org list
|
|
~p"/orgs"
|
|
end
|
|
else
|
|
~p"/account/totp-enrollment"
|
|
end
|
|
end
|
|
|
|
defp signed_in_path(_), do: ~p"/orgs"
|
|
|
|
@doc """
|
|
Plug for routes that require the user to be authenticated.
|
|
"""
|
|
def set_user_locale(conn, _opts) do
|
|
if conn.assigns[:current_scope] && conn.assigns.current_scope.user do
|
|
locale = conn.assigns.current_scope.user.language || "en"
|
|
Gettext.put_locale(ToweropsWeb.Gettext, locale)
|
|
end
|
|
|
|
conn
|
|
end
|
|
|
|
def require_authenticated_user(conn, _opts) do
|
|
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
|
conn
|
|
else
|
|
conn
|
|
|> put_flash(:error, t_auth("You must log in to access this page."))
|
|
|> maybe_store_return_to()
|
|
|> redirect(to: ~p"/users/log-in")
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Plug for routes that require the user to be a superuser.
|
|
"""
|
|
def require_superuser(conn, _opts) do
|
|
user = conn.assigns.current_scope && conn.assigns.current_scope.user
|
|
|
|
if user && user.is_superuser do
|
|
conn
|
|
else
|
|
conn
|
|
|> put_flash(:error, t_auth("You must be a superuser to access this page."))
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
|
put_session(conn, :user_return_to, current_path(conn))
|
|
end
|
|
|
|
defp maybe_store_return_to(conn), do: conn
|
|
|
|
@doc """
|
|
Plug to store the current path for LiveView routes.
|
|
|
|
This runs in the pipeline before LiveView mount, allowing us to store
|
|
the return path in the session before on_mount callbacks run. Always
|
|
overwrites any existing return path to ensure sudo mode redirects work correctly.
|
|
"""
|
|
def store_return_to_for_liveview(conn, _opts) do
|
|
# Skip authentication-related, public, dev, and asset paths
|
|
skip_paths = [
|
|
"/users/log-in",
|
|
"/users/register",
|
|
"/users/reset-password",
|
|
"/users/confirm",
|
|
"/users/sudo",
|
|
"/docs/api",
|
|
"/privacy",
|
|
"/terms",
|
|
"/help",
|
|
"/dev/",
|
|
"/assets/",
|
|
"/health"
|
|
]
|
|
|
|
should_store =
|
|
conn.method == "GET" &&
|
|
!Enum.any?(skip_paths, &String.starts_with?(conn.request_path, &1)) &&
|
|
conn.request_path != "/"
|
|
|
|
if should_store do
|
|
put_session(conn, :user_return_to, current_path(conn))
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Plug to load the current organization from the URL slug.
|
|
|
|
Expects the organization slug to be in path_params as "org_slug".
|
|
Verifies that the current user is a member of the organization.
|
|
"""
|
|
def load_current_organization(conn, _opts) do
|
|
org_slug = conn.path_params["org_slug"]
|
|
scope = conn.assigns.current_scope
|
|
|
|
if org_slug && scope && scope.user do
|
|
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
|
|
membership = Towerops.Organizations.get_membership(organization.id, scope.user.id)
|
|
|
|
if membership do
|
|
alias Towerops.Organizations.Policy
|
|
|
|
conn
|
|
|> assign(:current_scope, Scope.put_organization(scope, organization))
|
|
|> assign(:current_membership, membership)
|
|
|> assign(:can_view_financials, Policy.can_view_financials?(membership))
|
|
|> put_session(:current_organization_id, organization.id)
|
|
else
|
|
conn
|
|
|> put_flash(:error, t_auth("You don't have access to this organization."))
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
else
|
|
conn
|
|
|> put_flash(:error, t_auth("Organization not found."))
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn
|
|
|> put_flash(:error, t_auth("Organization not found."))
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
|
|
@doc """
|
|
Mounts authentication state for LiveView routes.
|
|
|
|
## Hooks
|
|
|
|
* `:redirect_if_user_is_authenticated` - Redirects authenticated users
|
|
* `:require_authenticated_user` - Requires user authentication
|
|
* `:load_current_organization` - Loads organization from URL slug
|
|
* `:require_organization_owner` - Requires user to be an owner of the current organization
|
|
|
|
"""
|
|
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
|
socket = mount_current_scope(socket, session)
|
|
|
|
if socket.assigns.current_scope && socket.assigns.current_scope.user do
|
|
user = socket.assigns.current_scope.user
|
|
{:halt, LiveView.redirect(socket, to: signed_in_path(user))}
|
|
else
|
|
{:cont, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:require_authenticated_user, _params, session, socket) do
|
|
socket = mount_current_scope(socket, session)
|
|
|
|
if socket.assigns.current_scope && socket.assigns.current_scope.user do
|
|
locale = socket.assigns.current_scope.user.language || "en"
|
|
Gettext.put_locale(ToweropsWeb.Gettext, locale)
|
|
{:cont, socket}
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("You must log in to access this page."))
|
|
|> LiveView.redirect(to: ~p"/users/log-in")
|
|
|
|
{:halt, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:load_current_organization, %{"org_slug" => org_slug}, _session, socket) do
|
|
scope = socket.assigns.current_scope
|
|
|
|
if org_slug && scope && scope.user do
|
|
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
|
|
membership = Towerops.Organizations.get_membership(organization.id, scope.user.id)
|
|
|
|
if membership do
|
|
{:cont,
|
|
socket
|
|
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|
|
|> Phoenix.Component.assign(:current_membership, membership)}
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("You don't have access to this organization."))
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("Organization not found."))
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("Organization not found."))
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
|
|
def on_mount(:load_current_organization, _params, _session, socket) do
|
|
{:cont, socket}
|
|
end
|
|
|
|
def on_mount(:subscribe_to_status_updates, _params, _session, socket) do
|
|
organization = socket.assigns[:current_scope] && socket.assigns.current_scope.organization
|
|
|
|
alert_count =
|
|
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
|
|
|
|
Process.put(:unresolved_alert_count, alert_count)
|
|
|
|
socket =
|
|
socket
|
|
|> maybe_subscribe_to_status_updates(organization)
|
|
|> attach_status_title_hook(organization)
|
|
|> attach_alert_count_hook(organization)
|
|
|
|
{:cont, socket}
|
|
end
|
|
|
|
def on_mount(:load_default_organization, _params, session, socket) do
|
|
user = socket.assigns.current_scope && socket.assigns.current_scope.user
|
|
|
|
if user do
|
|
load_organization_for_user(socket, session, user)
|
|
else
|
|
{:cont, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:require_superuser, _params, session, socket) do
|
|
socket = mount_current_scope(socket, session)
|
|
user = socket.assigns.current_scope && socket.assigns.current_scope.user
|
|
|
|
if user && user.is_superuser do
|
|
{:cont, socket}
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("You must be a superuser to access this page."))
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:require_sudo_mode, _params, session, socket) do
|
|
# Force refetch user from session to ensure we have latest data (including last_sudo_at)
|
|
# We need to refetch because the user may have just verified sudo mode
|
|
scope = build_scope_from_session(session)
|
|
socket = Phoenix.Component.assign(socket, :current_scope, scope)
|
|
user = scope && scope.user
|
|
|
|
case {user && Accounts.sudo_mode?(user, -10), user && Accounts.totp_enabled?(user)} do
|
|
{true, _} ->
|
|
{:cont, socket}
|
|
|
|
{false, true} ->
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("Please verify your identity to continue."))
|
|
|> LiveView.redirect(to: ~p"/users/sudo-verify")
|
|
|
|
{:halt, socket}
|
|
|
|
{false, false} ->
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("Two-factor authentication is required for this action."))
|
|
|> LiveView.redirect(to: ~p"/account/totp-enrollment")
|
|
|
|
{:halt, socket}
|
|
|
|
_ ->
|
|
{:halt, LiveView.redirect(socket, to: ~p"/users/log-in")}
|
|
end
|
|
end
|
|
|
|
def on_mount(:require_totp_enrollment, _params, _session, socket) do
|
|
scope = socket.assigns.current_scope
|
|
user = scope && scope.user
|
|
|
|
# Skip TOTP requirement when impersonating - superuser has already authenticated
|
|
if scope && scope.impersonating? do
|
|
{:cont, socket}
|
|
else
|
|
if user && !Accounts.totp_enabled?(user) do
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("You must set up two-factor authentication to continue."))
|
|
|> LiveView.redirect(to: ~p"/account/totp-enrollment")
|
|
|
|
{:halt, socket}
|
|
else
|
|
{:cont, socket}
|
|
end
|
|
end
|
|
end
|
|
|
|
def on_mount(:require_organization_owner, _params, _session, socket) do
|
|
membership = Map.get(socket.assigns, :current_membership)
|
|
|
|
if membership && membership.role == :owner do
|
|
{:cont, socket}
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, t_auth("You must be an organization owner to access this page."))
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
end
|
|
|
|
def on_mount(:load_cookie_consent, _params, session, socket) do
|
|
requires_consent = Map.get(session, "requires_cookie_consent", false)
|
|
|
|
# Store in process dictionary so layouts can access without explicit component attribute
|
|
Process.put(:requires_cookie_consent, requires_consent)
|
|
|
|
socket =
|
|
socket
|
|
|> Phoenix.Component.assign(:requires_cookie_consent, requires_consent)
|
|
|> LiveView.attach_hook(:persist_cookie_consent, :handle_params, fn _params, _url, socket ->
|
|
# Ensure the assign persists across navigation
|
|
socket = Phoenix.Component.assign(socket, :requires_cookie_consent, requires_consent)
|
|
{:cont, socket}
|
|
end)
|
|
|
|
{:cont, socket}
|
|
end
|
|
|
|
def on_mount(:handle_policy_consent, _params, _session, socket) do
|
|
# Attach LiveView event handler for accepting updated policies
|
|
socket =
|
|
LiveView.attach_hook(socket, :policy_consent_handler, :handle_event, fn
|
|
"accept_updated_policies", %{"user-id" => user_id}, socket ->
|
|
# Grant consent for all policies that need re-consent
|
|
policies_needing_consent = Map.get(socket.assigns, :policies_needing_consent, [])
|
|
|
|
Enum.each(policies_needing_consent, fn policy_type ->
|
|
Accounts.grant_consent(user_id, policy_type)
|
|
end)
|
|
|
|
# Clear the policies_needing_consent list and show success message
|
|
socket =
|
|
socket
|
|
|> Phoenix.Component.assign(:policies_needing_consent, [])
|
|
|> LiveView.put_flash(:info, t_auth("Thank you for accepting the updated policies."))
|
|
|
|
{:halt, socket}
|
|
|
|
_event, _params, socket ->
|
|
{:cont, socket}
|
|
end)
|
|
|
|
{:cont, socket}
|
|
end
|
|
|
|
defp load_organization_for_user(socket, session, user) do
|
|
org_id = session["current_organization_id"]
|
|
organization = find_user_organization(org_id, user.id)
|
|
|
|
case organization do
|
|
nil ->
|
|
halt_with_error(socket, "Please select an organization.")
|
|
|
|
org ->
|
|
assign_organization_and_membership(socket, org, user.id)
|
|
end
|
|
end
|
|
|
|
defp find_user_organization(org_id, user_id) when is_binary(org_id) do
|
|
# Try session org first
|
|
org =
|
|
try do
|
|
Towerops.Organizations.get_organization!(org_id)
|
|
rescue
|
|
Ecto.NoResultsError -> nil
|
|
end
|
|
|
|
# Verify user has access to this org
|
|
if org do
|
|
membership = Towerops.Organizations.get_membership(org.id, user_id)
|
|
if membership, do: org
|
|
end
|
|
end
|
|
|
|
defp find_user_organization(_org_id, user_id) do
|
|
user = Accounts.get_user(user_id)
|
|
default_org_id = user && user.default_organization_id
|
|
|
|
# Try user's default organization first
|
|
with true <- not is_nil(default_org_id),
|
|
default_org = try_get_organization(default_org_id),
|
|
true <- not is_nil(default_org),
|
|
membership = Towerops.Organizations.get_membership(default_org.id, user_id),
|
|
true <- not is_nil(membership) do
|
|
default_org
|
|
else
|
|
_ -> fallback_to_first_org(user_id)
|
|
end
|
|
end
|
|
|
|
defp try_get_organization(org_id) do
|
|
Towerops.Organizations.get_organization!(org_id)
|
|
rescue
|
|
Ecto.NoResultsError -> nil
|
|
end
|
|
|
|
defp fallback_to_first_org(user_id) do
|
|
case Towerops.Organizations.list_user_organizations(user_id) do
|
|
[first_org | _] -> first_org
|
|
[] -> nil
|
|
end
|
|
end
|
|
|
|
defp assign_organization_and_membership(socket, organization, user_id) do
|
|
membership = Towerops.Organizations.get_membership(organization.id, user_id)
|
|
|
|
case membership do
|
|
nil ->
|
|
halt_with_error(socket, "You don't have access to any organizations.")
|
|
|
|
mem ->
|
|
alias Towerops.Organizations.Policy
|
|
|
|
scope = socket.assigns.current_scope
|
|
|
|
{:cont,
|
|
socket
|
|
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|
|
|> Phoenix.Component.assign(:current_membership, mem)
|
|
|> Phoenix.Component.assign(:can_view_financials, Policy.can_view_financials?(mem))}
|
|
end
|
|
end
|
|
|
|
defp halt_with_error(socket, message) do
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, message)
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
|
|
defp mount_current_scope(socket, session) do
|
|
socket =
|
|
Phoenix.Component.assign_new(socket, :current_scope, fn ->
|
|
build_scope_from_session(session)
|
|
end)
|
|
|
|
# Get timezone from user profile, connect params, or default to UTC
|
|
user = socket.assigns.current_scope && socket.assigns.current_scope.user
|
|
connect_timezone = get_in(socket.private, [:connect_params, "timezone"])
|
|
|
|
timezone =
|
|
cond do
|
|
user && user.timezone -> user.timezone
|
|
connect_timezone -> connect_timezone
|
|
true -> "UTC"
|
|
end
|
|
|
|
# If user is logged in but doesn't have a timezone set, update their profile
|
|
_ =
|
|
if user && !user.timezone && connect_timezone do
|
|
Task.start(fn ->
|
|
Accounts.update_user_profile(user, %{timezone: connect_timezone})
|
|
end)
|
|
end
|
|
|
|
Phoenix.Component.assign(socket, :timezone, timezone)
|
|
end
|
|
|
|
defp build_scope_from_session(session) do
|
|
if session["impersonating"] do
|
|
build_impersonation_scope(session)
|
|
else
|
|
build_normal_scope(session)
|
|
end
|
|
end
|
|
|
|
defp build_impersonation_scope(session) do
|
|
superuser_id = session["superuser_id"]
|
|
target_user_id = session["target_user_id"]
|
|
|
|
if superuser_id && target_user_id do
|
|
fetch_impersonation_users(superuser_id, target_user_id)
|
|
else
|
|
Scope.for_user(nil)
|
|
end
|
|
end
|
|
|
|
defp fetch_impersonation_users(superuser_id, target_user_id) do
|
|
with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id),
|
|
target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do
|
|
Scope.for_impersonation(superuser, target_user)
|
|
else
|
|
_ -> Scope.for_user(nil)
|
|
end
|
|
end
|
|
|
|
defp build_normal_scope(session) do
|
|
case session["user_token"] do
|
|
nil -> Scope.for_user(nil)
|
|
user_token -> fetch_user_from_token(user_token)
|
|
end
|
|
end
|
|
|
|
defp fetch_user_from_token(user_token) do
|
|
case Accounts.get_user_by_session_token(user_token) do
|
|
{user, _token_inserted_at} -> Scope.for_user(user)
|
|
nil -> Scope.for_user(nil)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Start impersonating a user as a superuser.
|
|
|
|
Stores the original superuser ID in the session and updates the current scope
|
|
to show the impersonated user. Creates an audit log entry.
|
|
"""
|
|
def start_impersonation(conn, target_user_id) do
|
|
superuser = conn.assigns.current_scope.user
|
|
target_user = Accounts.get_user!(target_user_id)
|
|
|
|
# Prevent impersonating self
|
|
if target_user.id == superuser.id do
|
|
conn
|
|
|> put_flash(:error, t_admin("You cannot impersonate yourself."))
|
|
|> redirect(to: ~p"/admin/users")
|
|
|> halt()
|
|
else
|
|
# Create audit log
|
|
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
|
|
|
|
Towerops.Admin.create_audit_log(%{
|
|
action: "impersonate_start",
|
|
superuser_id: superuser.id,
|
|
target_user_id: target_user.id,
|
|
metadata: %{target_email: target_user.email, target_is_superuser: target_user.is_superuser},
|
|
ip_address: ip
|
|
})
|
|
|
|
# Store superuser ID and target user ID in session and update scope
|
|
conn
|
|
|> put_session(:superuser_id, superuser.id)
|
|
|> put_session(:target_user_id, target_user.id)
|
|
|> put_session(:impersonating, true)
|
|
|> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
|
|
|> maybe_set_default_organization(target_user)
|
|
|> put_flash(:info, t_admin("Now impersonating %{email}", email: target_user.email))
|
|
|> redirect(to: ~p"/orgs")
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Stop impersonating and restore the superuser session.
|
|
|
|
Removes impersonation state from the session and restores the original superuser
|
|
scope. Creates an audit log entry.
|
|
"""
|
|
def stop_impersonation(conn) do
|
|
superuser_id = get_session(conn, :superuser_id)
|
|
target_user = conn.assigns.current_scope.user
|
|
superuser = Accounts.get_user!(superuser_id)
|
|
|
|
# Create audit log
|
|
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
|
|
|
|
Towerops.Admin.create_audit_log(%{
|
|
action: "impersonate_end",
|
|
superuser_id: superuser_id,
|
|
target_user_id: target_user.id,
|
|
metadata: %{target_email: target_user.email},
|
|
ip_address: ip
|
|
})
|
|
|
|
conn
|
|
|> delete_session(:superuser_id)
|
|
|> delete_session(:target_user_id)
|
|
|> delete_session(:impersonating)
|
|
|> assign(:current_scope, Scope.for_user(superuser))
|
|
|> maybe_set_default_organization(superuser)
|
|
|> put_flash(:info, t_admin("Stopped impersonating"))
|
|
|> redirect(to: signed_in_path(superuser))
|
|
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"
|
|
|
|
defp maybe_subscribe_to_status_updates(socket, organization) do
|
|
if organization && LiveView.connected?(socket) do
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "organization:#{organization.id}:alerts")
|
|
end
|
|
|
|
socket
|
|
end
|
|
|
|
defp attach_status_title_hook(socket, nil), do: socket
|
|
|
|
defp attach_status_title_hook(socket, organization) do
|
|
LiveView.attach_hook(socket, :status_title_updates, :handle_info, fn
|
|
{:alert_changed, org_id}, socket when org_id == organization.id ->
|
|
emoji = StatusHelpers.status_emoji(organization)
|
|
{:halt, LiveView.push_event(socket, "update_status_emoji", %{emoji: emoji})}
|
|
|
|
_message, socket ->
|
|
{:cont, socket}
|
|
end)
|
|
end
|
|
|
|
defp attach_alert_count_hook(socket, nil), do: socket
|
|
|
|
defp attach_alert_count_hook(socket, organization) do
|
|
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
|
|
{:alert_changed, org_id}, socket when org_id == organization.id ->
|
|
count = Towerops.Alerts.count_active_alerts(organization.id)
|
|
Process.put(:unresolved_alert_count, count)
|
|
{:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)}
|
|
|
|
_message, socket ->
|
|
{:cont, socket}
|
|
end)
|
|
end
|
|
end
|