checkStrength('mypassword', ['john', 'john@example.com']); * * // Authenticated user changing their own password: * $pm->change($user_id, $current_password, $new_password, $confirm_password); * * // Forced set (admin reset / login forced reset — skips current password check): * $pm->forceSet($user_id, $new_password, $confirm_password); * * Security: * - Passwords are hashed with PASSWORD_BCRYPT (cost factor PHP default = 10). * - zxcvbn score ≥ 3 ("safely unguessable") is required before any hash is written. * - User's own name, username, and email are passed to zxcvbn as penalty inputs. * - All DB queries use PDO prepared statements with bound parameters. * - AJAX handler methods output JSON via json_encode (XSS-safe for string values). */ class PasswordManager { private $pdo; private $zxcvbn_path; /** Minimum zxcvbn score required (0–4). 3 = "safely unguessable". */ const MIN_SCORE = 3; /** * @param PDO $pdo PDO connection to the wms.user table database. * @param string $include_url Absolute server path to the app root directory, * used to locate the zxcvbn-php autoloader. * Example: '/var/www/html/wms' */ public function __construct($pdo, string $include_url) { $this->pdo = $pdo; $this->zxcvbn_path = rtrim($include_url, '/') . '/../lib/zxcvbn-php-master/vendor/autoload.php'; } // ───────────────────────────────────────────────────────────── // Core public API // ───────────────────────────────────────────────────────────── /** * Run a zxcvbn password strength analysis and return structured feedback. * * Used by the live AJAX strength indicator while the user types. * Pass user-specific strings as $user_inputs so zxcvbn penalises * passwords that contain the user's own name, email, or username. * * Score meanings: * 0 — too guessable (online attack in < 100 guesses) * 1 — very guessable (online attack in < 1000 guesses) * 2 — somewhat guessable (offline attack in < 1M guesses) * 3 — safely unguessable (offline attack in < 100M guesses) ← MIN_SCORE * 4 — very unguessable (offline attack in > 100M guesses) * * @param string $password The password string to analyse. * @param array $user_inputs Strings to penalise if found in the password * (e.g. name, email, username). * @return array Keys: score (int 0–4), warning (string), suggestions (array of strings). */ 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'] ?? [], ]; } /** * Change the password for an authenticated user. * * Verifies the current password before applying the new one. * Use this on the profile settings page where the user is already logged in * and must prove knowledge of their existing password. * * Steps: * 1. Validates all fields are present and new passwords match. * 2. Fetches the user record and verifies $current_password via password_verify(). * 3. Runs zxcvbn strength enforcement (throws if score < MIN_SCORE). * 4. Hashes and persists the new password. * * @param int $user_id The wms.user.user_id of the user changing their password. * @param string $current_password The user's current password for verification. * @param string $new_password The desired new password. * @param string $confirm_password Must match $new_password exactly. * @throws \InvalidArgumentException On validation failure (message is safe to show the 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 { 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.'); } $user = $this->fetchUser($user_id); if (!password_verify($current_password, $user['password'])) { throw new \InvalidArgumentException('Current password is incorrect.'); } $this->enforceStrength($new_password, $user); $this->persist($user_id, $new_password); } /** * Force-set a new password without verifying the current one. * * Use for flows where the current password is unavailable or irrelevant: * - Admin-initiated password reset * - First-login forced password change * - Forgot-password flow after OTP verification (via PasswordResetManager) * * Steps: * 1. Validates new password fields are present and match. * 2. Fetches the user record (needed for zxcvbn personalisation). * 3. Runs zxcvbn strength enforcement. * 4. Hashes and persists the new password. * * @param int $user_id The wms.user.user_id to reset. * @param string $new_password The desired new password. * @param string $confirm_password Must match $new_password exactly. * @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); } // ───────────────────────────────────────────────────────────── // HTTP handler (thin AJAX endpoint wrapper) // ───────────────────────────────────────────────────────────── /** * Handle an AJAX change-password request and echo a JSON response. * * Called by: setting/api/engine/change_password.php * * Expected $data keys: * current_password, new_password, confirm_password (all strings) * * On success: destroys the session (forces re-login with new password), * returns { success: 1, message: "..." } * On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." } * On other Exception (system error): HTTP 500, generic message (error logged server-side) * * Outputs JSON and calls exit. Safe against XSS — all output via json_encode. * * @param int $user_id The wms.user.user_id of the currently authenticated user. * @param array $data Request data array containing the password fields. */ public function handleChange(int $user_id, array $data): void { try { $this->change( $user_id, $data['current_password'] ?? '', $data['new_password'] ?? '', $data['confirm_password'] ?? '' ); // Destroy session: user must re-authenticate with new password. // The db_auth OTP would invalidate naturally on next request // (hash changed), but explicit destroy is immediate. 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 // ───────────────────────────────────────────────────────────── /** * Load the zxcvbn-php autoloader. * * Called lazily (only when a strength check is actually needed). * Throws a RuntimeException if the autoloader file is missing, * so misconfiguration is caught clearly rather than as a silent failure. * * @throws \RuntimeException If the autoloader file is not found. */ 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; } /** * Fetch a user record by user_id from the wms.user table. * * Returns the full user row including the hashed password (needed for * password_verify) and personal fields (needed for zxcvbn penalisation). * * @param int $user_id The wms.user.user_id to fetch. * @return array Associative user row: user_id, username, name, surname, email, password. * @throws \RuntimeException If no user is found for the given ID. */ 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 strength check and throw if the score is below MIN_SCORE. * * Passes user personal fields (name, surname, username, email) to zxcvbn * so that passwords containing the user's own details are penalised. * The first actionable feedback string from zxcvbn is included in the * exception message shown to the user. * * @param string $password The new password to evaluate. * @param array $user User row from fetchUser() — provides personalisation data. * @throws \InvalidArgumentException If score < MIN_SCORE, with zxcvbn feedback. */ 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 the password with bcrypt and write it to the wms.user table. * * Uses PASSWORD_BCRYPT with PHP's default cost factor. * After this write, the OTP-based auth in db_auth.php will invalidate * on the next request because it re-derives the OTP from the stored hash — * changing the hash implicitly forces a fresh login. * * @param int $user_id The wms.user.user_id to update. * @param string $password The plain-text new password (will be hashed here). * @throws \RuntimeException If the UPDATE affected 0 rows (user not found or no change). */ 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.'); } } }