db = $db; if ($id) { $this->loadUserById($id); } } /** * Load user data from the database by id. */ private function loadUserById($id): void { $stmt = $this->db->prepare("SELECT * FROM users WHERE id = ?"); $stmt->bind_param("i", $id); $stmt->execute(); $user_data = $stmt->get_result()->fetch_assoc(); $stmt->close(); if ($user_data) { $this->id = $user_data['id']; $this->username = $user_data['username']; $this->firstName = $user_data['firstName']; $this->lastName = $user_data['lastName']; $this->email = $user_data['email']; $this->verified = $user_data['emailVerified']; $this->img = $user_data['img']; $this->api_key = $user_data['apiKey']; $this->created = $user_data['created']; $this->updated = $user_data['lastupdated']; $this->role = $user_data['isAdmin'] ? 'admin' : 'user'; // New fields loaded from the database $this->location = $user_data['location'] ?? ""; $this->bio = $user_data['bio'] ?? ""; } } /** * Register a new user. * * @throws RandomException if the user already exists. */ public function newUser(string $username, string $password, string $email, string $firstName, string $lastName): int { if ($this->check_existing_user($username, $email)) { throw new RandomException("User already exists"); } $this->username = $username; $this->email = $email; $this->firstName = $firstName; $this->lastName = $lastName; $password_hashed = password_hash($password, PASSWORD_DEFAULT); // Set default values for optional fields. $this->location = ""; $this->bio = ""; $this->created = date('Y-m-d H:i:s'); $this->updated = date('Y-m-d H:i:s'); $this->verified = 0; $this->role = "user"; $this->img = ""; $this->api_key = bin2hex(random_bytes(32)); $stmt = $this->db->prepare("INSERT INTO users (username, password, email, firstName, lastName, img, emailVerified, apiKey, location, bio) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?)"); $stmt->bind_param("sssssssss", $this->username, $password_hashed, $this->email, $this->firstName, $this->lastName, $this->img, $this->api_key, $this->location, $this->bio); $stmt->execute(); $userId = $stmt->insert_id; $stmt->close(); $this->id = $userId; return $userId; } private function check_existing_user($username, $email): false|array|null { $stmt = $this->db->prepare("SELECT * FROM users WHERE username = ? OR email = ?"); $stmt->bind_param("ss", $username, $email); $stmt->execute(); $result = $stmt->get_result(); $user = $result->fetch_assoc(); $stmt->close(); return $user; } /** * Login a user by email and password. * * Returns the user data array if successful. In case of failure, * a string error message is returned. * @throws DateMalformedStringException */ public function login($email, $password): array|string { // Retrieve user record by email $stmt = $this->db->prepare("SELECT * FROM users WHERE email = ?"); $stmt->bind_param("s", $email); $stmt->execute(); $result = $stmt->get_result(); $user_data = $result->fetch_assoc(); $stmt->close(); // Check login_attempts table for lockout status $stmt = $this->db->prepare("SELECT * FROM login_attempts WHERE email = ?"); $stmt->bind_param("s", $email); $stmt->execute(); $attempt_data = $stmt->get_result()->fetch_assoc(); $stmt->close(); $current_time = new DateTime(); if ($attempt_data && !empty($attempt_data['lockout_until'])) { $lockout_until = new DateTime($attempt_data['lockout_until']); if ($current_time < $lockout_until) { return "Account locked until " . $lockout_until->format('Y-m-d H:i:s') . ". Please try again later."; } } // If no user record found, still update login_attempts to mitigate enumeration issues if (!$user_data) { $this->updateFailedAttempt($email); return "Invalid email or password."; } // Verify the password using password_verify if (password_verify($password, $user_data['password'])) { // Successful login – clear login attempts. $this->resetLoginAttempts($email); // Return the user data for further session handling return $user_data; } else { $attempts = $this->updateFailedAttempt($email); return "Invalid email or password. Attempt $attempts of 3."; } } /** * Update (or create) a record in the login_attempts table for a failed attempt. * If attempts reach 3, set a lockout that doubles each time. * Returns the current number of attempts. * @throws DateMalformedStringException */ private function updateFailedAttempt($email) { // Check for an existing record $stmt = $this->db->prepare("SELECT * FROM login_attempts WHERE email = ?"); $stmt->bind_param("s", $email); $stmt->execute(); $record = $stmt->get_result()->fetch_assoc(); $stmt->close(); $current_time = new DateTime(); if ($record) { $attempts = $record['attempts'] + 1; $lockouts = $record['lockouts']; if ($attempts >= 3) { // Increment lockouts and calculate the new lockout duration: // Duration in minutes = 30 * 2^(lockouts) $lockouts++; $duration = 30 * pow(2, $lockouts - 1); $lockout_until = clone $current_time; $lockout_until->modify("+{$duration} minutes"); // Reset attempts to 0 on lockout $attempts = 0; $stmt = $this->db->prepare("UPDATE login_attempts SET attempts = ?, lockouts = ?, last_attempt = NOW(), lockout_until = ? WHERE email = ?"); $lockout_until_str = $lockout_until->format('Y-m-d H:i:s'); $stmt->bind_param("iiss", $attempts, $lockouts, $lockout_until_str, $email); } else { $stmt = $this->db->prepare("UPDATE login_attempts SET attempts = ?, last_attempt = NOW() WHERE email = ?"); $stmt->bind_param("is", $attempts, $email); } } else { // Create a new record for this email $attempts = 1; $lockouts = 0; $stmt = $this->db->prepare("INSERT INTO login_attempts (email, attempts, lockouts, last_attempt) VALUES (?, ?, ?, NOW())"); $stmt->bind_param("sii", $email, $attempts, $lockouts); } $stmt->execute(); $stmt->close(); return $attempts; } /** * Reset the login_attempts record for the given email. */ private function resetLoginAttempts($email): void { $stmt = $this->db->prepare("DELETE FROM login_attempts WHERE email = ?"); $stmt->bind_param("s", $email); $stmt->execute(); $stmt->close(); } /** * Update the user's email address. * * @param string $newEmail * @param array $config Configuration array for AWS SES and app settings. * @return string Success message. * @throws Exception on validation or email-sending failure. */ public function updateEmail(string $newEmail, array $config): string { $newEmail = filter_var($newEmail, FILTER_VALIDATE_EMAIL); if (!$newEmail) { throw new Exception("Invalid email format."); } // Update email and mark as unverified. $stmt = $this->db->prepare("UPDATE users SET email = ?, emailVerified = 0 WHERE id = ?"); $stmt->bind_param("si", $newEmail, $this->id); $stmt->execute(); $stmt->close(); // Generate verification code and expiry (15 minutes from now) $verification_code = bin2hex(random_bytes(16)); $expires_at = date("Y-m-d H:i:s", strtotime("+15 minutes")); // Store the verification record. $stmt = $this->db->prepare("REPLACE INTO email_verifications (user_id, email, verification_code, expires_at) VALUES (?, ?, ?, ?)"); $stmt->bind_param("isss", $this->id, $newEmail, $verification_code, $expires_at); $stmt->execute(); $stmt->close(); // Use the new Email class to send the verification email. $emailObj = new Email($config); $emailObj->sendVerificationEmail($newEmail, $verification_code); // Update the class properties. $this->email = $newEmail; $this->verified = 0; return "Email updated. A verification email has been sent to your new address."; } /** * @throws Exception */ public function updateName($firstName, $lastName): string { // Update the user's name. $stmt = $this->db->prepare("UPDATE users SET firstName = ?, lastName = ? WHERE id = ?"); $stmt->bind_param("ssi", $firstName, $lastName, $this->id); if (!$stmt->execute()) { $stmt->close(); throw new Exception("Failed to update name. Please try again."); } $stmt->close(); // Optionally update class properties. $this->firstName = $firstName; $this->lastName = $lastName; return "Name updated successfully."; } /** * @throws Exception */ public function updatePassword($currentPassword, $newPassword, $confirmPassword): string { // Retrieve the current password hash. $stmt = $this->db->prepare("SELECT password FROM users WHERE id = ?"); $stmt->bind_param("i", $this->id); $stmt->execute(); $userData = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$userData || !password_verify($currentPassword, $userData['password'])) { throw new Exception("Current password is incorrect."); } if ($newPassword !== $confirmPassword) { throw new Exception("New password and confirmation do not match."); } // Validate the new password. $pattern = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,32}$/'; if (!preg_match($pattern, $newPassword)) { throw new Exception("New password must be 8-32 characters and include at least one uppercase letter, one lowercase letter, one number, and one symbol."); } $hashed_new_password = password_hash($newPassword, PASSWORD_DEFAULT); $stmt = $this->db->prepare("UPDATE users SET password = ? WHERE id = ?"); $stmt->bind_param("si", $hashed_new_password, $this->id); if (!$stmt->execute()) { $stmt->close(); throw new Exception("Failed to update password. Please try again."); } $stmt->close(); return "Password updated successfully."; } /** * @throws Exception */ public function updateUsername($newUsername): string { // Validate username format. if (!preg_match('/^[a-zA-Z0-9_]{3,25}$/', $newUsername)) { throw new Exception("Invalid username format."); } // Check if the new username already exists for another user. $stmt = $this->db->prepare("SELECT id FROM users WHERE username = ? AND id != ?"); $stmt->bind_param("si", $newUsername, $this->id); $stmt->execute(); $result = $stmt->get_result(); if ($result->num_rows > 0) { $stmt->close(); throw new Exception("Username already taken."); } $stmt->close(); // Update the username. $stmt = $this->db->prepare("UPDATE users SET username = ? WHERE id = ?"); $stmt->bind_param("si", $newUsername, $this->id); $stmt->execute(); $stmt->close(); $this->username = $newUsername; return "Username updated successfully."; } /** * Verify the user's email using the provided verification code. * * @param string $verification_code The code submitted by the user. * @return string Success message. * @throws Exception If the code is invalid or expired. */ public function verifyEmail(string $verification_code): string { // Look up the verification record for this user and code $stmt = $this->db->prepare("SELECT * FROM email_verifications WHERE user_id = ? AND verification_code = ?"); $stmt->bind_param("is", $this->id, $verification_code); $stmt->execute(); $result = $stmt->get_result(); $record = $result->fetch_assoc(); $stmt->close(); if (!$record) { throw new Exception("Invalid verification code."); } // Check if the verification code has expired $current_time = new DateTime(); $expires_at = new DateTime($record['expires_at']); if ($current_time > $expires_at) { throw new Exception("Verification code has expired. Please request a new one."); } // Update the user's record to mark the email as verified $stmt = $this->db->prepare("UPDATE users SET emailVerified = 1 WHERE id = ?"); $stmt->bind_param("i", $this->id); $stmt->execute(); $stmt->close(); // Remove the verification record to clean up $stmt = $this->db->prepare("DELETE FROM email_verifications WHERE user_id = ?"); $stmt->bind_param("i", $this->id); $stmt->execute(); $stmt->close(); // Update the object property $this->verified = 1; return "Email verified successfully."; } // New setters for location and bio /** * @throws Exception */ public function setLocation(string $location): string { $stmt = $this->db->prepare("UPDATE users SET location = ? WHERE id = ?"); $stmt->bind_param("si", $location, $this->id); if ($stmt->execute()) { $this->location = $location; $stmt->close(); return "Location updated successfully."; } $stmt->close(); throw new Exception("Failed to update location."); } /** * @throws Exception */ public function setBio(string $bio): string { $stmt = $this->db->prepare("UPDATE users SET bio = ? WHERE id = ?"); $stmt->bind_param("si", $bio, $this->id); if ($stmt->execute()) { $this->bio = $bio; $stmt->close(); return "Bio updated successfully."; } $stmt->close(); throw new Exception("Failed to update bio."); } // Getter methods public function getId(): string { return $this->id; } public function getUsername(): string { return $this->username; } public function getFirstName(): string { return $this->firstName; } public function getLastName(): string { return $this->lastName; } public function getEmail(): string { return $this->email; } public function getLocation(): string { return $this->location; } public function getBio(): string { return $this->bio; } public function getCreated(): string { return $this->created; } public function getUpdated(): string { return $this->updated; } public function getVerified(): string { return $this->verified; } public function getRole(): string { return $this->role; } public function getImg(): string { return $this->img; } public function getApiKey(): string { return $this->api_key; } }