mfa enforced on login
This commit is contained in:
parent
55e9397d59
commit
2a36917738
9 changed files with 236 additions and 10 deletions
|
|
@ -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")}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue