towerops/lib/towerops_web/live/account_live/totp_enrollment.ex

393 lines
16 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
require Logger
@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
# Only generate secret when socket is connected
# On initial render (disconnected), use temporary placeholder
if connected?(socket) do
# Generate secret once per LiveView process
# IMPORTANT: If you refresh this page, you'll get a NEW secret
# You must scan the CURRENT QR code that's displayed
secret = Accounts.generate_totp_secret()
uri = Accounts.generate_totp_uri(user, secret)
qr_code = Accounts.generate_totp_qr_code(user, secret)
# Base32 encode secret for storage in socket assigns
# This prevents corruption during LiveView serialization
secret_base32 = Base.encode32(secret, padding: false)
Logger.info("TOTP enrollment started",
user_email: user.email,
secret_length: byte_size(secret),
secret_base32: secret_base32,
secret_hex: Base.encode16(secret),
uri: uri
)
socket =
socket
|> assign(:page_title, t("Set Up Two-Factor Authentication"))
|> assign(:secret_base32, secret_base32)
|> assign(:qr_code, qr_code)
|> assign(:error, nil)
|> assign(:recovery_codes, [])
|> assign(:show_recovery_codes, false)
{:ok, socket}
else
# Initial disconnected render - use placeholder
# Real secret will be generated when socket connects
socket =
socket
|> assign(:page_title, t("Set Up Two-Factor Authentication"))
|> assign(:secret_base32, nil)
|> assign(:qr_code, nil)
|> assign(:error, nil)
|> assign(:recovery_codes, [])
|> assign(:show_recovery_codes, false)
{:ok, socket}
end
end
end
@impl true
def handle_event("verify_code", %{"code" => code}, socket) do
user = socket.assigns.current_scope.user
secret_base32 = socket.assigns.secret_base32
# Decode base32 secret back to binary
secret = Base.decode32!(secret_base32, padding: false, case: :upper)
Logger.info("TOTP enrollment verification attempt",
user_email: user.email,
provided_code: code,
secret_length: byte_size(secret),
secret_base32_from_assigns: secret_base32,
secret_binary_hex: Base.encode16(secret)
)
if Accounts.verify_totp(secret, code) do
Logger.info("TOTP enrollment successful", user_email: user.email)
handle_valid_code(socket, user, secret)
else
Logger.warning("TOTP enrollment verification failed", user_email: user.email)
{:noreply, assign(socket, :error, t("Invalid code. Please try again."))}
end
end
@impl true
def handle_event("confirm_recovery_codes_saved", _params, socket) do
user = socket.assigns.current_scope.user
redirect_path = get_post_enrollment_path(user)
{:noreply,
socket
|> put_flash(:info, t("Two-factor authentication enabled successfully!"))
|> redirect(to: redirect_path)}
end
def handle_event("validate_code", _params, socket) do
{:noreply, socket}
end
defp handle_valid_code(socket, user, secret) do
# Create first TOTP device (new multi-device system)
case Accounts.create_totp_device(user.id, "Primary Device", secret) do
{:ok, _device, _secret} ->
# Generate recovery codes for the user
{:ok, codes} = Accounts.generate_recovery_codes(user.id)
# Show recovery codes to user - they can only be displayed once
{:noreply,
socket
|> assign(:recovery_codes, codes)
|> assign(:show_recovery_codes, true)}
{:error, _changeset} ->
{:noreply, assign(socket, :error, t("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.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<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">
<%= if @show_recovery_codes do %>
<!-- Recovery Codes Display -->
<div class="text-center mb-8">
<.icon
name="hero-shield-check"
class="w-16 h-16 mx-auto mb-4 text-green-600 dark:text-green-400"
/>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Save Your Recovery Codes
</h1>
<p class="text-gray-600 dark:text-gray-400">
Two-factor authentication has been enabled successfully!
</p>
</div>
<div class="space-y-6">
<!-- Warning -->
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/20 p-4 border border-yellow-200 dark:border-yellow-800">
<div class="flex">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-yellow-400 mt-0.5"
/>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Important: Save these recovery codes now
</h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<ul class="list-disc pl-5 space-y-1">
<li>These codes will only be shown once and cannot be retrieved later</li>
<li>Store them in a secure location like a password manager</li>
<li>Each code can only be used once</li>
<li>Use these codes if you lose access to your authenticator app</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Recovery Codes -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 text-center">
Your Recovery Codes
</h2>
<div class="grid grid-cols-2 gap-3 max-w-md mx-auto">
<%= for code <- @recovery_codes do %>
<div class="bg-gray-50 dark:bg-gray-800 rounded-md p-3 text-center">
<code class="text-lg font-mono text-gray-900 dark:text-white font-semibold">
{code}
</code>
</div>
<% end %>
</div>
<div class="mt-6 flex gap-3 justify-center">
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#recovery-codes-text")}
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-clipboard-document" class="w-5 h-5 mr-2" /> Copy All
</button>
<a
href={"data:text/plain;charset=utf-8," <> URI.encode(Enum.join(@recovery_codes, "\n"))}
download="towerops-recovery-codes.txt"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 shadow-sm text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-arrow-down-tray" class="w-5 h-5 mr-2" /> Download
</a>
</div>
<!-- Hidden textarea for copy functionality -->
<textarea
id="recovery-codes-text"
class="sr-only"
readonly
>{Enum.join(@recovery_codes, "\n")}</textarea>
</div>
<!-- Continue Button -->
<div class="flex justify-center pt-4">
<button
type="button"
phx-click="confirm_recovery_codes_saved"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
I've Saved My Recovery Codes <.icon name="hero-arrow-right" class="w-5 h-5 ml-2" />
</button>
</div>
</div>
<% else %>
<!-- Enrollment Form -->
<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>
<%= if @qr_code do %>
<div class="ml-11 flex justify-center">
<div class="bg-white dark:bg-gray-900 p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700">
<img src={@qr_code} alt="TOTP QR Code" class="w-64 h-64" />
</div>
</div>
<% else %>
<div class="ml-11 flex justify-center">
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg border-2 border-gray-200 dark:border-gray-700 w-72 h-72 flex items-center justify-center">
<p class="text-gray-500 dark:text-gray-400">Loading QR code...</p>
</div>
</div>
<% end %>
</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}
id="totp-verify-form"
phx-change="validate_code"
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 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>
<% end %>
</div>
</div>
</Layouts.authenticated>
"""
end
end