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

274 lines
12 KiB
PHP

<?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']
);
}
}