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