Some checks failed
SonarQube Scan / SonarQube Trigger (push) Failing after 5m27s
439 lines
17 KiB
PHP
439 lines
17 KiB
PHP
<?php
|
||
|
||
namespace DJMixHosting;
|
||
|
||
use DateMalformedStringException;
|
||
use DateTime;
|
||
use Exception;
|
||
use Random\RandomException;
|
||
|
||
class User {
|
||
|
||
private Database $db;
|
||
private string $id;
|
||
private string $username;
|
||
private string $firstName;
|
||
private string $lastName;
|
||
private string $email;
|
||
private string $location;
|
||
private string $bio;
|
||
private string $created;
|
||
private string $updated;
|
||
private bool $verified;
|
||
private string $role;
|
||
private string $img = "";
|
||
private string $api_key;
|
||
|
||
public function __construct($db, $id = null) {
|
||
$this->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; }
|
||
}
|