- Client IP: only trust X-Forwarded-For from RFC 1918 proxy IPs - Webhook auth: handle nil/blank secret with controlled error, not 500 crash - Sudo redirect: reuse validated return_path? from login to prevent open redirect - Map live: remove redundant inline script (ensureLeaflet hook handles loading) - Bang calls: convert crash-prone exact matches to case in QR live and API controllers
136 lines
4.2 KiB
Elixir
136 lines
4.2 KiB
Elixir
defmodule ToweropsWeb.UserSudoController do
|
|
@moduledoc """
|
|
Controller for sudo mode verification.
|
|
|
|
Handles re-authentication for sensitive operations by requiring TOTP verification
|
|
(no recovery codes allowed).
|
|
"""
|
|
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.UserAuth, only: [require_authenticated_user: 2, valid_return_path?: 1]
|
|
|
|
alias Towerops.Accounts
|
|
|
|
plug :require_authenticated_user
|
|
|
|
def new(conn, _params) do
|
|
user = conn.assigns.current_scope.user
|
|
|
|
cond do
|
|
# Already in sudo mode (verified within last 10 minutes), redirect to destination
|
|
recently_verified_sudo?(user) ->
|
|
return_to = sudo_return_path(conn)
|
|
|
|
conn
|
|
|> delete_session(:user_return_to)
|
|
|> redirect(to: return_to)
|
|
|
|
# No TOTP devices, must enroll first
|
|
!Accounts.totp_enabled?(user) ->
|
|
conn
|
|
|> put_flash(:error, t("Two-factor authentication is required for this action."))
|
|
|> redirect(to: ~p"/account/totp-enrollment")
|
|
|
|
# Show verification form
|
|
true ->
|
|
form = Phoenix.Component.to_form(%{}, as: "user")
|
|
render(conn, :verify, form: form)
|
|
end
|
|
end
|
|
|
|
# Check if user verified sudo mode within the last 10 minutes
|
|
# Uses last_sudo_at timestamp instead of authenticated_at to distinguish
|
|
# between regular login and explicit sudo verification
|
|
defp recently_verified_sudo?(%{last_sudo_at: nil}), do: false
|
|
|
|
defp recently_verified_sudo?(%{last_sudo_at: last_sudo_at}) do
|
|
ten_minutes_ago = DateTime.add(DateTime.utc_now(), -10, :minute)
|
|
DateTime.after?(last_sudo_at, ten_minutes_ago)
|
|
end
|
|
|
|
def verify(conn, %{"user" => %{"totp_code" => totp_code}}) do
|
|
user = conn.assigns.current_scope.user
|
|
|
|
case Accounts.verify_totp_only(user, totp_code) do
|
|
{:ok, _verified_user} ->
|
|
# Grant sudo mode (updates last_sudo_at in database)
|
|
case Accounts.grant_sudo_mode(user) do
|
|
{:ok, _updated_user} ->
|
|
# Redirect to return_to path or default
|
|
return_to = sudo_return_path(conn)
|
|
|
|
conn
|
|
|> put_flash(:info, t("Identity verified."))
|
|
|> delete_session(:user_return_to)
|
|
|> redirect(to: return_to)
|
|
|
|
{:error, _changeset} ->
|
|
# Shouldn't happen, but handle gracefully
|
|
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
|
|
|
conn
|
|
|> put_flash(:error, t("Failed to grant sudo mode. Please try again."))
|
|
|> render(:verify, form: form)
|
|
end
|
|
|
|
{:error, :recovery_code_not_allowed} ->
|
|
# Log failed sudo attempt (recovery code used)
|
|
_ = log_failed_sudo_attempt(conn, user, "recovery_code_not_allowed")
|
|
|
|
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
|
|
|
conn
|
|
|> put_flash(
|
|
:error,
|
|
"Recovery codes are not allowed for sudo mode verification. Please use your authenticator app."
|
|
)
|
|
|> render(:verify, form: form)
|
|
|
|
{:error, :invalid_code} ->
|
|
# Log failed sudo attempt (invalid code)
|
|
_ = log_failed_sudo_attempt(conn, user, "invalid_code")
|
|
|
|
form = Phoenix.Component.to_form(%{"totp_code" => totp_code}, as: "user")
|
|
|
|
conn
|
|
|> put_flash(:error, t("Invalid authentication code. Please try again."))
|
|
|> render(:verify, form: form)
|
|
end
|
|
end
|
|
|
|
def verify(conn, _params) do
|
|
# Handle missing totp_code parameter
|
|
form = Phoenix.Component.to_form(%{}, as: "user")
|
|
render(conn, :verify, form: form)
|
|
end
|
|
|
|
defp log_failed_sudo_attempt(conn, user, reason) do
|
|
Accounts.record_login_attempt(%{
|
|
email: user.email,
|
|
success: false,
|
|
failure_reason: "sudo_verification_failed",
|
|
method: "sudo_totp",
|
|
ip_address: ToweropsWeb.RemoteIp.from_conn(conn),
|
|
user_agent: extract_user_agent(conn),
|
|
metadata: %{sudo_failure_reason: reason}
|
|
})
|
|
end
|
|
|
|
defp sudo_return_path(conn) do
|
|
path = get_session(conn, :user_return_to)
|
|
|
|
if valid_return_path?(path) do
|
|
path
|
|
else
|
|
~p"/users/settings"
|
|
end
|
|
end
|
|
|
|
defp extract_user_agent(conn) do
|
|
case Plug.Conn.get_req_header(conn, "user-agent") do
|
|
[ua | _] -> ua
|
|
[] -> nil
|
|
end
|
|
end
|
|
end
|