diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index e1f5ba7..e869c83 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -120,6 +120,34 @@ class AuthController extends Controller return; } + // Check if 2FA is required + $twoFactorService = new \App\Services\TwoFactorService(); + $policy = $twoFactorService->getTwoFactorPolicy(); + + if ($policy !== 'disabled' && $user['two_factor_enabled']) { + // User has 2FA enabled - require verification + $_SESSION['user_id'] = $user['id']; + $_SESSION['username'] = $user['username']; + $_SESSION['full_name'] = $user['full_name']; + $_SESSION['email'] = $user['email']; + $_SESSION['role'] = $user['role']; + $_SESSION['2fa_required'] = true; + + // Clear any existing session messages before redirecting to 2FA + unset($_SESSION['error']); + unset($_SESSION['success']); + + $this->redirect('/2fa/verify'); + return; + } + + // Check if 2FA is forced for this user + if ($twoFactorService->isTwoFactorRequired($user['id'])) { + $_SESSION['error'] = 'You must enable two-factor authentication to continue. Please contact an administrator.'; + $this->redirect('/login'); + return; + } + // Login successful - create session $_SESSION['user_id'] = $user['id']; $_SESSION['username'] = $user['username']; @@ -342,10 +370,26 @@ class AuthController extends Controller private function verifyEmail($token) { try { + // Debug logging + $this->logger->info("Email verification attempt with token: " . substr($token, 0, 10) . "..."); + // Find user by verification token using model $user = $this->userModel->findByVerificationToken($token); if (!$user) { + $this->logger->warning("No user found with verification token: " . substr($token, 0, 10) . "..."); + + // Debug: Check if any user has this token (regardless of verification status) + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare("SELECT id, email, email_verified, email_verification_token FROM users WHERE email_verification_token = ?"); + $stmt->execute([$token]); + $debugUser = $stmt->fetch(); + if ($debugUser) { + $this->logger->info("Debug: Found user with token - ID: {$debugUser['id']}, Email: {$debugUser['email']}, Verified: {$debugUser['email_verified']}"); + } else { + $this->logger->warning("Debug: No user found with this token at all"); + } + $this->view('auth/verify-email', [ 'title' => 'Verification Failed', 'error' => true, diff --git a/app/Controllers/InstallerController.php b/app/Controllers/InstallerController.php index 95fc64e..a3b8a3b 100644 --- a/app/Controllers/InstallerController.php +++ b/app/Controllers/InstallerController.php @@ -48,6 +48,7 @@ class InstallerController extends Controller '014_add_captcha_settings.sql', '015_create_error_logs_table.sql', '016_add_tags_to_domains.sql', + '017_add_two_factor_authentication.sql', ]; try { diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index e12d6e4..4b7c856 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -7,18 +7,21 @@ use Core\Auth; use App\Models\User; use App\Models\SessionManager; use App\Models\RememberToken; +use App\Services\Logger; class ProfileController extends Controller { private User $userModel; private SessionManager $sessionModel; private RememberToken $rememberTokenModel; + private Logger $logger; public function __construct() { $this->userModel = new User(); $this->sessionModel = new SessionManager(); $this->rememberTokenModel = new RememberToken(); + $this->logger = new Logger('profile'); } /** @@ -66,6 +69,7 @@ class ProfileController extends Controller $this->view('profile/index', [ 'user' => $user, 'sessions' => $formattedSessions, + 'userModel' => $this->userModel, 'title' => 'My Profile' ]); } @@ -108,6 +112,10 @@ class ProfileController extends Controller return; } + // Get current user data to check if email changed + $currentUser = $this->userModel->find($userId); + $emailChanged = $currentUser['email'] !== $email; + // Check if email is already taken by another user $existingUsers = $this->userModel->where('email', $email); foreach ($existingUsers as $existingUser) { @@ -118,18 +126,46 @@ class ProfileController extends Controller } } - // Update user - $this->userModel->update($userId, [ + // Prepare update data + $updateData = [ 'full_name' => $fullName, 'email' => $email, - ]); + ]; + + // If email changed, mark as unverified and send verification email + if ($emailChanged) { + $updateData['email_verified'] = null; + + // Generate new verification token + $verificationToken = bin2hex(random_bytes(32)); + $updateData['email_verification_token'] = $verificationToken; + } + + // Update user + $this->userModel->update($userId, $updateData); // Update session - $_SESSION['full_name'] = $fullName; - $_SESSION['email'] = $email; + $_SESSION['full_name'] = $fullName; + $_SESSION['email'] = $email; - $_SESSION['success'] = 'Profile updated successfully'; - $this->redirect('/profile'); + // Send verification email if email changed + if ($emailChanged) { + try { + \App\Helpers\EmailHelper::sendVerificationEmail($email, $fullName, $verificationToken); + $_SESSION['success'] = 'Profile updated successfully. Please check your new email address for a verification link.'; + } catch (\Exception $e) { + $_SESSION['success'] = 'Profile updated successfully, but verification email could not be sent. Please try resending verification.'; + $this->logger->error("Failed to send verification email after profile update", [ + 'user_id' => $userId, + 'email' => $email, + 'error' => $e->getMessage() + ]); + } + } else { + $_SESSION['success'] = 'Profile updated successfully'; + } + + $this->redirect('/profile'); } /** @@ -226,11 +262,29 @@ class ProfileController extends Controller return; } - // Use AuthController logic - $authController = new AuthController(); - - $_SESSION['pending_verification_email'] = $user['email']; - $_SESSION['success'] = 'Verification email sent! Please check your inbox.'; + try { + // Generate new verification token + $token = bin2hex(random_bytes(32)); + + // Debug logging + $this->logger->info("Generated new verification token for user {$userId}: " . substr($token, 0, 10) . "..."); + + // Update verification token in database + $this->userModel->updateEmailVerificationToken($userId, $token); + + // Send verification email + \App\Helpers\EmailHelper::sendVerificationEmail($user['email'], $user['full_name'], $token); + + $_SESSION['success'] = 'Verification email sent! Please check your inbox.'; + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to resend verification email. Please try again.'; + $this->logger->error("Failed to resend verification email", [ + 'user_id' => $userId, + 'email' => $user['email'], + 'error' => $e->getMessage() + ]); + } $this->redirect('/profile'); } diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 6a436b9..c62ef24 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -26,6 +26,7 @@ class SettingsController extends Controller $appSettings = $this->settingModel->getAppSettings(); $emailSettings = $this->settingModel->getEmailSettings(); $captchaSettings = $this->settingModel->getCaptchaSettings(); + $twoFactorSettings = $this->settingModel->getTwoFactorSettings(); // Predefined notification day options $notificationPresets = [ @@ -69,6 +70,7 @@ class SettingsController extends Controller 'appSettings' => $appSettings, 'emailSettings' => $emailSettings, 'captchaSettings' => $captchaSettings, + 'twoFactorSettings' => $twoFactorSettings, 'notificationPresets' => $notificationPresets, 'checkIntervalPresets' => $checkIntervalPresets, 'title' => 'Settings' @@ -435,5 +437,59 @@ class SettingsController extends Controller $this->redirect('/settings#email'); } + + public function updateTwoFactor() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/settings'); + return; + } + + // CSRF Protection + $this->verifyCsrf('/settings#security'); + + try { + $twoFactorPolicy = trim($_POST['two_factor_policy'] ?? 'optional'); + $rateLimitMinutes = (int)($_POST['two_factor_rate_limit_minutes'] ?? 15); + $emailCodeExpiryMinutes = (int)($_POST['two_factor_email_code_expiry_minutes'] ?? 10); + + // Validate policy + $validPolicies = ['disabled', 'optional', 'forced']; + if (!in_array($twoFactorPolicy, $validPolicies)) { + $_SESSION['error'] = 'Invalid 2FA policy selected'; + $this->redirect('/settings#security'); + return; + } + + // Validate rate limit (1-60 minutes) + if ($rateLimitMinutes < 1 || $rateLimitMinutes > 60) { + $_SESSION['error'] = 'Rate limit must be between 1 and 60 minutes'; + $this->redirect('/settings#security'); + return; + } + + // Validate email code expiry (1-30 minutes) + if ($emailCodeExpiryMinutes < 1 || $emailCodeExpiryMinutes > 30) { + $_SESSION['error'] = 'Email code expiry must be between 1 and 30 minutes'; + $this->redirect('/settings#security'); + return; + } + + $twoFactorSettings = [ + 'two_factor_policy' => $twoFactorPolicy, + 'two_factor_rate_limit_minutes' => $rateLimitMinutes, + 'two_factor_email_code_expiry_minutes' => $emailCodeExpiryMinutes + ]; + + $this->settingModel->updateTwoFactorSettings($twoFactorSettings); + + $_SESSION['success'] = 'Two-Factor Authentication settings updated successfully'; + $this->redirect('/settings#security'); + + } catch (\Exception $e) { + $_SESSION['error'] = 'Failed to update 2FA settings: ' . $e->getMessage(); + $this->redirect('/settings#security'); + } + } } diff --git a/app/Controllers/TwoFactorController.php b/app/Controllers/TwoFactorController.php new file mode 100644 index 0000000..7237861 --- /dev/null +++ b/app/Controllers/TwoFactorController.php @@ -0,0 +1,489 @@ +userModel = new User(); + $this->twoFactorService = new TwoFactorService(); + $this->logger = new Logger('2fa'); + } + + /** + * Show 2FA setup page + */ + public function setup() + { + Auth::require(); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user) { + $_SESSION['error'] = 'User not found'; + $this->redirect('/profile'); + return; + } + + // Check if 2FA is disabled by admin + $policy = $this->twoFactorService->getTwoFactorPolicy(); + if ($policy === 'disabled') { + $_SESSION['error'] = 'Two-factor authentication is disabled'; + $this->redirect('/profile'); + return; + } + + // Check if email is verified + if (!$user['email_verified']) { + $_SESSION['error'] = 'You must verify your email address before enabling 2FA'; + $this->redirect('/profile'); + return; + } + + // Check if already enabled + if ($user['two_factor_enabled']) { + $_SESSION['info'] = 'Two-factor authentication is already enabled'; + $this->redirect('/profile'); + return; + } + + // Generate or reuse existing secret for this setup session + if (!isset($_SESSION['2fa_setup_secret'])) { + $_SESSION['2fa_setup_secret'] = $this->twoFactorService->generateSecret(); + } + + $secret = $_SESSION['2fa_setup_secret']; + $qrCodeUrl = $this->twoFactorService->generateQrCodeDataUri($user['email'], $secret); + + $this->view('2fa/setup', [ + 'user' => $user, + 'secret' => $secret, + 'qrCodeUrl' => $qrCodeUrl, + 'title' => 'Setup Two-Factor Authentication' + ]); + } + + /** + * Verify 2FA setup and enable it + */ + public function verifySetup() + { + Auth::require(); + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/2fa/setup'); + return; + } + + $this->verifyCsrf('/2fa/setup'); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + $verificationCode = $_POST['verification_code'] ?? ''; + + if (!$user || !$user['email_verified'] || $user['two_factor_enabled']) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/2fa/setup'); + return; + } + + // Get the secret from session (should exist from setup page) + if (!isset($_SESSION['2fa_setup_secret'])) { + $_SESSION['error'] = 'Setup session expired. Please start over.'; + $this->redirect('/2fa/setup'); + return; + } + + $secret = $_SESSION['2fa_setup_secret']; + + if (empty($verificationCode)) { + $_SESSION['error'] = 'Please enter the verification code'; + $this->redirect('/2fa/setup'); + return; + } + + // Verify the code + if (!$this->twoFactorService->verifyTotpCode($secret, $verificationCode)) { + $_SESSION['error'] = 'Invalid verification code. Please try again.'; + $this->redirect('/2fa/setup'); + return; + } + + // Generate backup codes + $backupCodes = $this->twoFactorService->generateBackupCodes(); + + // Enable 2FA + if ($this->twoFactorService->enableTwoFactor($userId, $secret, $backupCodes)) { + $_SESSION['success'] = 'Two-factor authentication enabled successfully!'; + + // Clear the setup secret from session + unset($_SESSION['2fa_setup_secret']); + + // Store backup codes in session for display + $_SESSION['backup_codes'] = $backupCodes; + + $this->redirect('/2fa/backup-codes'); + } else { + $_SESSION['error'] = 'Failed to enable two-factor authentication'; + $this->redirect('/2fa/setup'); + } + } + + /** + * Cancel 2FA setup (clear session secret) + */ + public function cancelSetup() + { + Auth::require(); + + // Clear the setup secret from session + unset($_SESSION['2fa_setup_secret']); + + $_SESSION['info'] = '2FA setup cancelled'; + $this->redirect('/profile'); + } + + /** + * Show backup codes page + */ + public function backupCodes() + { + Auth::require(); + + $backupCodes = $_SESSION['backup_codes'] ?? null; + + if (!$backupCodes) { + $_SESSION['error'] = 'No backup codes found'; + $this->redirect('/profile'); + return; + } + + // Clear backup codes from session after display + unset($_SESSION['backup_codes']); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + $this->view('2fa/backup-codes', [ + 'user' => $user, + 'backupCodes' => $backupCodes, + 'title' => 'Backup Codes' + ]); + } + + /** + * Show 2FA verification page (during login) + */ + public function showVerify() + { + // Check if user is in 2FA verification state + if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) { + $this->redirect('/'); + return; + } + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user || !$user['two_factor_enabled']) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/login'); + return; + } + + $this->view('2fa/verify', [ + 'user' => $user, + 'title' => 'Two-Factor Authentication' + ]); + } + + /** + * Process 2FA verification + */ + public function verify() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/2fa/verify'); + return; + } + + $this->verifyCsrf('/2fa/verify'); + + // Check if user is in 2FA verification state + if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) { + $this->redirect('/'); + return; + } + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + $ipAddress = $_SERVER['REMOTE_ADDR'] ?? ''; + + if (!$user || !$user['two_factor_enabled']) { + $_SESSION['error'] = 'Invalid request'; + $this->redirect('/login'); + return; + } + + // Check rate limiting + if (!$this->twoFactorService->checkRateLimit($ipAddress, $userId)) { + $_SESSION['error'] = 'Too many failed attempts. Please try again later.'; + $this->redirect('/2fa/verify'); + return; + } + + $verificationCode = trim($_POST['verification_code'] ?? ''); + $verified = false; + + if (!empty($verificationCode)) { + // Try TOTP code first (6 digits) + if (strlen($verificationCode) === 6 && is_numeric($verificationCode)) { + if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) { + $verified = true; + } + } + + // Try email code if TOTP failed (6 digits) + if (!$verified && strlen($verificationCode) === 6 && is_numeric($verificationCode)) { + if ($this->twoFactorService->verifyEmailCode($userId, $verificationCode)) { + $verified = true; + } + } + + // Try backup code (8 characters) + if (!$verified && strlen($verificationCode) === 8) { + if ($this->twoFactorService->verifyBackupCode($userId, $verificationCode)) { + $verified = true; + } + } + } + + // Record attempt + $this->twoFactorService->recordAttempt($userId, $ipAddress, $verified); + + if ($verified) { + // Clear 2FA requirement and complete login + unset($_SESSION['2fa_required']); + + // Determine which method was used + $method = 'unknown'; + if (strlen($verificationCode) === 6 && is_numeric($verificationCode)) { + // Try to determine if it was TOTP or email by checking which one succeeded + if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) { + $method = 'totp'; + } else { + $method = 'email'; + } + } elseif (strlen($verificationCode) === 8) { + $method = 'backup'; + } + + $this->logger->info('2FA verification successful', [ + 'user_id' => $userId, + 'method' => $method + ]); + + $_SESSION['success'] = 'Login successful!'; + $this->redirect('/'); + } else { + $_SESSION['error'] = 'Invalid verification code. Please try again.'; + $this->redirect('/2fa/verify'); + } + } + + /** + * Send email verification code + */ + public function sendEmailCode() + { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/2fa/verify'); + return; + } + + try { + // Check if user is in 2FA verification state + if (!isset($_SESSION['2fa_required']) || !$_SESSION['2fa_required']) { + $this->jsonResponse(['success' => false, 'error' => 'Invalid request']); + return; + } + + $userId = Auth::id(); + if (!$userId) { + $this->jsonResponse(['success' => false, 'error' => 'User not authenticated']); + return; + } + + $user = $this->userModel->find($userId); + if (!$user) { + $this->jsonResponse(['success' => false, 'error' => 'User not found']); + return; + } + + if (!$user['two_factor_enabled']) { + $this->jsonResponse(['success' => false, 'error' => 'Two-factor authentication not enabled']); + return; + } + + if (!$user['email_verified']) { + $this->jsonResponse(['success' => false, 'error' => 'Email not verified']); + return; + } + + // Check rate limit + if (!$this->twoFactorService->checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $userId)) { + $this->jsonResponse(['success' => false, 'error' => 'Rate limit exceeded. Please try again later.']); + return; + } + + $result = $this->twoFactorService->generateEmailCode($userId); + $this->jsonResponse($result); + + } catch (\Exception $e) { + $this->logger->error('Error sending 2FA email code', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + $this->jsonResponse(['success' => false, 'error' => 'Failed to send email code']); + } + } + + /** + * Disable 2FA + */ + public function disable() + { + Auth::require(); + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + $this->verifyCsrf('/profile'); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user || !$user['two_factor_enabled']) { + $_SESSION['error'] = 'Two-factor authentication is not enabled'; + $this->redirect('/profile'); + return; + } + + // Require 2FA verification to disable 2FA + $verificationCode = trim($_POST['verification_code'] ?? ''); + if (empty($verificationCode)) { + $_SESSION['error'] = 'Please enter your 2FA verification code to disable two-factor authentication'; + $this->redirect('/profile'); + return; + } + + // Verify the code using any available method + $verified = false; + + // Try TOTP code first + if ($this->twoFactorService->verifyTotpCode($user['two_factor_secret'], $verificationCode)) { + $verified = true; + } + + // Try email code if TOTP failed + if (!$verified && $user['email_verified']) { + if ($this->twoFactorService->verifyEmailCode($userId, $verificationCode)) { + $verified = true; + } + } + + // Try backup code if other methods failed + if (!$verified) { + if ($this->twoFactorService->verifyBackupCode($userId, $verificationCode)) { + $verified = true; + } + } + + if (!$verified) { + $_SESSION['error'] = 'Invalid verification code. Please enter a valid 2FA code to disable two-factor authentication'; + $this->redirect('/profile'); + return; + } + + // Check if 2FA is forced + if ($this->twoFactorService->isTwoFactorRequired($userId)) { + $_SESSION['error'] = 'Two-factor authentication is required and cannot be disabled'; + $this->redirect('/profile'); + return; + } + + if ($this->twoFactorService->disableTwoFactor($userId)) { + $_SESSION['success'] = 'Two-factor authentication has been disabled'; + } else { + $_SESSION['error'] = 'Failed to disable two-factor authentication'; + } + + $this->redirect('/profile#twofactor'); + } + + /** + * Regenerate backup codes + */ + public function regenerateBackupCodes() + { + Auth::require(); + + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + $this->redirect('/profile'); + return; + } + + $this->verifyCsrf('/profile'); + + $userId = Auth::id(); + $user = $this->userModel->find($userId); + + if (!$user || !$user['two_factor_enabled']) { + $_SESSION['error'] = 'Two-factor authentication is not enabled'; + $this->redirect('/profile'); + return; + } + + // Generate new backup codes + $backupCodes = $this->twoFactorService->generateBackupCodes(); + + // Update user with new backup codes + if ($this->userModel->update($userId, [ + 'two_factor_backup_codes' => json_encode($backupCodes) + ])) { + $_SESSION['success'] = 'New backup codes generated successfully!'; + + // Store backup codes in session for display + $_SESSION['backup_codes'] = $backupCodes; + + $this->redirect('/2fa/backup-codes'); + } else { + $_SESSION['error'] = 'Failed to generate new backup codes'; + $this->redirect('/profile#twofactor'); + } + } + + /** + * Send JSON response + */ + private function jsonResponse(array $data): void + { + header('Content-Type: application/json'); + echo json_encode($data); + exit; + } +} diff --git a/app/Helpers/EmailHelper.php b/app/Helpers/EmailHelper.php index 13d94ed..c1206b1 100644 --- a/app/Helpers/EmailHelper.php +++ b/app/Helpers/EmailHelper.php @@ -231,7 +231,7 @@ class EmailHelper /** * Send a notification email */ - public static function sendNotificationEmail(string $toEmail, string $subject, string $message, array $data = []): array + public static function sendNotificationEmail(string $toEmail, string $subject, string $message, array $data = [], bool $isHtml = false): array { try { $emailSettings = self::getEmailSettings(); @@ -245,8 +245,16 @@ class EmailHelper // Content $mail->isHTML(true); $mail->Subject = $subject; - $mail->Body = self::formatHtmlBody($message, $data, $appSettings); - $mail->AltBody = strip_tags($message); + + if ($isHtml) { + // Message is already formatted HTML, use it directly + $mail->Body = $message; + $mail->AltBody = strip_tags($message); + } else { + // Message is plain text, format it with template + $mail->Body = self::formatHtmlBody($message, $data, $appSettings); + $mail->AltBody = strip_tags($message); + } $mail->send(); @@ -384,7 +392,7 @@ class EmailHelper "; - return self::sendNotificationEmail($email, $subject, $htmlContent); + return self::sendNotificationEmail($email, $subject, $htmlContent, [], true); } catch (\Exception $e) { $errorMessage = "Failed to send verification email: " . $e->getMessage(); @@ -467,7 +475,7 @@ class EmailHelper "; - return self::sendNotificationEmail($email, $subject, $htmlContent); + return self::sendNotificationEmail($email, $subject, $htmlContent, [], true); } catch (\Exception $e) { $errorMessage = "Failed to send password reset email: " . $e->getMessage(); @@ -506,4 +514,73 @@ class EmailHelper return "Domain Monitor Alert"; } + + /** + * Send 2FA verification code email + */ + public static function sendTwoFactorCode(string $email, string $fullName, string $code): array + { + try { + $appSettings = self::getAppSettings(); + $subject = 'Your Two-Factor Authentication Code'; + + // Create a properly formatted HTML email + $htmlContent = " +
Hello {$fullName},
+ ++ Use this code to complete your two-factor authentication: +
+ ++ ⏰ Important: This code expires in 10 minutes for security reasons. +
++ If you did not request this code, please ignore this email. + No further action is required. +
+This is an automated message from {$appSettings['app_name']}
+ Visit Dashboard +Save these codes in a safe place - they can be used to access your account if you lose your authenticator device
++ These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone. +
+= htmlspecialchars($code) ?>
+
+ Download one of these apps on your mobile device:
+ +Google Authenticator
+iOS & Android
+Authy
+iOS & Android
+Microsoft Authenticator
+iOS & Android
+Open your authenticator app and scan this QR code:
+ ++ Note: This QR code will remain the same even if you refresh the page. + Once you scan it, you can enter the verification code below. +
+Can't scan? Enter this code manually:
+= htmlspecialchars($secret) ?>
+ Enter the 6-digit code from your authenticator app:
+ + +Important Security Notice
++ Once 2FA is enabled, you'll need your authenticator app to log in. + Make sure to save your backup codes in a secure location. +
+
+ Hello, = htmlspecialchars($user['full_name'] ?? $user['username']) ?>!
+ Please enter your 2FA code to complete login.
+