Implement comprehensive admin interface allowing designated superusers to view all users and organizations, impersonate users for debugging, and perform administrative operations. All superuser actions are tracked in audit logs for compliance. Features: - Superuser authentication with dedicated admin routes at /admin - User impersonation with session state preservation - Admin dashboard with system statistics - User and organization management interfaces - Comprehensive audit logging with IP tracking - Visual impersonation banner with exit capability - Security controls preventing self-impersonation and superuser-to-superuser impersonation Database: - Add is_superuser boolean field to users table - Create audit_logs table for tracking sensitive operations - Set graham@mcintire.me as initial superuser
475 lines
15 KiB
Elixir
475 lines
15 KiB
Elixir
defmodule ToweropsWeb.UserAuth do
|
|
@moduledoc false
|
|
use ToweropsWeb, :verified_routes
|
|
|
|
import Phoenix.Controller
|
|
import Plug.Conn
|
|
|
|
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: "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)
|
|
|
|
conn
|
|
|> create_or_extend_session(user, params)
|
|
|> redirect(to: user_return_to || signed_in_path(conn))
|
|
end
|
|
|
|
@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)
|
|
|
|
with {token, conn} <- ensure_user_token(conn),
|
|
{user, _token_inserted_at} <- Accounts.get_user_by_session_token(token),
|
|
superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id) do
|
|
assign(conn, :current_scope, Scope.for_impersonation(superuser, user))
|
|
else
|
|
_ ->
|
|
# Impersonation invalid, clear it
|
|
conn
|
|
|> delete_session(:superuser_id)
|
|
|> delete_session(:impersonating)
|
|
|> assign(:current_scope, Scope.for_user(nil))
|
|
end
|
|
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 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 = Accounts.generate_user_session_token(user)
|
|
remember_me = get_session(conn, :user_remember_me)
|
|
|
|
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()
|
|
|
|
conn
|
|
|> configure_session(renew: true)
|
|
|> clear_session()
|
|
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 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.
|
|
"""
|
|
def require_sudo_mode(conn, _opts) do
|
|
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
|
|
conn
|
|
else
|
|
conn
|
|
|> put_flash(:error, "You must re-authenticate 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 not be authenticated.
|
|
"""
|
|
def redirect_if_user_is_authenticated(conn, _opts) do
|
|
if conn.assigns.current_scope do
|
|
conn
|
|
|> redirect(to: signed_in_path(conn))
|
|
|> halt()
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp signed_in_path(_conn), 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, "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, "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 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"]
|
|
user = conn.assigns.current_scope && conn.assigns.current_scope.user
|
|
|
|
if org_slug && user do
|
|
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
|
|
membership = Towerops.Organizations.get_membership(organization.id, user.id)
|
|
|
|
if membership do
|
|
conn
|
|
|> assign(:current_organization, organization)
|
|
|> assign(:current_membership, membership)
|
|
else
|
|
conn
|
|
|> put_flash(:error, "You don't have access to this organization.")
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
else
|
|
conn
|
|
|> put_flash(:error, "Organization not found.")
|
|
|> redirect(to: ~p"/orgs")
|
|
|> halt()
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
conn
|
|
|> put_flash(:error, "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
|
|
|
|
"""
|
|
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
|
|
{:halt, LiveView.redirect(socket, to: signed_in_path(socket))}
|
|
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, "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
|
|
user = socket.assigns.current_scope && socket.assigns.current_scope.user
|
|
|
|
if org_slug && user do
|
|
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
|
|
membership = Towerops.Organizations.get_membership(organization.id, user.id)
|
|
|
|
if membership do
|
|
{:cont,
|
|
socket
|
|
|> Phoenix.Component.assign(:current_organization, organization)
|
|
|> Phoenix.Component.assign(:current_membership, membership)}
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, "You don't have access to this organization.")
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
else
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, "Organization not found.")
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
socket =
|
|
socket
|
|
|> LiveView.put_flash(:error, "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(: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, "You must be a superuser to access this page.")
|
|
|> LiveView.redirect(to: ~p"/orgs")
|
|
|
|
{:halt, socket}
|
|
end
|
|
end
|
|
|
|
defp mount_current_scope(socket, session) do
|
|
Phoenix.Component.assign_new(socket, :current_scope, fn ->
|
|
if user_token = session["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
|
|
else
|
|
Scope.for_user(nil)
|
|
end
|
|
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 or other superusers
|
|
if target_user.id == superuser.id do
|
|
conn
|
|
|> put_flash(:error, "You cannot impersonate yourself.")
|
|
|> redirect(to: ~p"/admin/users")
|
|
else
|
|
if target_user.is_superuser do
|
|
conn
|
|
|> put_flash(:error, "You cannot impersonate other superusers.")
|
|
|> redirect(to: ~p"/admin/users")
|
|
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},
|
|
ip_address: ip
|
|
})
|
|
|
|
# Store superuser ID in session and update scope
|
|
conn
|
|
|> put_session(:superuser_id, superuser.id)
|
|
|> put_session(:impersonating, true)
|
|
|> assign(:current_scope, Scope.for_impersonation(superuser, target_user))
|
|
|> put_flash(:info, "Now impersonating #{target_user.email}")
|
|
|> redirect(to: ~p"/orgs")
|
|
end
|
|
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
|
|
})
|
|
|
|
# Restore superuser session
|
|
conn
|
|
|> delete_session(:superuser_id)
|
|
|> delete_session(:impersonating)
|
|
|> assign(:current_scope, Scope.for_user(superuser))
|
|
|> put_flash(:info, "Stopped impersonating")
|
|
|> redirect(to: ~p"/admin")
|
|
end
|
|
end
|