diff --git a/app/classes/passwordReset.php b/app/classes/passwordReset.php new file mode 100644 index 0000000..8b7c4fb --- /dev/null +++ b/app/classes/passwordReset.php @@ -0,0 +1,172 @@ +db = $database; + } else { + $this->db = $database->getConnection(); + } + } + + /** + * Creates a password reset request and sends email to user + * + * @param string $email User's email address + * @return array Status of the reset request + */ + public function requestReset($email) { + // Check if email exists + $query = $this->db->prepare(" + SELECT u.id, um.email + FROM users u + JOIN users_meta um ON u.id = um.user_id + WHERE um.email = :email" + ); + $query->bindParam(':email', $email); + $query->execute(); + + $user = $query->fetch(PDO::FETCH_ASSOC); + if (!$user) { + return ['success' => false, 'message' => 'If this email exists in our system, you will receive reset instructions.']; + } + + // Generate unique token + $token = bin2hex(random_bytes(self::TOKEN_LENGTH / 2)); + $expires = time() + self::TOKEN_EXPIRY; + + // Store token in database + $query = $this->db->prepare(" + INSERT INTO user_password_reset (user_id, token, expires) + VALUES (:user_id, :token, :expires)" + ); + $query->bindParam(':user_id', $user['id']); + $query->bindParam(':token', $token); + $query->bindParam(':expires', $expires); + + if (!$query->execute()) { + return ['success' => false, 'message' => 'Failed to process reset request']; + } + + // We need the config for the email details + global $config; + + // Prepare the reset link + $scheme = $_SERVER['REQUEST_SCHEME']; + $domain = trim($config['domain'], '/'); + $folder = trim($config['folder'], '/'); + $folderPath = $folder !== '' ? "/$folder" : ''; + $resetLink = "{$scheme}://{$domain}{$folderPath}/index.php?page=login&action=reset&token=" . urlencode($token); + + // Send email with reset link + $to = $user['email']; + $subject = "{$config['site_name']} - Password reset request"; + $message = "Dear user,\n\n"; + $message .= "We received a request to reset your password for your {$config['site_name']} account.\n\n"; + $message .= "To set a new password, please click the link below:\n\n"; + $message .= $resetLink . "\n\n"; + $message .= "This link will expire in 1 hour for security reasons.\n\n"; + $message .= "If you did not request this password reset, please ignore this email. Your account remains secure.\n\n"; + if (!empty($config['site_name'])) { + $message .= "Best regards,\n"; + $message .= "The {$config['site_name']} team\n"; + if (!empty($config['site_slogan'])) { + $message .= ":: {$config['site_slogan']} ::"; + } + } + + $headers = [ + 'From' => "noreply@{$config['domain']}", + 'Reply-To' => "noreply@{$config['domain']}", + 'X-Mailer' => 'PHP/' . phpversion() + ]; + + if (!mail($to, $subject, $message, $headers)) { + return ['success' => false, 'message' => 'Failed to send reset email']; + } + + return ['success' => true, 'message' => 'If this email exists in our system, you will receive reset instructions.']; + } + + /** + * Validates a reset token and returns associated user ID if valid + * + * @param string $token Reset token + * @return array Validation result with user ID if successful + */ + public function validateToken($token) { + $now = time(); + + $query = $this->db->prepare(" + SELECT user_id + FROM user_password_reset + WHERE token = :token + AND expires > :now + AND used = 0 + "); + + $query->bindParam(':token', $token); + $query->bindParam(':now', $now); + $query->execute(); + + $result = $query->fetch(PDO::FETCH_ASSOC); + + if (!$result) { + return ['valid' => false]; + } + + return ['valid' => true, 'user_id' => $result['user_id']]; + } + + /** + * Completes the password reset process + * + * @param string $token Reset token + * @param string $newPassword New password + * @return bool Whether reset was successful + */ + public function resetPassword($token, $newPassword) { + $validation = $this->validateToken($token); + if (!$validation['valid']) { + return false; + } + + // Start transaction + $this->db->beginTransaction(); + + try { + // Update password + $hashedPassword = password_hash($newPassword, PASSWORD_DEFAULT); + $query = $this->db->prepare( + "UPDATE user + SET password = :password + WHERE id = :user_id" + ); + $query->bindParam(':password', $hashedPassword); + $query->bindParam(':user_id', $validation['user_id']); + $query->execute(); + + // Mark token as used + $query = $this->db->prepare( + "UPDATE user_password_reset + SET used = 1 + WHERE token = :token" + ); + $query->bindParam(':token', $token); + $query->execute(); + + $this->db->commit(); + return true; + } catch (Exception $e) { + $this->db->rollBack(); + return false; + } + } +} diff --git a/app/pages/login.php b/app/pages/login.php index 082f40a..4d17b58 100644 --- a/app/pages/login.php +++ b/app/pages/login.php @@ -19,11 +19,11 @@ unset($error); try { // connect to database - $dbWeb = connectDB($config)['db']; + $db = connectDB($config)['db']; // Initialize RateLimiter require_once '../app/classes/ratelimiter.php'; - $rateLimiter = new RateLimiter($dbWeb); + $rateLimiter = new RateLimiter($db); // Get user IP $user_IP = getUserIP(); @@ -38,7 +38,7 @@ try { $rememberMe = isset($_SESSION['2fa_pending_remember']); require_once '../app/classes/twoFactorAuth.php'; - $twoFactorAuth = new TwoFactorAuthentication($dbWeb); + $twoFactorAuth = new TwoFactorAuthentication($db); if ($twoFactorAuth->verify($userId, $code)) { // Complete login @@ -63,6 +63,122 @@ try { // Load the 2FA verification template include '../app/templates/credentials-2fa-verify.php'; exit(); + } elseif ($action === 'forgot') { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Handle password reset request + try { + // Validate CSRF token + $security = SecurityHelper::getInstance(); + if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) { + throw new Exception('Invalid security token. Please try again.'); + } + + // Apply rate limiting + if (!$rateLimiter->isIpWhitelisted($user_IP)) { + if ($rateLimiter->isIpBlacklisted($user_IP)) { + throw new Exception(Feedback::get('LOGIN', 'IP_BLACKLISTED')['message']); + } + if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) { + throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']); + } + $rateLimiter->attempt('password_reset', $user_IP); + } + + // Validate email + $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL); + if (!$email) { + throw new Exception('Please enter a valid email address.'); + } + + // Process reset request + require_once '../app/classes/passwordReset.php'; + $resetHandler = new PasswordReset($db); + $result = $resetHandler->requestReset($email); + + // Always show same message whether email exists or not for security + Feedback::flash('NOTICE', 'DEFAULT', $result['message']); + header("Location: $app_root?page=login"); + exit(); + + } catch (Exception $e) { + Feedback::flash('ERROR', 'DEFAULT', $e->getMessage()); + } + } + + // Generate CSRF token + $security = SecurityHelper::getInstance(); + $security->generateCsrfToken(); + + // Load the forgot password form + include '../app/helpers/feedback.php'; + include '../app/templates/form-password-forgot.php'; + exit(); + + } elseif ($action === 'reset' && isset($_GET['token'])) { + // Handle password reset + try { + require_once '../app/classes/passwordReset.php'; + $resetHandler = new PasswordReset($db); + $token = $_GET['token']; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + // Validate CSRF token + $security = SecurityHelper::getInstance(); + if (!$security->verifyCsrfToken($_POST['csrf_token'] ?? '')) { + throw new Exception('Invalid security token. Please try again.'); + } + + // Apply rate limiting + if (!$rateLimiter->isIpWhitelisted($user_IP)) { + if ($rateLimiter->tooManyAttempts('password_reset', $user_IP)) { + throw new Exception(Feedback::get('LOGIN', 'TOO_MANY_ATTEMPTS')['message']); + } + $rateLimiter->attempt('password_reset', $user_IP); + } + + // Validate password + require_once '../app/classes/validator.php'; + $validator = new Validator($_POST); + $rules = [ + 'new_password' => [ + 'required' => true, + 'min' => 8 + ], + 'confirm_password' => [ + 'required' => true, + 'matches' => 'new_password' + ] + ]; + + if (!$validator->validate($rules)) { + throw new Exception($validator->getFirstError()); + } + + // Reset password + if ($resetHandler->resetPassword($token, $_POST['new_password'])) { + Feedback::flash('NOTICE', 'DEFAULT', 'Your password has been reset successfully. You can now log in.'); + header("Location: $app_root?page=login"); + exit(); + } + throw new Exception('Invalid or expired reset link. Please request a new one.'); + } + + // Verify token is valid + $validation = $resetHandler->validateToken($token); + if (!$validation['valid']) { + throw new Exception('Invalid or expired reset link. Please request a new one.'); + } + + // Show reset password form + include '../app/helpers/feedback.php'; + include '../app/templates/form-password-reset.php'; + exit(); + + } catch (Exception $e) { + Feedback::flash('ERROR', 'DEFAULT', $e->getMessage()); + header("Location: $app_root?page=login&action=forgot"); + exit(); + } } if ( $_SERVER['REQUEST_METHOD'] == 'POST' && $action !== 'verify' ) { diff --git a/app/templates/form-login.php b/app/templates/form-login.php index c14fea2..f1a14b8 100644 --- a/app/templates/form-login.php +++ b/app/templates/form-login.php @@ -2,17 +2,17 @@
Welcome to JILO!
Please enter login credentials:
Welcome to = htmlspecialchars($config['site_name']); ?>!
Please enter login credentials:
Enter your email address and we will send you
+ instructions to reset your password.