app settings
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PasswordResetManager
|
||||
*
|
||||
* Handles the full OTP-based password reset flow.
|
||||
* Reusable across:
|
||||
* - Profile page: "Forgot your current password?" (authenticated user)
|
||||
* - Login page: "Forgot password?" (unauthenticated user)
|
||||
*
|
||||
* Flow:
|
||||
* 1. requestOtp($user_id) — generate OTP, email it, store in session
|
||||
* 2. confirmReset($user_id, ...) — verify OTP, call PasswordManager::forceSet()
|
||||
*
|
||||
* HTTP handler methods for thin endpoint wrappers:
|
||||
* handleRequestOtp($user_id)
|
||||
* handleConfirmReset($user_id, $data)
|
||||
*
|
||||
* Session keys used (prefixed to avoid collision with login OTP):
|
||||
* reset_otp, reset_otp_time, reset_reference, reset_user_id
|
||||
*/
|
||||
class PasswordResetManager {
|
||||
|
||||
private $pdo1;
|
||||
private $pdo2;
|
||||
private $include_url;
|
||||
private $SMTP;
|
||||
private $pinkey;
|
||||
|
||||
/** OTP validity window in minutes — matches login OTP */
|
||||
const OTP_EXPIRY_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* @param PDO $pdo1 Main DB (user table)
|
||||
* @param PDO $pdo2 Company DB (smtp_setting table)
|
||||
* @param string $include_url Absolute server path to app root (for requires)
|
||||
* @param array $SMTP System default SMTP config from config.php
|
||||
* @param string $pinkey Encryption key from config.php
|
||||
*/
|
||||
public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) {
|
||||
$this->pdo1 = $pdo1;
|
||||
$this->pdo2 = $pdo2;
|
||||
$this->include_url = rtrim($include_url, '/');
|
||||
$this->SMTP = $SMTP;
|
||||
$this->pinkey = $pinkey;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: request OTP
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate OTP, send it to the user's registered email, store in session.
|
||||
*
|
||||
* @param int $user_id From session (profile) or looked-up by email/username (login)
|
||||
* @param int $company_id For SMTP fallback lookup
|
||||
* @return array ['masked_email' => string, 'reference' => string]
|
||||
*
|
||||
* @throws \RuntimeException if user not found or email missing
|
||||
* @throws \Exception if mailer fails (mailer calls exit internally)
|
||||
*/
|
||||
public function requestOtp(int $user_id, int $company_id = 0): array {
|
||||
|
||||
// ── Fetch user ────────────────────────────────────────────
|
||||
$sth = $this->pdo1->prepare(
|
||||
'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1'
|
||||
);
|
||||
$sth->execute([':id' => $user_id]);
|
||||
$user = $sth->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$user || empty($user['email'])) {
|
||||
throw new \RuntimeException('No email address found for this account.');
|
||||
}
|
||||
|
||||
// ── Generate OTP ──────────────────────────────────────────
|
||||
$otp_time = time();
|
||||
$otp = $this->generateOTP($user['password'], $otp_time);
|
||||
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
|
||||
|
||||
// ── Send email ────────────────────────────────────────────
|
||||
require_once $this->include_url . '/assets/utils/module/mailer.php';
|
||||
|
||||
$mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]);
|
||||
$mailer->send_email([
|
||||
'company_id' => $company_id,
|
||||
'smtp' => $this->SMTP,
|
||||
'subject' => 'Password Reset OTP — Reference: ' . $reference_number,
|
||||
'message' => implode("\n", [
|
||||
"Your password reset OTP is: {$otp}",
|
||||
"Reference number: {$reference_number}",
|
||||
"",
|
||||
"This OTP is valid for " . self::OTP_EXPIRY_MINUTES . " minutes.",
|
||||
"If you did not request this, please ignore this email.",
|
||||
]),
|
||||
'channel_name' => 'WMS Security',
|
||||
'to' => $user['email'],
|
||||
'key' => $this->pinkey,
|
||||
]);
|
||||
|
||||
// ── Store in session ──────────────────────────────────────
|
||||
$_SESSION['reset_otp'] = $otp;
|
||||
$_SESSION['reset_otp_time'] = $otp_time;
|
||||
$_SESSION['reset_reference'] = $reference_number;
|
||||
$_SESSION['reset_user_id'] = $user_id;
|
||||
|
||||
// ── Return masked email for UI display ────────────────────
|
||||
return [
|
||||
'masked_email' => $this->maskEmail($user['email']),
|
||||
'reference' => $reference_number,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: confirm reset
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify OTP then force-set new password via PasswordManager.
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param string $otp_input
|
||||
* @param string $new_password
|
||||
* @param string $confirm_password
|
||||
*
|
||||
* @throws \InvalidArgumentException OTP invalid/expired, passwords weak/mismatch
|
||||
* @throws \RuntimeException DB or session state error
|
||||
*/
|
||||
public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void {
|
||||
|
||||
// ── Validate session state ────────────────────────────────
|
||||
if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) {
|
||||
throw new \InvalidArgumentException('No active reset request. Please request a new OTP.');
|
||||
}
|
||||
|
||||
// ── Verify ownership ──────────────────────────────────────
|
||||
if ((int)$_SESSION['reset_user_id'] !== $user_id) {
|
||||
throw new \RuntimeException('Invalid reset request.');
|
||||
}
|
||||
|
||||
// ── Check expiry ──────────────────────────────────────────
|
||||
$elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60;
|
||||
if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) {
|
||||
$this->clearSession();
|
||||
throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
|
||||
}
|
||||
|
||||
// ── Verify OTP value ──────────────────────────────────────
|
||||
if (trim($otp_input) !== $_SESSION['reset_otp']) {
|
||||
throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
|
||||
}
|
||||
|
||||
// ── Force-set password via PasswordManager ────────────────
|
||||
require_once $this->include_url . '/assets/utils/classes/PasswordManager.php';
|
||||
|
||||
$pm = new PasswordManager($this->pdo1, $this->include_url);
|
||||
$pm->forceSet($user_id, $new_password, $confirm_password);
|
||||
|
||||
// ── Clear full session on success ────────────────────────
|
||||
// Destroys the login session so the user must re-authenticate
|
||||
// with their new password. The OTP in db_auth would invalidate
|
||||
// naturally on next request anyway (hash changed), but clearing
|
||||
// here is immediate and explicit.
|
||||
$this->clearSession();
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Public: HTTP handlers (thin endpoint wrappers call these)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle AJAX request-OTP call and echo JSON.
|
||||
* Endpoint: setting/api/engine/request_reset_otp.php
|
||||
* login/api/engine/request_reset_otp.php
|
||||
*/
|
||||
public function handleRequestOtp(int $user_id, int $company_id = 0): void {
|
||||
|
||||
try {
|
||||
|
||||
$result = $this->requestOtp($user_id, $company_id);
|
||||
|
||||
echo json_encode([
|
||||
'success' => 1,
|
||||
'masked_email' => $result['masked_email'],
|
||||
'reference' => $result['reference'],
|
||||
]);
|
||||
|
||||
} catch (\RuntimeException $e) {
|
||||
error_log('[PasswordResetManager::handleRequestOtp] ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => 0, 'message' => $e->getMessage()]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log('[PasswordResetManager::handleRequestOtp] ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => 0, 'message' => 'Failed to send OTP. Please try again.']);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX confirm-reset call and echo JSON.
|
||||
* Endpoint: setting/api/engine/reset_password_otp.php
|
||||
* login/api/engine/reset_password_otp.php
|
||||
*
|
||||
* Expected $data keys: otp, new_password, confirm_password
|
||||
*/
|
||||
public function handleConfirmReset(int $user_id, array $data): void {
|
||||
|
||||
try {
|
||||
|
||||
$this->confirmReset(
|
||||
$user_id,
|
||||
$data['otp'] ?? '',
|
||||
$data['new_password'] ?? '',
|
||||
$data['confirm_password'] ?? ''
|
||||
);
|
||||
|
||||
echo json_encode(['success' => 1, 'message' => 'Password reset successfully.']);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => 0, 'message' => $e->getMessage()]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
error_log('[PasswordResetManager::handleConfirmReset] ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => 0, 'message' => 'Failed to reset password. Please try again.']);
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateOTP(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string {
|
||||
$counter = floor($otp_time / $time_step);
|
||||
$data = pack('NN', 0, $counter);
|
||||
$hash = hash_hmac('sha1', $data, $secret_key, true);
|
||||
$offset = ord(substr($hash, -1)) & 0x0F;
|
||||
$value = unpack('N', substr($hash, $offset, 4));
|
||||
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
|
||||
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function numberToLetters(int $num): string {
|
||||
$result = '';
|
||||
while ($num > 0) {
|
||||
$mod = ($num - 1) % 26;
|
||||
$result = chr(65 + $mod) . $result;
|
||||
$num = intval(($num - $mod) / 26);
|
||||
}
|
||||
return str_pad($result, 6, 'A', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function maskEmail(string $email): string {
|
||||
$at = strpos($email, '@');
|
||||
return substr($email, 0, 2)
|
||||
. str_repeat('*', max(1, $at - 2))
|
||||
. substr($email, $at);
|
||||
}
|
||||
|
||||
private function clearSession(): void {
|
||||
unset(
|
||||
$_SESSION['reset_otp'],
|
||||
$_SESSION['reset_otp_time'],
|
||||
$_SESSION['reset_reference'],
|
||||
$_SESSION['reset_user_id']
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user