Files
wms-app/app/assets/utils/classes/PasswordManager.php
T

356 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* PasswordManager
*
* Handles all password-related operations using zxcvbn-php for strength enforcement.
* Designed to be reused across multiple flows:
* - Profile page: authenticated user changes their own password (requires current password).
* - Login forced reset: system-initiated change, no current password needed.
* - Future: admin reset, forgot password (delegate to PasswordResetManager for OTP).
*
* Method order:
* Core public API → checkStrength, change, forceSet
* HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints)
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
*
* Usage:
* $pm = new PasswordManager($pdo, $include_url);
*
* // Live strength check (AJAX feedback while typing):
* $result = $pm->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, '/') . '/assets/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 handlers (thin AJAX endpoint wrappers)
// ─────────────────────────────────────────────────────────────
/**
* Handle an AJAX strength-check request and echo a JSON response.
*
* Called by: setting/api/engine/check_password.php
*
* Expected $data keys:
* password (string) — the password string to analyse
*
* Response JSON keys:
* success (int 1), score (int -1 if empty, else 0–4), feedback (string)
*
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
*
* @param array $data Request data array (typically from $_POST or decoded JSON body).
*/
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 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.');
}
}
}