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

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

How 2FA Works

+ +
+
+ + + + + + +
+
+

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