Files
wms-app/app/assets/utils/classes/PasswordManager.php
T
2026-04-27 10:50:31 +07:00

257 lines
11 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 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
*
* Usage:
* $pm = new PasswordManager($pdo1, $include_url);
*
* // Check strength only (for live UI feedback)
* $result = $pm->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.');
}
}
}