242 lines
9.5 KiB
Elixir
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
|