checkStrength('mypassword', ['john', 'john@example.com']); * * // Change password (profile page — verifies current password) * $pm->change($user_id, $current_password, $new_password, $confirm_password); * * // Force set password (admin reset / login forced reset — no current password) * $pm->forceSet($user_id, $new_password, $confirm_password); */ class PasswordManager { private $pdo; private $zxcvbn_path; /** Minimum zxcvbn score required (0–4). 3 = "safely unguessable" */ const MIN_SCORE = 3; public function __construct($pdo, string $include_url) { $this->pdo = $pdo; $this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php'; } // ───────────────────────────────────────────────────────────── // Public: strength check (used by live AJAX feedback endpoint) // ───────────────────────────────────────────────────────────── /** * Run zxcvbn strength analysis. * * @param string $password * @param array $user_inputs Personal data to penalise (name, email, username…) * @return array ['score' => 0-4, 'warning' => string, 'suggestions' => array] */ public function checkStrength(string $password, array $user_inputs = []): array { $this->loadZxcvbn(); $zxcvbn = new \ZxcvbnPhp\Zxcvbn(); $result = $zxcvbn->passwordStrength($password, $user_inputs); return [ 'score' => (int) $result['score'], 'warning' => $result['feedback']['warning'] ?? '', 'suggestions' => $result['feedback']['suggestions'] ?? [], ]; } // ───────────────────────────────────────────────────────────── // Public: change password (profile — verifies current password) // ───────────────────────────────────────────────────────────── /** * Change password for an authenticated user. * Verifies current password before applying the new one. * * @throws InvalidArgumentException on validation failure (safe to show user) * @throws RuntimeException on DB failure (log internally, show generic message) */ public function change(int $user_id, string $current_password, string $new_password, string $confirm_password): void { // ── Basic field validation ──────────────────────────────── if (empty($current_password) || empty($new_password) || empty($confirm_password)) { throw new \InvalidArgumentException('All password fields are required.'); } if ($new_password !== $confirm_password) { throw new \InvalidArgumentException('New passwords do not match.'); } // ── Load user record ────────────────────────────────────── $user = $this->fetchUser($user_id); // ── Verify current password ─────────────────────────────── if (!password_verify($current_password, $user['password'])) { throw new \InvalidArgumentException('Current password is incorrect.'); } // ── Strength check ──────────────────────────────────────── $this->enforceStrength($new_password, $user); // ── Hash and persist ────────────────────────────────────── $this->persist($user_id, $new_password); } // ───────────────────────────────────────────────────────────── // Public: force set password (admin reset / login forced reset) // ───────────────────────────────────────────────────────────── /** * Force-set a new password without requiring the current password. * Use for: admin-initiated reset, forgot-password flow, first-login forced change. * * @throws InvalidArgumentException on validation failure * @throws RuntimeException on DB failure */ public function forceSet(int $user_id, string $new_password, string $confirm_password): void { if (empty($new_password) || empty($confirm_password)) { throw new \InvalidArgumentException('Password fields are required.'); } if ($new_password !== $confirm_password) { throw new \InvalidArgumentException('Passwords do not match.'); } $user = $this->fetchUser($user_id); $this->enforceStrength($new_password, $user); $this->persist($user_id, $new_password); } // ───────────────────────────────────────────────────────────── // Public: HTTP handlers (call from thin API endpoint files) // ───────────────────────────────────────────────────────────── /** * Handle AJAX strength-check request and echo JSON response. * Endpoint: setting/api/engine/check_password.php * * Expected $data keys: password */ public function handleCheck(array $data): void { $password = $data['password'] ?? ''; if (empty($password)) { echo json_encode(['success' => 1, 'score' => -1, 'feedback' => '']); exit; } $result = $this->checkStrength($password); $feedback = $result['warning'] ?: ($result['suggestions'][0] ?? ''); echo json_encode([ 'success' => 1, 'score' => $result['score'], 'feedback' => $feedback, ]); exit; } /** * Handle AJAX change-password request and echo JSON response. * Endpoint: setting/api/engine/change_password.php * * Expected $data keys: current_password, new_password, confirm_password */ public function handleChange(int $user_id, array $data): void { try { $this->change( $user_id, $data['current_password'] ?? '', $data['new_password'] ?? '', $data['confirm_password'] ?? '' ); session_destroy(); echo json_encode(['success' => 1, 'message' => 'Password changed successfully.']); } catch (\InvalidArgumentException $e) { http_response_code(400); echo json_encode(['success' => 0, 'message' => $e->getMessage()]); } catch (\Exception $e) { error_log('[PasswordManager::handleChange] ' . $e->getMessage()); http_response_code(500); echo json_encode(['success' => 0, 'message' => 'Failed to change password. Please try again.']); } exit; } // ───────────────────────────────────────────────────────────── // Private helpers // ───────────────────────────────────────────────────────────── private function loadZxcvbn(): void { if (!file_exists($this->zxcvbn_path)) { throw new \RuntimeException('zxcvbn autoloader not found at: ' . $this->zxcvbn_path); } require_once $this->zxcvbn_path; } private function fetchUser(int $user_id): array { $sth = $this->pdo->prepare( 'SELECT user_id, username, name, surname, email, password FROM user WHERE user_id = :id LIMIT 1' ); $sth->execute([':id' => $user_id]); $user = $sth->fetch(\PDO::FETCH_ASSOC); if (!$user) { throw new \RuntimeException('User not found.'); } return $user; } /** * Run zxcvbn and throw if score is below MIN_SCORE. * Passes personal fields so zxcvbn penalises name/email use. */ private function enforceStrength(string $password, array $user): void { $user_inputs = array_values(array_filter([ $user['username'] ?? '', $user['name'] ?? '', $user['surname'] ?? '', $user['email'] ?? '', ])); $result = $this->checkStrength($password, $user_inputs); if ($result['score'] < self::MIN_SCORE) { $msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.'); throw new \InvalidArgumentException('Password is too weak. ' . $msg); } } /** * Hash and write the new password to the DB. * The OTP session will invalidate automatically on the next * request because db_auth.php re-derives the OTP from the * stored password hash — changing it forces re-login. */ private function persist(int $user_id, string $password): void { $hashed = password_hash($password, PASSWORD_BCRYPT); $sth = $this->pdo->prepare( 'UPDATE user SET password = :password WHERE user_id = :user_id' ); $sth->execute([ ':password' => $hashed, ':user_id' => $user_id, ]); if ($sth->rowCount() === 0) { throw new \RuntimeException('Password update failed — no rows affected.'); } } }