app settings

This commit is contained in:
Thanakorn S
2026-04-27 10:50:31 +07:00
parent 76b9b2a2cb
commit d8c55d278a
3128 changed files with 630683 additions and 138 deletions
@@ -0,0 +1,257 @@
<?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.');
}
}
}