From 6e8fef9b7981225eb7fde690129fa9a89543b4cc Mon Sep 17 00:00:00 2001 From: Hosteroid Date: Thu, 16 Oct 2025 17:25:06 +0300 Subject: [PATCH] Add two-factor authentication (2FA) support Introduces two-factor authentication (2FA) with TOTP, backup codes, and email codes. Adds controllers, services, views, and migration for 2FA setup, verification, and management. Updates user and settings models, email helper, and relevant controllers to support 2FA policy enforcement, configuration, and user flows. Enhances security by allowing admins to require or disable 2FA, and provides backup code generation and management for account recovery. --- app/Controllers/AuthController.php | 44 ++ app/Controllers/InstallerController.php | 1 + app/Controllers/ProfileController.php | 78 ++- app/Controllers/SettingsController.php | 56 ++ app/Controllers/TwoFactorController.php | 489 ++++++++++++++++++ app/Helpers/EmailHelper.php | 87 +++- app/Models/Setting.php | 28 + app/Models/User.php | 32 +- app/Services/TwoFactorService.php | 329 ++++++++++++ app/Views/2fa/backup-codes.php | 209 ++++++++ app/Views/2fa/setup.php | 162 ++++++ app/Views/2fa/verify.php | 206 ++++++++ app/Views/profile/index.php | 208 +++++++- app/Views/settings/index.php | 81 +++ composer.json | 4 +- core/Auth.php | 23 +- .../017_add_two_factor_authentication.sql | 44 ++ routes/web.php | 15 + 18 files changed, 2072 insertions(+), 24 deletions(-) create mode 100644 app/Controllers/TwoFactorController.php create mode 100644 app/Services/TwoFactorService.php create mode 100644 app/Views/2fa/backup-codes.php create mode 100644 app/Views/2fa/setup.php create mode 100644 app/Views/2fa/verify.php create mode 100644 database/migrations/017_add_two_factor_authentication.sql 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 = " +
+
+

🔐 Two-Factor Authentication

+
+ +
+

Your Verification Code

+ +

Hello {$fullName},

+ +

+ Use this code to complete your two-factor authentication: +

+ +
+
{$code}
+
+ +
+

+ ⏰ 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 +
+
+ "; + + return self::sendNotificationEmail($email, $subject, $htmlContent, [], true); + + } catch (\Exception $e) { + $errorMessage = "Failed to send 2FA verification email: " . $e->getMessage(); + + // Log the error using the application's logger + self::getLogger()->error($errorMessage, [ + 'email' => $email, + 'full_name' => $fullName, + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + + return [ + 'success' => false, + 'message' => $errorMessage, + 'error' => $e->getMessage() + ]; + } + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index a16ca89..9a36649 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -256,5 +256,33 @@ class Setting extends Model return $result; } + + /** + * Get 2FA settings + */ + public function getTwoFactorSettings(): array + { + return [ + 'policy' => $this->getValue('two_factor_policy', 'optional'), + 'rate_limit_minutes' => (int)$this->getValue('two_factor_rate_limit_minutes', 15), + 'email_code_expiry_minutes' => (int)$this->getValue('two_factor_email_code_expiry_minutes', 10) + ]; + } + + /** + * Update 2FA settings + */ + public function updateTwoFactorSettings(array $settings): bool + { + $result = true; + + foreach ($settings as $key => $value) { + if (!$this->setValue($key, $value)) { + $result = false; + } + } + + return $result; + } } diff --git a/app/Models/User.php b/app/Models/User.php index d591c4b..9c0b9ea 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -182,7 +182,7 @@ class User extends Model public function findByVerificationToken(string $token): ?array { $stmt = $this->db->prepare( - "SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0" + "SELECT * FROM users WHERE email_verification_token = ? AND (email_verified IS NULL OR email_verified = 0)" ); $stmt->execute([$token]); $result = $stmt->fetch(); @@ -254,5 +254,35 @@ class User extends Model $stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?"); return $stmt->execute([$token]); } + + /** + * Get user's 2FA status + */ + public function getTwoFactorStatus(int $userId): array + { + $user = $this->find($userId); + if (!$user) { + return ['enabled' => false, 'can_enable' => false, 'required' => false]; + } + + $twoFactorService = new \App\Services\TwoFactorService(); + + return [ + 'enabled' => (bool)$user['two_factor_enabled'], + 'can_enable' => $twoFactorService->canEnableTwoFactor($userId), + 'required' => $twoFactorService->isTwoFactorRequired($userId), + 'setup_at' => $user['two_factor_setup_at'], + 'backup_codes_count' => $user['two_factor_backup_codes'] ? count(json_decode($user['two_factor_backup_codes'], true)) : 0 + ]; + } + + /** + * Check if user has verified email (required for 2FA) + */ + public function hasVerifiedEmail(int $userId): bool + { + $user = $this->find($userId); + return $user && $user['email_verified']; + } } diff --git a/app/Services/TwoFactorService.php b/app/Services/TwoFactorService.php new file mode 100644 index 0000000..3575262 --- /dev/null +++ b/app/Services/TwoFactorService.php @@ -0,0 +1,329 @@ +userModel = new User(); + $this->settingModel = new Setting(); + $this->logger = new Logger('2fa'); + $this->google2fa = new Google2FA(); + } + + /** + * Generate a new TOTP secret for user + */ + public function generateSecret(): string + { + return $this->google2fa->generateSecretKey(); + } + + /** + * Generate QR code data URI for authenticator app + */ + public function generateQrCodeDataUri(string $email, string $secret, string $appName = 'Domain Monitor'): string + { + $qrCode = new QrCode($this->google2fa->getQRCodeUrl($appName, $email, $secret)); + $qrCode->setSize(200); + $qrCode->setMargin(10); + + $writer = new PngWriter(); + $result = $writer->write($qrCode); + + return 'data:image/png;base64,' . base64_encode($result->getString()); + } + + /** + * Verify TOTP code + */ + public function verifyTotpCode(string $secret, string $code, int $window = 1): bool + { + if (strlen($code) !== 6 || !ctype_digit($code)) { + return false; + } + + return $this->google2fa->verifyKey($secret, $code, $window); + } + + + /** + * Generate backup codes for user + */ + public function generateBackupCodes(int $count = 8): array + { + $codes = []; + for ($i = 0; $i < $count; $i++) { + $codes[] = strtoupper(substr(md5(random_bytes(16)), 0, 8)); + } + return $codes; + } + + /** + * Verify backup code + */ + public function verifyBackupCode(int $userId, string $code): bool + { + $user = $this->userModel->find($userId); + if (!$user || !$user['two_factor_enabled'] || !$user['two_factor_backup_codes']) { + return false; + } + + $backupCodes = json_decode($user['two_factor_backup_codes'], true); + if (!is_array($backupCodes)) { + return false; + } + + $codeIndex = array_search(strtoupper($code), $backupCodes); + if ($codeIndex === false) { + return false; + } + + // Remove used backup code + unset($backupCodes[$codeIndex]); + $backupCodes = array_values($backupCodes); // Re-index array + + // Update user with remaining backup codes + $this->userModel->update($userId, [ + 'two_factor_backup_codes' => json_encode($backupCodes) + ]); + + $this->logger->info('Backup code used successfully', [ + 'user_id' => $userId, + 'remaining_codes' => count($backupCodes) + ]); + + return true; + } + + /** + * Generate and send email verification code + */ + public function generateEmailCode(int $userId): array + { + $user = $this->userModel->find($userId); + if (!$user || !$user['email_verified']) { + return ['success' => false, 'error' => 'User email not verified']; + } + + // Clean up expired codes + $this->cleanExpiredEmailCodes($userId); + + // Generate 6-digit code + $code = str_pad(random_int(100000, 999999), 6, '0', STR_PAD_LEFT); + + // Calculate expiry time + $expiryMinutes = (int)$this->settingModel->getValue('two_factor_email_code_expiry_minutes', 10); + $expiresAt = date('Y-m-d H:i:s', time() + ($expiryMinutes * 60)); + + // Store code in database + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "INSERT INTO two_factor_email_codes (user_id, code, expires_at) VALUES (?, ?, ?)" + ); + $stmt->execute([$userId, $code, $expiresAt]); + + // Send email + $result = EmailHelper::sendTwoFactorCode($user['email'], $user['full_name'], $code); + + if ($result['success']) { + $this->logger->info('2FA email code sent', [ + 'user_id' => $userId, + 'email' => $user['email'] + ]); + return ['success' => true, 'expires_at' => $expiresAt]; + } else { + $this->logger->error('Failed to send 2FA email code', [ + 'user_id' => $userId, + 'email' => $user['email'], + 'error' => $result['error'] ?? 'Unknown error' + ]); + return ['success' => false, 'error' => 'Failed to send email code']; + } + } + + /** + * Verify email code + */ + public function verifyEmailCode(int $userId, string $code): bool + { + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "SELECT * FROM two_factor_email_codes + WHERE user_id = ? AND code = ? AND used = 0 AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1" + ); + $stmt->execute([$userId, $code]); + $emailCode = $stmt->fetch(); + + if (!$emailCode) { + return false; + } + + // Mark code as used + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "UPDATE two_factor_email_codes SET used = 1 WHERE id = ?" + ); + $stmt->execute([$emailCode['id']]); + + $this->logger->info('2FA email code verified successfully', [ + 'user_id' => $userId + ]); + + return true; + } + + /** + * Check if user can enable 2FA (email must be verified) + */ + public function canEnableTwoFactor(int $userId): bool + { + $user = $this->userModel->find($userId); + return $user && $user['email_verified'] && !$user['two_factor_enabled']; + } + + /** + * Enable 2FA for user + */ + public function enableTwoFactor(int $userId, string $secret, array $backupCodes): bool + { + $user = $this->userModel->find($userId); + if (!$this->canEnableTwoFactor($userId)) { + return false; + } + + $result = $this->userModel->update($userId, [ + 'two_factor_enabled' => 1, + 'two_factor_secret' => $secret, + 'two_factor_backup_codes' => json_encode($backupCodes), + 'two_factor_setup_at' => date('Y-m-d H:i:s') + ]); + + if ($result) { + $this->logger->info('2FA enabled successfully', [ + 'user_id' => $userId, + 'backup_codes_count' => count($backupCodes) + ]); + } + + return $result; + } + + /** + * Disable 2FA for user + */ + public function disableTwoFactor(int $userId): bool + { + $result = $this->userModel->update($userId, [ + 'two_factor_enabled' => 0, + 'two_factor_secret' => null, + 'two_factor_backup_codes' => null, + 'two_factor_setup_at' => null + ]); + + if ($result) { + // Clean up email codes + $this->cleanExpiredEmailCodes($userId); + + $this->logger->info('2FA disabled successfully', [ + 'user_id' => $userId + ]); + } + + return $result; + } + + /** + * Get 2FA policy setting + */ + public function getTwoFactorPolicy(): string + { + return $this->settingModel->getValue('two_factor_policy', 'optional'); + } + + /** + * Check if 2FA is required for user based on policy + */ + public function isTwoFactorRequired(int $userId): bool + { + $policy = $this->getTwoFactorPolicy(); + + if ($policy === 'disabled') { + return false; + } + + if ($policy === 'forced') { + $user = $this->userModel->find($userId); + return $user && $user['email_verified'] && !$user['two_factor_enabled']; + } + + return false; // Optional policy + } + + /** + * Check rate limiting for 2FA attempts + */ + public function checkRateLimit(string $ipAddress, int $userId = null): bool + { + $rateLimitMinutes = (int)$this->settingModel->getValue('two_factor_rate_limit_minutes', 15); + $since = date('Y-m-d H:i:s', time() - ($rateLimitMinutes * 60)); + + $query = "SELECT COUNT(*) as attempts FROM two_factor_verification_attempts + WHERE ip_address = ? AND created_at > ?"; + $params = [$ipAddress, $since]; + + if ($userId) { + $query .= " AND user_id = ?"; + $params[] = $userId; + } + + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare($query); + $stmt->execute($params); + $result = $stmt->fetch(); + + // Allow max 5 attempts per IP, 3 per user per IP + $maxAttempts = $userId ? 3 : 5; + return $result['attempts'] < $maxAttempts; + } + + /** + * Record 2FA verification attempt + */ + public function recordAttempt(int $userId, string $ipAddress, bool $success): void + { + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "INSERT INTO two_factor_verification_attempts (user_id, ip_address, success) VALUES (?, ?, ?)" + ); + $stmt->execute([$userId, $ipAddress, $success ? 1 : 0]); + } + + /** + * Clean up expired email codes + */ + private function cleanExpiredEmailCodes(int $userId): void + { + $pdo = \Core\Database::getConnection(); + $stmt = $pdo->prepare( + "DELETE FROM two_factor_email_codes WHERE user_id = ? AND expires_at < NOW()" + ); + $stmt->execute([$userId]); + } + +} diff --git a/app/Views/2fa/backup-codes.php b/app/Views/2fa/backup-codes.php new file mode 100644 index 0000000..4075457 --- /dev/null +++ b/app/Views/2fa/backup-codes.php @@ -0,0 +1,209 @@ + + +
+
+
+

2FA Backup Codes

+

Save these codes in a safe place - they can be used to access your account if you lose your authenticator device

+
+ +
+ +
+
+
+ +
+
+

Important Security Notice

+

+ These backup codes are shown only once. Each code can only be used once. Store them securely and never share them with anyone. +

+
+
+
+ + +
+
+

Your Backup Codes

+ +
+ +
+ $code): ?> +
+ + +
+ +
+
+ + +
+

How to use backup codes:

+
    +
  • • When logging in, enter a backup code instead of your 2FA code
  • +
  • • Each backup code can only be used once
  • +
  • • After using a code, it will be automatically removed from your account
  • +
  • • If you run out of backup codes, you'll need to disable and re-enable 2FA
  • +
+
+ + + +
+
+
+ + + + diff --git a/app/Views/2fa/setup.php b/app/Views/2fa/setup.php new file mode 100644 index 0000000..7233a62 --- /dev/null +++ b/app/Views/2fa/setup.php @@ -0,0 +1,162 @@ + + +
+
+
+

+ + Setup Two-Factor Authentication +

+
+ +
+ +
+

Step 1: Install an Authenticator App

+

Download one of these apps on your mobile device:

+ +
+
+ +

Google Authenticator

+

iOS & Android

+
+
+ +

Authy

+

iOS & Android

+
+
+ +

Microsoft Authenticator

+

iOS & Android

+
+
+
+ + +
+

Step 2: Scan QR Code

+

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. +

+
+
+ +
+
+ QR Code for 2FA setup +
+ +
+

Can't scan? Enter this code manually:

+
+ +
+
+
+
+ + +
+

Step 3: Verify Setup

+

Enter the 6-digit code from your authenticator app:

+ +
+ + +
+ +

Enter 6-digit code

+
+ +
+ + + + Cancel + +
+
+
+ + +
+
+ +
+

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. +

+
+
+
+
+
+
+ + + + diff --git a/app/Views/2fa/verify.php b/app/Views/2fa/verify.php new file mode 100644 index 0000000..cf97b4f --- /dev/null +++ b/app/Views/2fa/verify.php @@ -0,0 +1,206 @@ +checkRateLimit($_SERVER['REMOTE_ADDR'] ?? '', $user['id']); +?> + +
+
+ +
+

+ 2FA Verification +

+

+ Hello, !
+ Please enter your 2FA code to complete login. +

+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + + +
+ + + +
+
+ + Security verification completed during login +
+
+ +
+ + +

Enter 6-digit code from your authenticator app, email code, or 8-character backup code

+
+ + + +
+ + + + + + Email code unavailable + + + + + Sign out instead + +
+ +
+
+

+ Having trouble? You can also use a backup code or contact your administrator for help. +

+
+
+
+ + + + diff --git a/app/Views/profile/index.php b/app/Views/profile/index.php index 4389951..645a938 100644 --- a/app/Views/profile/index.php +++ b/app/Views/profile/index.php @@ -4,6 +4,11 @@ $pageTitle = 'My Profile'; $pageDescription = 'Manage your account settings and preferences'; $pageIcon = 'fas fa-user-circle'; ob_start(); + +// Get 2FA status +$twoFactorStatus = $userModel->getTwoFactorStatus($user['id']); +$twoFactorService = new \App\Services\TwoFactorService(); +$twoFactorPolicy = $twoFactorService->getTwoFactorPolicy(); ?> @@ -54,6 +59,10 @@ ob_start(); Security + + + + + + + + + + + +
+
+ +
+

Two-Factor Authentication Required

+

You must enable 2FA to continue using your account.

+
+
+
+ +
+ + + Enable Two-Factor Authentication + +
+ + +
+
+
+ +
+

Enhanced Security Available

+

+ Enable two-factor authentication to add an extra layer of security to your account. +

+
+
+
+ + + +
+

How 2FA Works

+
    +
  • • Generate time-based codes using an authenticator app
  • +
  • • Use backup codes if you lose access to your device
  • +
  • • Receive email codes as an alternative method
  • +
  • • Enhanced protection against unauthorized access
  • +
+
+
+ + + + + + +
+
+

Two-Factor Authentication

+

Configure 2FA policy and security settings

+
+ + + +
+
+ + +

+ + Users must have verified email addresses to enable 2FA +

+
+ +
+
+ + +

Maximum failed attempts per IP address

+
+ +
+ + +

How long email backup codes remain valid

+
+
+ + +
+

+ + Two-Factor Authentication Features +

+
    +
  • TOTP Authenticator Apps: Google Authenticator, Authy, Microsoft Authenticator
  • +
  • Email Backup Codes: One-time codes sent to verified email addresses
  • +
  • Backup Recovery Codes: 8 single-use codes generated during setup
  • +
  • Rate Limiting: Prevents brute force attacks on verification codes
  • +
+
+
+ +
+ +
+ +
+