towerops/lib/towerops_web/live/account_live/totp_enrollment.ex
2026-01-28 17:18:23 -06:00

242 lines
9.5 KiB
Elixir

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
# Generate secret once and store in process state
# If user refreshes, they'll get a NEW secret - they must scan the LATEST QR code
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
handle_valid_code(socket, user, secret)
else
{:noreply, assign(socket, :error, "Invalid code. Please try again.")}
end
end
defp handle_valid_code(socket, user, secret) do
case Accounts.enable_totp(user, secret) do
{:ok, updated_user} ->
redirect_path = get_post_enrollment_path(updated_user)
{:noreply,
socket
|> put_flash(:info, "Two-factor authentication enabled successfully!")
|> redirect(to: redirect_path)}
{:error, _changeset} ->
{:noreply, assign(socket, :error, "Failed to enable two-factor authentication. Please try again.")}
end
end
defp get_post_enrollment_path(user) do
case Towerops.Organizations.list_user_organizations(user.id) do
[_first_org | _] -> ~p"/devices"
[] -> ~p"/orgs"
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 flex flex-col items-center"
>
<div class="space-y-4 w-full max-w-xs">
<div>
<label
for="code"
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 text-center"
>
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 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">
<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">
<.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 space-y-4">
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg border border-yellow-200 dark:border-yellow-800">
<div class="flex">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0"
/>
<div class="ml-3">
<p class="text-sm text-yellow-900 dark:text-yellow-100 font-semibold">
Important: Do not refresh this page!
</p>
<p class="text-sm text-yellow-900 dark:text-yellow-100 mt-1">
Each time you refresh, a new QR code is generated. You must scan the current QR code
and enter the verification code immediately without refreshing.
If you refresh by accident, you must scan the NEW QR code again.
</p>
</div>
</div>
</div>
<div class="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 flex-shrink-0"
/>
<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>
</div>
</Layouts.app>
"""
end
end