963 lines
29 KiB
Elixir
963 lines
29 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
|
|
|
|
# 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: "Towerops"
|
|
]
|
|
|
|
# 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 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
|
|
conn
|
|
|> assign(:current_scope, Scope.put_organization(scope, organization))
|
|
|> assign(:current_membership, 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
|
|
{: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(: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 ->
|
|
scope = socket.assigns.current_scope
|
|
|
|
{:cont,
|
|
socket
|
|
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|
|
|> Phoenix.Component.assign(:current_membership, 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
|
|
})
|
|
|
|
# Dashboard page
|
|
redirect_path =
|
|
case Towerops.Organizations.list_user_organizations(superuser.id) do
|
|
[_first_org | _] -> ~p"/dashboard"
|
|
[] -> ~p"/orgs"
|
|
end
|
|
|
|
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: 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
|