connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1'); $stmt->execute([':email' => trim($identifier), ':name' => trim($identifier)]); return $stmt->fetch() ?: null; } public static function passwordError(string $password, string $confirmation): string { if ($password !== $confirmation) return 'Passwords must match.'; // PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation. if (strlen($password) < 12 || strlen($password) > 72 || str_contains($password, "\0")) { return 'Use a password between 12 and 72 bytes long.'; } return ''; } /** Shared database counters survive session/cookie changes. Requires the auth migration. */ public function allowAttempt(string $scope, string $identifier, int $limit, int $seconds): bool { $db = $this->connect(); $key = hash('sha256', $scope . ':' . strtolower(trim($identifier))); $stmt = $db->prepare('INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1), window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)'); $stmt->execute([':key' => $key, ':seconds' => $seconds, ':window' => $seconds]); $stmt = $db->prepare('SELECT attempts FROM auth_attempts WHERE attempt_key = :key'); $stmt->execute([':key' => $key]); return (int) $stmt->fetchColumn() <= $limit; } public function login(string $identifier, string $password): bool { $ipAllowed = $this->allowAttempt('login-ip', $_SERVER['REMOTE_ADDR'] ?? '', 50, 900); $accountAllowed = $this->allowAttempt('login-account', $identifier, 10, 900); if (!$ipAllowed || !$accountAllowed) return false; $user = $this->findUser($identifier); // A real hash avoids an immediate return for unknown accounts. $hash = $user['user_password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'; $verified = password_verify($password, $hash); if (!$user || !$verified || $user['user_status'] !== 'active') return false; if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { $stmt = $this->connect()->prepare('UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash'); $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]); } session_regenerate_id(true); $_SESSION[self::USER_AUTHENTICATED] = true; $_SESSION[self::USER_SERIAL] = (int) $user['user_serial']; $_SESSION[self::USER_NAME] = $user['user_name']; $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; $_SESSION[self::USER_EMAIL] = $user['user_email']; $_SESSION[self::USER_ROLE] = $user['user_role']; $_SESSION['auth_version'] = (int) $user['user_auth_version']; $_SESSION['last_activity'] = time(); $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id'); $stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]); return true; } public function currentUser(): ?array { if (empty($_SESSION[self::USER_AUTHENTICATED])) return null; if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT) return null; $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id'); $stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]); $user = $stmt->fetch(); if (!$user || $user['user_status'] !== 'active' || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1)) return null; $_SESSION['last_activity'] = time(); $_SESSION[self::USER_ROLE] = $user['user_role']; $_SESSION[self::USER_NAME] = $user['user_name']; $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; $_SESSION[self::USER_EMAIL] = $user['user_email']; return $user; } public function register(array $input): void { $first = trim($input['user_first_name'] ?? ''); $last = trim($input['user_last_name'] ?? ''); $email = trim($input['user_email'] ?? ''); $name = trim($input['user_name'] ?? ''); if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128) throw new InvalidArgumentException('Enter your first and last name (up to 128 bytes each).'); if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100) throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).'); if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name)) throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.'); $error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? ''); if ($error) throw new InvalidArgumentException($error); $db = $this->connect(); // Cross-field uniqueness keeps email-or-username login unambiguous. $stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1'); $stmt->execute([':name' => $name, ':email' => $email, ':email2' => $email, ':name2' => $name]); if ($stmt->fetch()) throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); $stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')"); try { $stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]); } catch (PDOException $e) { if ($e->getCode() === '23000') throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); throw $e; } } /** Never modify the password when somebody merely requests an email. */ public function requestReset(string $identifier): void { $ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600); $accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600); if (!$ipAllowed || !$accountAllowed) return; $user = $this->findUser($identifier); if (!$user || $user['user_status'] !== 'active') return; $baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/'); if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https' || parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null || parse_url($baseUrl, PHP_URL_USER) !== null || parse_url($baseUrl, PHP_URL_PASS) !== null) { throw new RuntimeException('Configure an HTTPS ApplicationUrl before sending reset emails.'); } $token = bin2hex(random_bytes(32)); $db = $this->connect(); $stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))'); $hash = hash('sha256', $token); $stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]); $url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token); if (!$this->deliverResetEmail($user, $url)) { $stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash'); $stmt->execute([':hash' => $hash]); throw new RuntimeException('Password reset email delivery failed.'); } } protected function deliverResetEmail(array $user, string $url): bool { return (new Email())->sendPasswordReset($user, $url); } public function resetPassword(string $token, string $password, string $confirmation): bool { $error = self::passwordError($password, $confirmation); if ($error) throw new InvalidArgumentException($error); if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) return false; $db = $this->connect(); $db->beginTransaction(); try { // Serialize resets and password changes on the user row. $stmt = $db->prepare('SELECT user_serial FROM password_resets WHERE token_hash = :hash'); $stmt->execute([':hash' => hash('sha256', $token)]); $id = $stmt->fetchColumn(); if (!$id) { $db->rollBack(); return false; } $stmt = $db->prepare("SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); $stmt->execute([':id' => $id]); if (!$stmt->fetch()) { $db->rollBack(); return false; } $stmt = $db->prepare('SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE'); $stmt->execute([':hash' => hash('sha256', $token)]); if (!$stmt->fetch()) { $db->rollBack(); return false; } $this->storePassword($db, (int) $id, $password); $db->commit(); return true; } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); throw $e; } } public function changePassword(int $id, string $current, string $password, string $confirmation): bool { $error = self::passwordError($password, $confirmation); if ($error) throw new InvalidArgumentException($error); $db = $this->connect(); $db->beginTransaction(); try { $stmt = $db->prepare("SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); $stmt->execute([':id' => $id]); $user = $stmt->fetch(); if (!$user || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1) || !password_verify($current, $user['user_password_hash'])) { $db->rollBack(); return false; } $this->storePassword($db, $id, $password); $db->commit(); return true; } catch (Throwable $e) { if ($db->inTransaction()) $db->rollBack(); throw $e; } } private function storePassword(PDO $db, int $id, string $password): void { $stmt = $db->prepare('UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id'); $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $id]); $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial = :id'); $stmt->execute([':id' => $id]); } }