Add mandatory TOTP MFA
This commit is contained in:
parent
1f6151b5a8
commit
0995abbccb
10 changed files with 559 additions and 9 deletions
|
|
@ -154,6 +154,88 @@ defmodule Towerops.Accounts do
|
|||
end
|
||||
end
|
||||
|
||||
## TOTP / Two-Factor Authentication
|
||||
|
||||
@doc """
|
||||
Generates a new TOTP secret for a user.
|
||||
|
||||
Returns a binary secret that should be stored temporarily until verified.
|
||||
"""
|
||||
def generate_totp_secret do
|
||||
NimbleTOTP.secret()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates an otpauth URI for displaying as a QR code.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> generate_totp_uri(user, secret)
|
||||
"otpauth://totp/Towerops:user@example.com?secret=SECRET&issuer=Towerops"
|
||||
"""
|
||||
def generate_totp_uri(%User{} = user, secret) do
|
||||
NimbleTOTP.otpauth_uri("Towerops:#{user.email}", secret, issuer: "Towerops")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a QR code as a data URI for the TOTP URI.
|
||||
|
||||
Returns a base64-encoded PNG image as a data URI that can be displayed in an img tag.
|
||||
"""
|
||||
def generate_totp_qr_code(%User{} = user, secret) do
|
||||
uri = generate_totp_uri(user, secret)
|
||||
|
||||
uri
|
||||
|> EQRCode.encode()
|
||||
|> EQRCode.png()
|
||||
|> Base.encode64()
|
||||
|> then(&"data:image/png;base64,#{&1}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies a TOTP code against a secret.
|
||||
|
||||
Returns true if the code is valid within the time window, false otherwise.
|
||||
"""
|
||||
def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do
|
||||
# Allow 30 second window for time drift
|
||||
NimbleTOTP.valid?(secret, code, since: System.system_time(:second) - 30)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables TOTP for a user by saving the secret.
|
||||
|
||||
The secret should have been verified before calling this function.
|
||||
"""
|
||||
def enable_totp(%User{} = user, secret) when is_binary(secret) do
|
||||
user
|
||||
|> Ecto.Changeset.change(totp_secret: secret)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a user has TOTP enabled.
|
||||
"""
|
||||
def totp_enabled?(%User{totp_secret: secret}) when is_binary(secret), do: true
|
||||
def totp_enabled?(_user), do: false
|
||||
|
||||
@doc """
|
||||
Verifies a TOTP code for a user who has TOTP enabled.
|
||||
|
||||
Returns {:ok, user} if valid, {:error, :invalid_code} otherwise.
|
||||
"""
|
||||
def verify_user_totp(%User{totp_secret: secret} = user, code) when is_binary(secret) and is_binary(code) do
|
||||
if verify_totp(secret, code) do
|
||||
{:ok, user}
|
||||
else
|
||||
{:error, :invalid_code}
|
||||
end
|
||||
end
|
||||
|
||||
def verify_user_totp(%User{}, _code) do
|
||||
{:error, :totp_not_enabled}
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule Towerops.Accounts.User do
|
|||
Users can own or be members of multiple organizations and authenticate via:
|
||||
- Email/password
|
||||
- WebAuthn passkeys
|
||||
- TOTP (Time-based One-Time Password) two-factor authentication
|
||||
- Session tokens
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
|
@ -27,6 +28,8 @@ defmodule Towerops.Accounts.User do
|
|||
field :name, :string
|
||||
field :avatar_url, :string
|
||||
field :timezone, :string
|
||||
field :totp_secret, :binary, redact: true
|
||||
field :totp_verified_at, :utc_datetime, virtual: true
|
||||
|
||||
has_many :memberships, Membership
|
||||
has_many :organizations, through: [:memberships, :organization]
|
||||
|
|
@ -46,6 +49,8 @@ defmodule Towerops.Accounts.User do
|
|||
name: String.t() | nil,
|
||||
avatar_url: String.t() | nil,
|
||||
timezone: String.t(),
|
||||
totp_secret: binary() | nil,
|
||||
totp_verified_at: DateTime.t() | nil,
|
||||
memberships: NotLoaded.t() | [Membership.t()],
|
||||
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()],
|
||||
credentials: NotLoaded.t() | [UserCredential.t()],
|
||||
|
|
|
|||
206
lib/towerops_web/live/account_live/totp_enrollment.ex
Normal file
206
lib/towerops_web/live/account_live/totp_enrollment.ex
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
defmodule ToweropsWeb.AccountLive.TotpEnrollment do
|
||||
@moduledoc """
|
||||
LiveView for mandatory TOTP enrollment during user registration.
|
||||
|
||||
New users must enroll a TOTP device before accessing the application.
|
||||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
# If user already has TOTP enabled, redirect them
|
||||
if Accounts.totp_enabled?(user) do
|
||||
{:ok, redirect(socket, to: ~p"/devices")}
|
||||
else
|
||||
secret = Accounts.generate_totp_secret()
|
||||
qr_code = Accounts.generate_totp_qr_code(user, secret)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Set Up Two-Factor Authentication")
|
||||
|> assign(:secret, secret)
|
||||
|> assign(:qr_code, qr_code)
|
||||
|> assign(:code, "")
|
||||
|> assign(:error, nil)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("verify_code", %{"code" => code}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
secret = socket.assigns.secret
|
||||
|
||||
if Accounts.verify_totp(secret, code) do
|
||||
case Accounts.enable_totp(user, secret) do
|
||||
{:ok, _user} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Two-factor authentication enabled successfully!")
|
||||
|> redirect(to: ~p"/devices")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, assign(socket, :error, "Failed to enable two-factor authentication. Please try again.")}
|
||||
end
|
||||
else
|
||||
{:noreply, assign(socket, :error, "Invalid code. Please try again.")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-2xl px-4 py-12">
|
||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<.icon
|
||||
name="hero-shield-check"
|
||||
class="w-16 h-16 mx-auto mb-4 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Set Up Two-Factor Authentication
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
To keep your account secure, two-factor authentication is required for all users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Step 1: Scan QR Code -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
|
||||
1
|
||||
</span>
|
||||
Install an Authenticator App
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
|
||||
Download an authenticator app on your phone if you haven't already:
|
||||
</p>
|
||||
<ul class="ml-11 space-y-2 text-gray-700 dark:text-gray-300">
|
||||
<li class="flex items-center">
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
|
||||
/> Google Authenticator (iOS, Android)
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
|
||||
/> Authy (iOS, Android, Desktop)
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="w-5 h-5 mr-2 text-green-600 dark:text-green-400"
|
||||
/> 1Password, Bitwarden (with TOTP support)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
|
||||
2
|
||||
</span>
|
||||
Scan the QR Code
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 ml-11 mb-4">
|
||||
Open your authenticator app and scan this QR code:
|
||||
</p>
|
||||
<div class="ml-11 flex justify-center">
|
||||
<div class="bg-white p-4 rounded-lg border-2 border-gray-200">
|
||||
<img src={@qr_code} alt="TOTP QR Code" class="w-64 h-64" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Enter Code -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<span class="flex items-center justify-center w-8 h-8 bg-blue-600 text-white rounded-full mr-3 text-sm">
|
||||
3
|
||||
</span>
|
||||
Enter the Code from Your App
|
||||
</h2>
|
||||
<.form
|
||||
for={%{}}
|
||||
as={:form}
|
||||
phx-submit="verify_code"
|
||||
class="ml-11"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="code"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
6-digit code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="code"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="block w-full max-w-xs rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500 text-2xl text-center tracking-widest font-mono"
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if @error do %>
|
||||
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4 max-w-xs">
|
||||
<div class="flex">
|
||||
<.icon name="hero-exclamation-circle" class="h-5 w-5 text-red-400" />
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">
|
||||
{@error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<.button type="submit" variant="primary" class="w-full max-w-xs">
|
||||
<.icon name="hero-shield-check" class="w-5 h-5 mr-2" /> Verify and Enable
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="flex">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-900 dark:text-blue-100">
|
||||
<strong>Keep your device safe:</strong>
|
||||
You'll need to enter a code from your authenticator app each time you log in.
|
||||
Make sure to keep your phone secure and backed up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -190,6 +190,7 @@ defmodule ToweropsWeb.Router do
|
|||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_totp_enrollment},
|
||||
{ToweropsWeb.UserAuth, :require_superuser}
|
||||
] do
|
||||
scope "/admin", ToweropsWeb.Admin do
|
||||
|
|
@ -207,6 +208,7 @@ defmodule ToweropsWeb.Router do
|
|||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_totp_enrollment},
|
||||
{ToweropsWeb.UserAuth, :require_sudo_mode}
|
||||
] do
|
||||
scope "/", ToweropsWeb do
|
||||
|
|
@ -217,7 +219,8 @@ defmodule ToweropsWeb.Router do
|
|||
end
|
||||
end
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
# TOTP enrollment page - requires auth but NOT TOTP (since user is enrolling)
|
||||
live_session :totp_enrollment,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user}
|
||||
|
|
@ -225,6 +228,19 @@ defmodule ToweropsWeb.Router do
|
|||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/account/totp-enrollment", AccountLive.TotpEnrollment, :index
|
||||
end
|
||||
end
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_totp_enrollment}
|
||||
] do
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/mobile/qr-login", MobileQRLive, :index
|
||||
live "/orgs", OrgLive.Index, :index
|
||||
live "/orgs/new", OrgLive.New, :new
|
||||
|
|
@ -235,6 +251,7 @@ defmodule ToweropsWeb.Router do
|
|||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_totp_enrollment},
|
||||
{ToweropsWeb.UserAuth, :load_current_organization}
|
||||
] do
|
||||
scope "/orgs/:org_slug", ToweropsWeb do
|
||||
|
|
@ -249,6 +266,7 @@ defmodule ToweropsWeb.Router do
|
|||
on_mount: [
|
||||
{ToweropsWeb.UserAuth, :load_cookie_consent},
|
||||
{ToweropsWeb.UserAuth, :require_authenticated_user},
|
||||
{ToweropsWeb.UserAuth, :require_totp_enrollment},
|
||||
{ToweropsWeb.UserAuth, :load_default_organization}
|
||||
] do
|
||||
scope "/", ToweropsWeb do
|
||||
|
|
|
|||
|
|
@ -257,15 +257,20 @@ defmodule ToweropsWeb.UserAuth do
|
|||
end
|
||||
|
||||
defp signed_in_path(user) when is_struct(user) do
|
||||
# Get user's organizations (ordered by most recently joined first)
|
||||
case Towerops.Organizations.list_user_organizations(user.id) do
|
||||
[_first_org | _] ->
|
||||
# Devices page
|
||||
~p"/devices"
|
||||
# Check if user has TOTP enabled (mandatory for all users)
|
||||
if Accounts.totp_enabled?(user) do
|
||||
# Get user's organizations (ordered by most recently joined first)
|
||||
case Towerops.Organizations.list_user_organizations(user.id) do
|
||||
[_first_org | _] ->
|
||||
# Devices page
|
||||
~p"/devices"
|
||||
|
||||
[] ->
|
||||
# No organizations yet, go to org list
|
||||
~p"/orgs"
|
||||
[] ->
|
||||
# No organizations yet, go to org list
|
||||
~p"/orgs"
|
||||
end
|
||||
else
|
||||
~p"/account/totp-enrollment"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -498,6 +503,21 @@ defmodule ToweropsWeb.UserAuth do
|
|||
end
|
||||
end
|
||||
|
||||
def on_mount(:require_totp_enrollment, _params, _session, socket) do
|
||||
user = socket.assigns.current_scope && socket.assigns.current_scope.user
|
||||
|
||||
if user && !Accounts.totp_enabled?(user) do
|
||||
socket =
|
||||
socket
|
||||
|> LiveView.put_flash(:error, "You must set up two-factor authentication to continue.")
|
||||
|> LiveView.redirect(to: ~p"/account/totp-enrollment")
|
||||
|
||||
{:halt, socket}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:load_cookie_consent, _params, session, socket) do
|
||||
requires_consent = Map.get(session, "requires_cookie_consent", false)
|
||||
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -42,6 +42,8 @@ defmodule Towerops.MixProject do
|
|||
defp deps do
|
||||
[
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:nimble_totp, "~> 1.0"},
|
||||
{:eqrcode, "~> 0.1.10"},
|
||||
{:phoenix, "~> 1.8.3"},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.13"},
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -14,6 +14,7 @@
|
|||
"ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.8", "aa02529c97f69aed5722899f5dc6360128735a92dd169f23c5d50b1f7fdede08", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "> 0.16.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "04c63d92b141723ad6fed2e60a4b461ca00b3594d16df47bbc48f1f4534f2c49"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"eqrcode": {:hex, :eqrcode, "0.1.10", "6294fece9d68ad64eef1c3c92cf111cfd6469f4fbf230a2d4cc905a682178f3f", [:mix], [], "hexpm", "da30e373c36a0fd37ab6f58664b16029919896d6c45a68a95cc4d713e81076f1"},
|
||||
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
|
||||
"oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"},
|
||||
"oban_met": {:hex, :oban_met, "1.0.5", "bb633ab06448dab2ef9194f6688d33b3d07fc3f2ad793a1a08f4dfbb2cc9fe50", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "64664d50805bbfd3903aeada1f3c39634652a87844797ee400b0bcc95a28f5ea"},
|
||||
"oban_web": {:hex, :oban_web, "2.11.7", "a998868bb32b3d3c44f328a8baec820af5c3b46a3a743d89c169e75b0bf7efcc", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "f0ef9ec8f97381a0287c60a732736ff77bc351cbbe82eceffb5a635f84788656"},
|
||||
|
|
|
|||
13
priv/repo/migrations/20260128205714_add_totp_to_users.exs
Normal file
13
priv/repo/migrations/20260128205714_add_totp_to_users.exs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Towerops.Repo.Migrations.AddTotpToUsers do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:users) do
|
||||
add :totp_secret, :binary
|
||||
end
|
||||
|
||||
alter table(:users_tokens) do
|
||||
add :totp_verified_at, :utc_datetime
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -391,4 +391,93 @@ defmodule Towerops.AccountsTest do
|
|||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
end
|
||||
end
|
||||
|
||||
describe "TOTP enrollment" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "generate_totp_secret/0 generates a valid secret" do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
assert is_binary(secret)
|
||||
assert byte_size(secret) > 0
|
||||
end
|
||||
|
||||
test "generate_totp_uri/2 creates valid otpauth URI", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
uri = Accounts.generate_totp_uri(user, secret)
|
||||
|
||||
assert uri =~ "otpauth://totp/Towerops:#{user.email}"
|
||||
assert uri =~ "secret="
|
||||
assert uri =~ "issuer=Towerops"
|
||||
end
|
||||
|
||||
test "generate_totp_qr_code/2 creates base64-encoded data URI", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
qr_code = Accounts.generate_totp_qr_code(user, secret)
|
||||
|
||||
assert String.starts_with?(qr_code, "data:image/png;base64,")
|
||||
# Verify it's valid base64 after the prefix
|
||||
[_, base64] = String.split(qr_code, ",", parts: 2)
|
||||
assert {:ok, _} = Base.decode64(base64)
|
||||
end
|
||||
|
||||
test "verify_totp/2 validates correct TOTP codes" do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
|
||||
assert Accounts.verify_totp(secret, code)
|
||||
end
|
||||
|
||||
test "verify_totp/2 rejects incorrect TOTP codes" do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
|
||||
refute Accounts.verify_totp(secret, "000000")
|
||||
refute Accounts.verify_totp(secret, "invalid")
|
||||
end
|
||||
|
||||
test "verify_totp/2 accepts codes within time window" do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
# Generate code for current time (should always be valid)
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
|
||||
# Verify it works with the time window check (since: -30)
|
||||
assert Accounts.verify_totp(secret, code)
|
||||
end
|
||||
|
||||
test "enable_totp/2 saves TOTP secret to user", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
|
||||
assert {:ok, updated_user} = Accounts.enable_totp(user, secret)
|
||||
assert updated_user.totp_secret == secret
|
||||
assert Accounts.totp_enabled?(updated_user)
|
||||
end
|
||||
|
||||
test "totp_enabled?/1 returns false for user without TOTP", %{user: user} do
|
||||
refute Accounts.totp_enabled?(user)
|
||||
end
|
||||
|
||||
test "totp_enabled?/1 returns true for user with TOTP", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
||||
|
||||
assert Accounts.totp_enabled?(updated_user)
|
||||
end
|
||||
|
||||
test "verify_user_totp/2 validates codes for enrolled user", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
||||
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
|
||||
assert {:ok, %User{}} = Accounts.verify_user_totp(updated_user, code)
|
||||
end
|
||||
|
||||
test "verify_user_totp/2 rejects invalid codes for enrolled user", %{user: user} do
|
||||
secret = Accounts.generate_totp_secret()
|
||||
{:ok, updated_user} = Accounts.enable_totp(user, secret)
|
||||
|
||||
assert {:error, :invalid_code} = Accounts.verify_user_totp(updated_user, "000000")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
113
test/towerops_web/live/account_live/totp_enrollment_test.exs
Normal file
113
test/towerops_web/live/account_live/totp_enrollment_test.exs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
defmodule ToweropsWeb.AccountLive.TotpEnrollmentTest do
|
||||
use ToweropsWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
describe "TOTP Enrollment Page - unenrolled user" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
%{conn: log_in_user(build_conn(), user), user: user}
|
||||
end
|
||||
|
||||
test "renders enrollment page with QR code", %{conn: conn} do
|
||||
{:ok, view, html} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
assert html =~ "Set Up Two-Factor Authentication"
|
||||
assert html =~ "Install an Authenticator App"
|
||||
assert html =~ "Scan the QR Code"
|
||||
assert html =~ "Enter the Code from Your App"
|
||||
|
||||
# Verify QR code is present (as base64 data URI)
|
||||
assert view |> element("img[alt='TOTP QR Code']") |> has_element?()
|
||||
end
|
||||
|
||||
test "accepts valid TOTP code and enables TOTP", %{conn: conn, user: user} do
|
||||
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
# Get the secret from the view's assigns (via testing API)
|
||||
secret = :sys.get_state(view.pid).socket.assigns.secret
|
||||
|
||||
# Generate a valid code
|
||||
code = NimbleTOTP.verification_code(secret)
|
||||
|
||||
# Submit the form
|
||||
view
|
||||
|> form("form", %{code: code})
|
||||
|> render_submit()
|
||||
|
||||
# Should redirect to devices page
|
||||
assert_redirect(view, ~p"/devices")
|
||||
|
||||
# Verify TOTP was enabled in database
|
||||
updated_user = Accounts.get_user!(user.id)
|
||||
assert Accounts.totp_enabled?(updated_user)
|
||||
assert updated_user.totp_secret == secret
|
||||
end
|
||||
|
||||
test "rejects invalid TOTP code", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
# Submit with invalid code
|
||||
html =
|
||||
view
|
||||
|> form("form", %{code: "000000"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "Invalid code. Please try again."
|
||||
|
||||
# Should still be on enrollment page (view is still alive, no redirect)
|
||||
assert render(view) =~ "Set Up Two-Factor Authentication"
|
||||
end
|
||||
|
||||
test "rejects non-numeric code", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
# HTML5 pattern validation would prevent this, but test backend handling
|
||||
html =
|
||||
view
|
||||
|> form("form", %{code: "abcdef"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "Invalid code. Please try again."
|
||||
end
|
||||
|
||||
test "renders code input field with proper configuration", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
# Verify input field is present with correct attributes
|
||||
assert html =~ "id=\"code\""
|
||||
assert html =~ "inputmode=\"numeric\""
|
||||
assert html =~ "pattern=\"[0-9]{6}\""
|
||||
assert html =~ "maxlength=\"6\""
|
||||
assert html =~ "placeholder=\"000000\""
|
||||
end
|
||||
end
|
||||
|
||||
describe "TOTP Enrollment Page - already enrolled user" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
secret = Accounts.generate_totp_secret()
|
||||
{:ok, enrolled_user} = Accounts.enable_totp(user, secret)
|
||||
|
||||
%{conn: log_in_user(build_conn(), enrolled_user), user: enrolled_user}
|
||||
end
|
||||
|
||||
test "redirects to devices page if TOTP already enabled", %{conn: conn} do
|
||||
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
assert path == ~p"/devices"
|
||||
end
|
||||
end
|
||||
|
||||
describe "TOTP Enrollment Page - unauthenticated user" do
|
||||
test "redirects to login page" do
|
||||
conn = build_conn()
|
||||
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/account/totp-enrollment")
|
||||
|
||||
assert path == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue