mfa enforced on login

This commit is contained in:
Graham McIntire 2026-01-29 14:31:13 -06:00
parent 55e9397d59
commit 2a36917738
9 changed files with 236 additions and 10 deletions

View file

@ -336,7 +336,7 @@ defmodule ToweropsWeb.Layouts do
</.link>
<.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")}
>

View file

@ -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

View file

@ -0,0 +1,43 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
Two-Factor Authentication
<:subtitle>
Enter the 6-digit code from your authenticator app
</:subtitle>
</.header>
</div>
<div class="mt-8">
<.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()}
/>
<div class="mt-6">
<.button class="w-full" variant="primary">
Verify <span aria-hidden="true">→</span>
</.button>
</div>
</.form>
<div class="mt-6 text-center">
<.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
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -138,7 +138,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
</li>
<li>
<.link
navigate={~p"/account/my-data"}
navigate={~p"/users/my-data"}
class="text-indigo-600 dark:text-indigo-400"
>
My Data

View file

@ -528,7 +528,7 @@ defmodule ToweropsWeb.UserSettingsLive do
</li>
<li>
<.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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"