- Replace gray->cool-steel, blue/indigo->cerulean, red->sweet-salmon, yellow/amber->wheat - Dark sidebar: sidebar/footer use cool-steel-800 bg, text lightened for contrast - ~11,600 color replacements across ~99 files - Update tests for new color class names
393 lines
17 KiB
Elixir
393 lines
17 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-cool-steel-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-cool-steel-900 dark:text-white mb-2">
|
|
Save Your Recovery Codes
|
|
</h1>
|
|
<p class="text-cool-steel-600 dark:text-cool-steel-400">
|
|
Two-factor authentication has been enabled successfully!
|
|
</p>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Warning -->
|
|
<div class="rounded-md bg-wheat-50 dark:bg-wheat-900/20 p-4 border border-wheat-200 dark:border-wheat-800">
|
|
<div class="flex">
|
|
<.icon
|
|
name="hero-exclamation-triangle"
|
|
class="h-5 w-5 text-wheat-400 mt-0.5"
|
|
/>
|
|
<div class="ml-3">
|
|
<h3 class="text-sm font-medium text-wheat-800 dark:text-wheat-200">
|
|
Important: Save these recovery codes now
|
|
</h3>
|
|
<div class="mt-2 text-sm text-wheat-700 dark:text-wheat-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-cool-steel-200 dark:border-cool-steel-700 rounded-lg p-6">
|
|
<h2 class="text-lg font-semibold text-cool-steel-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-cool-steel-50 dark:bg-cool-steel-800 rounded-md p-3 text-center">
|
|
<code class="text-lg font-mono text-cool-steel-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-cool-steel-300 dark:border-cool-steel-600 shadow-sm text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cool-steel-300 dark:border-cool-steel-600 shadow-sm text-sm font-medium rounded-md text-cool-steel-700 dark:text-cool-steel-300 bg-white dark:bg-cool-steel-800 hover:bg-cool-steel-50 dark:hover:bg-cool-steel-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cerulean-600 hover:bg-cerulean-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cerulean-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-cerulean-600 dark:text-cerulean-400"
|
|
/>
|
|
<h1 class="text-3xl font-bold text-cool-steel-900 dark:text-white mb-2">
|
|
Set Up Two-Factor Authentication
|
|
</h1>
|
|
<p class="text-cool-steel-600 dark:text-cool-steel-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-cool-steel-200 dark:border-cool-steel-700 pt-6">
|
|
<h2 class="text-xl font-semibold text-cool-steel-900 dark:text-white mb-4 flex items-center">
|
|
<span class="flex items-center justify-center w-8 h-8 bg-cerulean-600 text-white rounded-full mr-3 text-sm">
|
|
1
|
|
</span>
|
|
Install an Authenticator App
|
|
</h2>
|
|
<p class="text-cool-steel-600 dark:text-cool-steel-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-200 dark:border-cool-steel-700 pt-6">
|
|
<h2 class="text-xl font-semibold text-cool-steel-900 dark:text-white mb-4 flex items-center">
|
|
<span class="flex items-center justify-center w-8 h-8 bg-cerulean-600 text-white rounded-full mr-3 text-sm">
|
|
2
|
|
</span>
|
|
Scan the QR Code
|
|
</h2>
|
|
<p class="text-cool-steel-600 dark:text-cool-steel-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-cool-steel-900 p-4 rounded-lg border-2 border-cool-steel-200 dark:border-cool-steel-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-cool-steel-100 dark:bg-cool-steel-800 p-4 rounded-lg border-2 border-cool-steel-200 dark:border-cool-steel-700 w-72 h-72 flex items-center justify-center">
|
|
<p class="text-cool-steel-500 dark:text-cool-steel-400">Loading QR code...</p>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
|
|
<!-- Step 3: Enter Code -->
|
|
<div class="border-t border-cool-steel-200 dark:border-cool-steel-700 pt-6">
|
|
<h2 class="text-xl font-semibold text-cool-steel-900 dark:text-white mb-4 flex items-center">
|
|
<span class="flex items-center justify-center w-8 h-8 bg-cerulean-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-cool-steel-700 dark:text-cool-steel-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-cool-steel-300 dark:border-cool-steel-600 dark:bg-cool-steel-800 dark:text-white shadow-sm focus:border-cerulean-500 focus:ring-cerulean-500 text-2xl text-center tracking-widest font-mono"
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
</div>
|
|
|
|
<%= if @error do %>
|
|
<div class="rounded-md bg-sweet-salmon-50 dark:bg-sweet-salmon-900/30 p-4">
|
|
<div class="flex">
|
|
<.icon name="hero-exclamation-circle" class="h-5 w-5 text-sweet-salmon-400" />
|
|
<div class="ml-3">
|
|
<p class="text-sm text-sweet-salmon-800 dark:text-sweet-salmon-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-cerulean-50 dark:bg-cerulean-900/20 rounded-lg border border-cerulean-200 dark:border-cerulean-800">
|
|
<div class="flex">
|
|
<.icon
|
|
name="hero-information-circle"
|
|
class="h-5 w-5 text-cerulean-600 dark:text-cerulean-400 mt-0.5"
|
|
/>
|
|
<div class="ml-3">
|
|
<p class="text-sm text-cerulean-900 dark:text-cerulean-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
|