From 2a36917738f04ac66426b13054f497b179471bcf Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 29 Jan 2026 14:31:13 -0600 Subject: [PATCH] mfa enforced on login --- lib/towerops_web/components/layouts.ex | 2 +- .../controllers/user_session_controller.ex | 79 ++++++++++++- .../user_session_html/totp.html.heex | 43 +++++++ lib/towerops_web/live/account_live/my_data.ex | 2 +- lib/towerops_web/live/user_settings_live.ex | 2 +- lib/towerops_web/router.ex | 2 + .../organizations/invitation_test.exs | 6 +- .../user_session_controller_test.exs | 106 +++++++++++++++++- test/towerops_web/live/my_data_live_test.exs | 4 +- 9 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 lib/towerops_web/controllers/user_session_html/totp.html.heex diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 0ce8931a..0e751b2b 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -336,7 +336,7 @@ defmodule ToweropsWeb.Layouts do <.link role="menuitem" - navigate={~p"/account/my-data"} + navigate={~p"/users/my-data"} class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white" phx-click={JS.hide(to: "#org-menu")} > diff --git a/lib/towerops_web/controllers/user_session_controller.ex b/lib/towerops_web/controllers/user_session_controller.ex index c6f789be..1e029565 100644 --- a/lib/towerops_web/controllers/user_session_controller.ex +++ b/lib/towerops_web/controllers/user_session_controller.ex @@ -36,7 +36,19 @@ defmodule ToweropsWeb.UserSessionController do # email + password login def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do if user = Accounts.get_user_by_email_and_password(email, password) do - UserAuth.log_in_user(conn, user, user_params) + # Check if user has TOTP enabled - require verification before creating session + if Accounts.totp_enabled?(user) do + # Store pending auth state and redirect to TOTP verification + remember_me = Map.get(user_params, "remember_me") == "true" + + conn + |> put_session(:pending_totp_user_id, user.id) + |> put_session(:pending_totp_remember_me, remember_me) + |> redirect(to: ~p"/users/log-in/totp") + else + # No TOTP, proceed with normal login + UserAuth.log_in_user(conn, user, user_params) + end else form = Phoenix.Component.to_form(user_params, as: "user") @@ -97,4 +109,69 @@ defmodule ToweropsWeb.UserSessionController do |> put_flash(:info, "Logged out successfully.") |> UserAuth.log_out_user() end + + def totp_verification_form(conn, _params) do + case get_session(conn, :pending_totp_user_id) do + nil -> + conn + |> put_flash(:error, "Please log in first.") + |> redirect(to: ~p"/users/log-in") + + _user_id -> + form = Phoenix.Component.to_form(%{}, as: "user") + render(conn, :totp, form: form) + end + end + + def verify_totp(conn, %{"user" => %{"totp_code" => code}}) do + case get_session(conn, :pending_totp_user_id) do + nil -> + redirect_to_login(conn) + + user_id -> + handle_totp_verification(conn, user_id, code) + end + end + + defp redirect_to_login(conn) do + conn + |> put_flash(:error, "Please log in first.") + |> redirect(to: ~p"/users/log-in") + end + + defp handle_totp_verification(conn, user_id, code) do + user = Accounts.get_user!(user_id) + + case Accounts.verify_user_totp(user, code) do + {:ok, user} -> + complete_totp_login(conn, user) + + {:error, _reason} -> + render_totp_error(conn, code) + end + end + + defp complete_totp_login(conn, user) do + remember_me = get_session(conn, :pending_totp_remember_me) + + user_params = + if remember_me do + %{"remember_me" => "true"} + else + %{} + end + + conn + |> delete_session(:pending_totp_user_id) + |> delete_session(:pending_totp_remember_me) + |> UserAuth.log_in_user(user, user_params) + end + + defp render_totp_error(conn, code) do + form = Phoenix.Component.to_form(%{"totp_code" => code}, as: "user") + + conn + |> put_flash(:error, "Invalid authentication code. Please try again.") + |> render(:totp, form: form) + end end diff --git a/lib/towerops_web/controllers/user_session_html/totp.html.heex b/lib/towerops_web/controllers/user_session_html/totp.html.heex new file mode 100644 index 00000000..65672f5b --- /dev/null +++ b/lib/towerops_web/controllers/user_session_html/totp.html.heex @@ -0,0 +1,43 @@ + +
+
+ <.header> + Two-Factor Authentication + <:subtitle> + Enter the 6-digit code from your authenticator app + + +
+ +
+ <.form :let={f} for={@form} as={:user} id="totp_form" action={~p"/users/log-in/totp"}> + <.input + field={f[:totp_code]} + type="text" + label="Authentication Code" + placeholder="000000" + autocomplete="one-time-code" + inputmode="numeric" + pattern="[0-9]{6}" + maxlength="6" + required + phx-mounted={JS.focus()} + /> +
+ <.button class="w-full" variant="primary"> + Verify + +
+ + +
+ <.link + href={~p"/users/log-in"} + class="text-sm font-semibold text-gray-600 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" + > + ← Back to login + +
+
+
+
diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex index 79c06315..2aac0632 100644 --- a/lib/towerops_web/live/account_live/my_data.ex +++ b/lib/towerops_web/live/account_live/my_data.ex @@ -138,7 +138,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
  • <.link - navigate={~p"/account/my-data"} + navigate={~p"/users/my-data"} class="text-indigo-600 dark:text-indigo-400" > My Data diff --git a/lib/towerops_web/live/user_settings_live.ex b/lib/towerops_web/live/user_settings_live.ex index c17db543..e562808c 100644 --- a/lib/towerops_web/live/user_settings_live.ex +++ b/lib/towerops_web/live/user_settings_live.ex @@ -528,7 +528,7 @@ defmodule ToweropsWeb.UserSettingsLive do
  • <.link - navigate={~p"/account/my-data"} + navigate={~p"/users/my-data"} class="text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400" > My Data diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 940c7335..3a3e694b 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -175,6 +175,8 @@ defmodule ToweropsWeb.Router do pipe_through [:browser] get "/users/log-in", UserSessionController, :new + get "/users/log-in/totp", UserSessionController, :totp_verification_form + post "/users/log-in/totp", UserSessionController, :verify_totp get "/users/log-in/:token", UserSessionController, :confirm post "/users/log-in", UserSessionController, :create delete "/users/log-out", UserSessionController, :delete diff --git a/test/towerops/organizations/invitation_test.exs b/test/towerops/organizations/invitation_test.exs index 0c85fae2..5e64764d 100644 --- a/test/towerops/organizations/invitation_test.exs +++ b/test/towerops/organizations/invitation_test.exs @@ -35,7 +35,7 @@ defmodule Towerops.Organizations.InvitationTest do assert %{email: ["can't be blank"]} = errors_on(changeset) end - test "requires role" do + test "defaults role to :member when not provided" do attrs = %{ email: "test@example.com", organization_id: Ecto.UUID.generate(), @@ -44,8 +44,8 @@ defmodule Towerops.Organizations.InvitationTest do changeset = Invitation.changeset(%Invitation{}, attrs) - refute changeset.valid? - assert %{role: ["can't be blank"]} = errors_on(changeset) + assert changeset.valid? + assert Ecto.Changeset.get_field(changeset, :role) == :member end test "requires organization_id" do diff --git a/test/towerops_web/controllers/user_session_controller_test.exs b/test/towerops_web/controllers/user_session_controller_test.exs index a6586599..c2f04913 100644 --- a/test/towerops_web/controllers/user_session_controller_test.exs +++ b/test/towerops_web/controllers/user_session_controller_test.exs @@ -7,7 +7,11 @@ defmodule ToweropsWeb.UserSessionControllerTest do setup do # Create users without TOTP for login flow tests (they'll be prompted to enroll) - %{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture(enable_totp: false)} + %{ + unconfirmed_user: unconfirmed_user_fixture(), + user: user_fixture(enable_totp: false), + user_with_totp: user_fixture(enable_totp: true) + } end describe "GET /users/log-in" do @@ -110,6 +114,106 @@ defmodule ToweropsWeb.UserSessionControllerTest do assert response =~ "Log in" assert response =~ "Invalid email or password" end + + test "redirects to TOTP verification when user has TOTP enabled", %{conn: conn, user_with_totp: user} do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => valid_user_password()} + }) + + # Should NOT create session yet + refute get_session(conn, :user_token) + # Should store pending auth state + assert get_session(conn, :pending_totp_user_id) == user.id + # Should redirect to TOTP verification + assert redirected_to(conn) == ~p"/users/log-in/totp" + end + + test "does not create session if password is wrong even for TOTP users", %{ + conn: conn, + user_with_totp: user + } do + conn = + post(conn, ~p"/users/log-in", %{ + "user" => %{"email" => user.email, "password" => "wrong_password"} + }) + + response = html_response(conn, 200) + assert response =~ "Invalid email or password" + refute get_session(conn, :user_token) + refute get_session(conn, :pending_totp_user_id) + end + end + + describe "GET /users/log-in/totp" do + test "renders TOTP verification page when pending auth exists", %{conn: conn, user_with_totp: user} do + conn = + conn + |> init_test_session(pending_totp_user_id: user.id) + |> get(~p"/users/log-in/totp") + + response = html_response(conn, 200) + assert response =~ "Two-Factor Authentication" + assert response =~ "Enter the 6-digit code from your authenticator app" + end + + test "redirects to login when no pending auth", %{conn: conn} do + conn = get(conn, ~p"/users/log-in/totp") + + assert redirected_to(conn) == ~p"/users/log-in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please log in first" + end + end + + describe "POST /users/log-in/totp" do + test "logs in with valid TOTP code", %{conn: conn, user_with_totp: user} do + # Generate valid TOTP code + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + conn + |> init_test_session(pending_totp_user_id: user.id) + |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => code}}) + + # Should create session + assert get_session(conn, :user_token) + # Should clear pending state + refute get_session(conn, :pending_totp_user_id) + # Should redirect to orgs (user has no organizations) + assert redirected_to(conn) == ~p"/orgs" + end + + test "rejects invalid TOTP code", %{conn: conn, user_with_totp: user} do + conn = + conn + |> init_test_session(pending_totp_user_id: user.id) + |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => "000000"}}) + + response = html_response(conn, 200) + assert response =~ "Invalid authentication code" + # Should NOT create session + refute get_session(conn, :user_token) + # Should keep pending state + assert get_session(conn, :pending_totp_user_id) == user.id + end + + test "redirects to login when no pending auth", %{conn: conn} do + conn = post(conn, ~p"/users/log-in/totp", %{"user" => %{"totp_code" => "123456"}}) + + assert redirected_to(conn) == ~p"/users/log-in" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please log in first" + end + + test "respects remember_me from initial login", %{conn: conn, user_with_totp: user} do + code = NimbleTOTP.verification_code(user.totp_secret) + + conn = + conn + |> init_test_session(pending_totp_user_id: user.id, pending_totp_remember_me: true) + |> post(~p"/users/log-in/totp", %{"user" => %{"totp_code" => code}}) + + assert conn.resp_cookies["_towerops_web_user_remember_me"] + end end describe "POST /users/log-in - magic link" do diff --git a/test/towerops_web/live/my_data_live_test.exs b/test/towerops_web/live/my_data_live_test.exs index 122fc874..ad098644 100644 --- a/test/towerops_web/live/my_data_live_test.exs +++ b/test/towerops_web/live/my_data_live_test.exs @@ -8,7 +8,7 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do describe "My Data page" do test "renders for organization owner without additional data", %{conn: conn} do # register_and_log_in_user creates a user with a Personal org (as owner) - {:ok, _view, html} = live(conn, ~p"/account/my-data") + {:ok, _view, html} = live(conn, ~p"/users/my-data") assert html =~ "My Data" assert html =~ "Profile Information" @@ -44,7 +44,7 @@ defmodule ToweropsWeb.AccountLive.MyDataTest do message: "Device is down" }) - {:ok, _view, html} = live(conn, ~p"/account/my-data") + {:ok, _view, html} = live(conn, ~p"/users/my-data") assert html =~ "My Data" assert html =~ "Profile Information"