modify classed and comments
This commit is contained in:
@@ -3,54 +3,84 @@
|
||||
/**
|
||||
* PasswordManager
|
||||
*
|
||||
* Handles all password operations using zxcvbn-php for strength enforcement.
|
||||
* Designed to be reused across:
|
||||
* - Profile: change password (requires current password verification)
|
||||
* - Login: forced password reset (no current password needed)
|
||||
* - Future: forgot password / admin reset flows
|
||||
* 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($pdo1, $include_url);
|
||||
* $pm = new PasswordManager($pdo, $include_url);
|
||||
*
|
||||
* // Check strength only (for live UI feedback)
|
||||
* // Live strength check (AJAX feedback while typing):
|
||||
* $result = $pm->checkStrength('mypassword', ['john', 'john@example.com']);
|
||||
*
|
||||
* // Change password (profile page — verifies current password)
|
||||
* // Authenticated user changing their own password:
|
||||
* $pm->change($user_id, $current_password, $new_password, $confirm_password);
|
||||
*
|
||||
* // Force set password (admin reset / login forced reset — no current 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" */
|
||||
/** 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';
|
||||
$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)
|
||||
// Core public API
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run zxcvbn strength analysis.
|
||||
* Run a zxcvbn password strength analysis and return structured feedback.
|
||||
*
|
||||
* @param string $password
|
||||
* @param array $user_inputs Personal data to penalise (name, email, username…)
|
||||
* @return array ['score' => 0-4, 'warning' => string, 'suggestions' => array]
|
||||
* 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);
|
||||
$result = $zxcvbn->passwordStrength($password, $user_inputs);
|
||||
|
||||
return [
|
||||
'score' => (int) $result['score'],
|
||||
@@ -59,20 +89,28 @@ class PasswordManager {
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: change password (profile — verifies current password)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change password for an authenticated user.
|
||||
* Verifies current password before applying the new one.
|
||||
* Change the password for an authenticated user.
|
||||
*
|
||||
* @throws InvalidArgumentException on validation failure (safe to show user)
|
||||
* @throws RuntimeException on DB failure (log internally, show generic message)
|
||||
* 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 {
|
||||
|
||||
// ── Basic field validation ────────────────────────────────
|
||||
if (empty($current_password) || empty($new_password) || empty($confirm_password)) {
|
||||
throw new \InvalidArgumentException('All password fields are required.');
|
||||
}
|
||||
@@ -81,31 +119,35 @@ class PasswordManager {
|
||||
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.
|
||||
* Force-set a new password without verifying the current one.
|
||||
*
|
||||
* @throws InvalidArgumentException on validation failure
|
||||
* @throws RuntimeException on DB failure
|
||||
* 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 {
|
||||
|
||||
@@ -124,14 +166,23 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: HTTP handlers (call from thin API endpoint files)
|
||||
// HTTP handlers (thin AJAX endpoint wrappers)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle AJAX strength-check request and echo JSON response.
|
||||
* Endpoint: setting/api/engine/check_password.php
|
||||
* Handle an AJAX strength-check request and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: password
|
||||
* 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 {
|
||||
|
||||
@@ -154,10 +205,22 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX change-password request and echo JSON response.
|
||||
* Endpoint: setting/api/engine/change_password.php
|
||||
* Handle an AJAX change-password request and echo a JSON response.
|
||||
*
|
||||
* Expected $data keys: current_password, new_password, confirm_password
|
||||
* 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 {
|
||||
|
||||
@@ -170,6 +233,9 @@ class PasswordManager {
|
||||
$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.']);
|
||||
|
||||
@@ -190,6 +256,15 @@ class PasswordManager {
|
||||
// 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);
|
||||
@@ -197,6 +272,16 @@ class PasswordManager {
|
||||
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
|
||||
@@ -213,8 +298,16 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run zxcvbn and throw if score is below MIN_SCORE.
|
||||
* Passes personal fields so zxcvbn penalises name/email use.
|
||||
* 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 {
|
||||
|
||||
@@ -234,10 +327,16 @@ class PasswordManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user