diff --git a/lib/mix/tasks/totp_diagnose.exs b/lib/mix/tasks/totp_diagnose.exs new file mode 100644 index 00000000..5268daa3 --- /dev/null +++ b/lib/mix/tasks/totp_diagnose.exs @@ -0,0 +1,126 @@ +defmodule Mix.Tasks.Totp.Diagnose do + @shortdoc "Diagnose TOTP verification failures" + + @moduledoc """ + Diagnostic tool for TOTP verification failures. + + Usage: + mix totp.diagnose + + Example: + mix totp.diagnose user@example.com 776330 1769641093 + + This will: + 1. Check if the provided code matches the user's secret at any time within ±24 hours + 2. Report the exact time offset if found + 3. Help identify if the issue is clock drift or secret mismatch + """ + + use Mix.Task + + require Logger + + def run(args) do + Mix.Task.run("app.start") + + case args do + [email, provided_code, timestamp_str] -> + diagnose(email, provided_code, String.to_integer(timestamp_str)) + + _ -> + IO.puts(""" + Usage: mix totp.diagnose + + Example: + mix totp.diagnose user@example.com 776330 1769641093 + """) + end + end + + defp diagnose(email, provided_code, server_timestamp) do + IO.puts("\n=== TOTP Diagnostic Tool ===\n") + IO.puts("Email: #{email}") + IO.puts("Provided code: #{provided_code}") + IO.puts("Server time: #{DateTime.from_unix!(server_timestamp)} (unix: #{server_timestamp})") + + user = Towerops.Accounts.get_user_by_email(email) + + if is_nil(user) do + IO.puts("\n❌ User not found!") + exit({:shutdown, 1}) + end + + if is_nil(user.totp_secret) do + IO.puts("\n❌ User does not have TOTP enabled!") + exit({:shutdown, 1}) + end + + IO.puts("\nTOTP enabled: Yes") + IO.puts("Secret length: #{byte_size(user.totp_secret)} bytes") + + # Check current expected codes + IO.puts("\n--- Expected codes at failure time ---") + expected_current = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp) + expected_prev = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp - 30) + expected_next = NimbleTOTP.verification_code(user.totp_secret, time: server_timestamp + 30) + + IO.puts("Current window: #{expected_current}") + IO.puts("Previous (-30s): #{expected_prev}") + IO.puts("Next (+30s): #{expected_next}") + + # Search for when the provided code would have been valid + IO.puts("\n--- Searching for provided code in wider time window ---") + IO.puts("Scanning ±24 hours (2880 time steps)...") + + found_time = search_for_code(user.totp_secret, provided_code, server_timestamp) + + if found_time do + offset_seconds = found_time - server_timestamp + offset_minutes = Float.round(offset_seconds / 60, 1) + + IO.puts("\n✓ FOUND! The provided code was valid at:") + IO.puts(" Time: #{DateTime.from_unix!(found_time)}") + IO.puts(" Offset: #{offset_seconds} seconds (#{offset_minutes} minutes)") + + cond do + abs(offset_seconds) <= 120 -> + IO.puts("\n⚠️ This should have been accepted (within ±2 minute window)") + IO.puts(" Possible bug in verification logic!") + + abs(offset_seconds) <= 900 -> + IO.puts("\n⏰ Clock drift detected: #{abs(offset_minutes)} minutes") + IO.puts(" The client's clock is off by more than 2 minutes.") + + true -> + IO.puts("\n⏰ Extreme clock drift: #{abs(offset_minutes)} minutes") + IO.puts(" The client's clock is significantly incorrect.") + end + else + IO.puts("\n❌ NOT FOUND in ±24 hour window") + IO.puts("\nThis strongly suggests a SECRET MISMATCH:") + IO.puts(" - The secret in the database differs from what's in the authenticator app") + IO.puts(" - The user may have scanned a different QR code") + IO.puts(" - The secret may have been regenerated without updating the authenticator") + IO.puts("\nRecommended action:") + IO.puts(" 1. Have the user disable TOTP") + IO.puts(" 2. Re-enable TOTP and scan the new QR code") + IO.puts(" 3. Verify the new code works before proceeding") + end + + IO.puts("\n") + end + + defp search_for_code(secret, provided_code, server_timestamp) do + # Check ±24 hours (86400 seconds = 2880 time steps of 30 seconds) + time_steps = 2880 + + Enum.find_value(-time_steps..time_steps, fn step -> + test_time = server_timestamp + step * 30 + expected = NimbleTOTP.verification_code(secret, time: test_time) + + if expected == provided_code do + test_time + end + end) + end +end diff --git a/lib/mix/tasks/totp_time_check.exs b/lib/mix/tasks/totp_time_check.exs new file mode 100644 index 00000000..99ee1488 --- /dev/null +++ b/lib/mix/tasks/totp_time_check.exs @@ -0,0 +1,75 @@ +defmodule Mix.Tasks.Totp.TimeCheck do + @shortdoc "Check server time and TOTP time synchronization" + + @moduledoc """ + Diagnostic tool to check if the server's clock is correct. + + Usage: + mix totp.time_check + + This will: + 1. Show the server's current time (BEAM VM time) + 2. Show the system's current time + 3. Calculate what TOTP codes should be valid right now for a test secret + """ + + use Mix.Task + + require Logger + + def run(_args) do + Mix.Task.run("app.start") + + IO.puts("\n=== Time Synchronization Check ===\n") + + # Get different time sources + system_time = System.system_time(:second) + os_time = :os.system_time(:second) + datetime_now = DateTime.utc_now() + + IO.puts("System.system_time(:second): #{system_time}") + IO.puts(" → #{DateTime.from_unix!(system_time)}") + IO.puts("") + IO.puts(":os.system_time(:second): #{os_time}") + IO.puts(" → #{DateTime.from_unix!(os_time)}") + IO.puts("") + IO.puts("DateTime.utc_now(): #{datetime_now}") + IO.puts(" → unix: #{DateTime.to_unix(datetime_now)}") + IO.puts("") + + # Check if they match + if system_time == os_time and system_time == DateTime.to_unix(datetime_now) do + IO.puts("✓ All time sources agree") + else + IO.puts("⚠️ WARNING: Time sources disagree!") + IO.puts(" System vs OS diff: #{system_time - os_time} seconds") + IO.puts(" System vs DateTime diff: #{system_time - DateTime.to_unix(datetime_now)} seconds") + end + + IO.puts("") + + # Show what TOTP codes would be generated now + test_secret = NimbleTOTP.secret() + current_code = NimbleTOTP.verification_code(test_secret, time: system_time) + prev_code = NimbleTOTP.verification_code(test_secret, time: system_time - 30) + next_code = NimbleTOTP.verification_code(test_secret, time: system_time + 30) + + IO.puts("Test TOTP codes (with random secret):") + IO.puts(" Current window: #{current_code}") + IO.puts(" Previous (-30s): #{prev_code}") + IO.puts(" Next (+30s): #{next_code}") + IO.puts("") + + # Recommend action + IO.puts("=== Recommendations ===") + IO.puts("") + IO.puts("Compare the server time above with actual UTC time:") + IO.puts(" Current actual UTC: Run 'date -u' on your local machine") + IO.puts("") + IO.puts("If the server time is wrong:") + IO.puts(" 1. Check K8s node time: kubectl exec -it -- date -u") + IO.puts(" 2. Check if NTP is running on the node") + IO.puts(" 3. Restart the pod to resync time") + IO.puts("") + end +end diff --git a/lib/towerops_web/controllers/health_controller.ex b/lib/towerops_web/controllers/health_controller.ex index 579188f9..1d88e1b7 100644 --- a/lib/towerops_web/controllers/health_controller.ex +++ b/lib/towerops_web/controllers/health_controller.ex @@ -78,4 +78,29 @@ defmodule ToweropsWeb.HealthController do defp redis_status_string(:ok), do: "connected" defp redis_status_string(:error), do: "disconnected" defp redis_status_string(:not_configured), do: "not_configured" + + @doc """ + Time diagnostic endpoint for debugging TOTP issues. + Returns server time from multiple sources. + """ + def time(conn, _params) do + system_time = System.system_time(:second) + os_time = :os.system_time(:second) + datetime_now = DateTime.utc_now() + + conn + |> put_resp_content_type("application/json") + |> send_resp( + 200, + Jason.encode!(%{ + system_time: system_time, + system_time_iso: system_time |> DateTime.from_unix!() |> DateTime.to_iso8601(), + os_time: os_time, + os_time_iso: os_time |> DateTime.from_unix!() |> DateTime.to_iso8601(), + datetime_now: DateTime.to_unix(datetime_now), + datetime_now_iso: DateTime.to_iso8601(datetime_now), + time_sources_agree: system_time == os_time and system_time == DateTime.to_unix(datetime_now) + }) + ) + end end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 18d7cc4a..42e9b16e 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -39,6 +39,7 @@ defmodule ToweropsWeb.Router do # Health check endpoint for Kubernetes probes (no authentication required) scope "/", ToweropsWeb do get "/health", HealthController, :index + get "/health/time", HealthController, :time end scope "/", ToweropsWeb do