pdo1 = $pdo1; $this->pdo2 = $pdo2; $this->include_url = rtrim($include_url, '/'); $this->SMTP = $SMTP; $this->pinkey = $pinkey; } // ───────────────────────────────────────────────────────────── // Core public API // ───────────────────────────────────────────────────────────── /** * Generate a TOTP, send it to the user's registered email, and store it in session. * * The OTP is derived from the user's current password hash so it is unique per user * and automatically invalidated if the password is changed by any other means. * A reference number (6 uppercase letters) is included in the email so the user * can verify the request is legitimate. * * Call this from the "request OTP" endpoint. The user_id must be resolved by the * caller (from $_SESSION for authenticated flows, or from a username/email lookup * for unauthenticated login-page flows). * * @param int $user_id The wms.user.user_id whose password is being reset. * @param int $company_id Used to look up company SMTP settings (0 = use system default). * @return array Keys: masked_email (string), reference (string 6-letter code). * @throws \RuntimeException If user not found, email missing, or mailer fails. */ public function requestOtp(int $user_id, int $company_id = 0): array { // Fetch user — need email (to send to) and password hash (for OTP derivation) $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 6-digit TOTP and a random 6-letter reference number $otp_time = time(); $otp = $this->generateOTP($user['password'], $otp_time); $reference_number = $this->randomReference(); // Send via the mailer module (uses company SMTP or falls back to system default) 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, ]); // Persist in session so confirmReset() can verify against it $_SESSION['reset_otp'] = $otp; $_SESSION['reset_otp_time'] = $otp_time; $_SESSION['reset_reference'] = $reference_number; $_SESSION['reset_user_id'] = $user_id; $_SESSION['reset_attempts'] = 0; return [ 'masked_email' => $this->maskEmail($user['email']), 'reference' => $reference_number, ]; } /** * Start a reset that can never succeed, for a login-page request whose * username/email matches no account. The session then looks exactly like a * real request (random unguessable OTP, reset_user_id 0), so the confirm step * answers "Incorrect OTP" instead of revealing that the account is missing. * * @return string Random 6-letter reference, same shape as a real one. */ public function startDecoy(): string { $reference = $this->randomReference(); $_SESSION['reset_otp'] = bin2hex(random_bytes(16)); $_SESSION['reset_otp_time'] = time(); $_SESSION['reset_reference'] = $reference; $_SESSION['reset_user_id'] = 0; $_SESSION['reset_attempts'] = 0; return $reference; } /** * Verify the OTP and force-set a new password via PasswordManager. * * Validates: * - Active session with a stored OTP and timestamp. * - Session reset_user_id matches the $user_id being reset (prevents cross-user reuse). * - OTP is within the OTP_EXPIRY_MINUTES window. * - Submitted OTP matches the stored value exactly. * * On success: * - Delegates to PasswordManager::forceSet() for strength enforcement and hashing. * - Clears reset session keys and destroys the full session (forces re-login). * * @param int $user_id The wms.user.user_id being reset. * @param string $otp_input The OTP submitted by the user. * @param string $new_password The desired new password. * @param string $confirm_password Must match $new_password exactly. * @throws \InvalidArgumentException OTP missing, expired, incorrect, or passwords invalid. * @throws \RuntimeException Session integrity error or DB failure. */ public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void { // Validate reset session exists if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) { throw new \InvalidArgumentException('No active reset request. Please request a new OTP.'); } // Verify this OTP belongs to the user making the request (prevents session swap attacks) if ((int)$_SESSION['reset_user_id'] !== $user_id) { throw new \RuntimeException('Invalid reset request.'); } // Check OTP has not expired $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 — at most OTP_MAX_ATTEMPTS wrong entries per issued OTP, // so the 6-digit code cannot be brute-forced inside its 5-minute window. if (!hash_equals((string)$_SESSION['reset_otp'], trim($otp_input)) || $user_id <= 0) { $_SESSION['reset_attempts'] = (int)($_SESSION['reset_attempts'] ?? 0) + 1; if ($_SESSION['reset_attempts'] >= self::OTP_MAX_ATTEMPTS) { $this->clearSession(); throw new \InvalidArgumentException('Too many incorrect OTP attempts. Please request a new OTP.'); } throw new \InvalidArgumentException('Incorrect OTP. Please try again.'); } // Delegate to PasswordManager for strength enforcement and persistence 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 reset keys and destroy session — user must log in with new password. // The OTP in db_auth would invalidate naturally (hash changed), but // explicit destroy is immediate and leaves no dangling session state. $this->clearSession(); session_destroy(); } // ───────────────────────────────────────────────────────────── // HTTP handlers (thin AJAX endpoint wrappers) // ───────────────────────────────────────────────────────────── /** * Handle an AJAX request-OTP call and echo a JSON response. * * Called by: * setting/api/engine/request_reset_otp.php (profile page) * login/api/engine/request_reset_otp.php (login page) * * On success: { success: 1, masked_email: "...", reference: "ABCDEF" } * On RuntimeException (user/mail error): HTTP 500, { success: 0, message: "..." } * On other Exception: HTTP 500, generic message (error logged server-side) * * Outputs JSON and calls exit. XSS-safe — all output via json_encode. * * @param int $user_id The wms.user.user_id to send the OTP to. * @param int $company_id Company SMTP scope (0 = use system default). */ 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 the login-page request-OTP call. The answer is the same whether or * not the username/email matches an account (no account enumeration): no * masked email, a generic message and a reference number. Mail failures are * logged, not reported, for the same reason. * * On success (always): { success: 1, message: PUBLIC_REQUEST_MESSAGE, reference: "ABCDEF" } * * @param int|null $user_id Resolved account, or null when nothing matched. * @param int $company_id Company SMTP scope (0 = use system default). */ public function handleRequestOtpPublic(?int $user_id, int $company_id = 0): void { $reference = null; if ($user_id) { try { $reference = $this->requestOtp($user_id, $company_id)['reference']; } catch (\Exception $e) { error_log('[PasswordResetManager::handleRequestOtpPublic] ' . $e->getMessage()); } } if ($reference === null) { $reference = $this->startDecoy(); } echo json_encode([ 'success' => 1, 'message' => self::PUBLIC_REQUEST_MESSAGE, 'reference' => $reference, ]); exit; } /** * Handle an AJAX confirm-reset call and echo a JSON response. * * Called by: * setting/api/engine/reset_password_otp.php (profile page) * login/api/engine/reset_password_otp.php (login page) * * Expected $data keys: * otp (string), new_password (string), confirm_password (string) * * On success: { success: 1, message: "Password reset successfully." } * On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." } * On other Exception: HTTP 500, generic message (error logged server-side) * * Outputs JSON and calls exit. XSS-safe — all output via json_encode. * * @param int $user_id The wms.user.user_id being reset. * @param array $data Request data array with 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 // ───────────────────────────────────────────────────────────── /** * Generate a TOTP-style numeric OTP from a secret key and timestamp. * * Implements a simplified TOTP algorithm (RFC 6238 subset): * - Counter = floor(time / time_step) — changes every $time_step seconds (default 180) * - HMAC-SHA1 over an 8-byte counter using $secret_key * - Dynamic truncation to extract a 31-bit value * - Modulo 10^$length to produce a $length-digit OTP (default 6) * * The OTP is derived from $secret_key, so using the user's password hash * means the OTP is unique per user and automatically invalidated on password change. * * @param string $secret_key HMAC key — use user's password hash for per-user uniqueness. * @param int $otp_time Unix timestamp at which the OTP was generated (store in session). * @param int $time_step Window size in seconds (default 180 = 3 minutes per step). * @param int $length Number of digits in the OTP (default 6). * @return string Zero-padded OTP string of $length digits. */ 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); } /** * Random 6-letter uppercase reference code (e.g. "BCDFHJ") for the reset email * and the confirmation screen. Carries no information about the OTP. * * @return string 6-character uppercase string. */ private function randomReference(): string { $result = ''; for ($i = 0; $i < 6; $i++) { $result .= chr(65 + random_int(0, 25)); } return $result; } /** * Mask an email address for display in the UI. * * Shows the first 2 characters of the local part, replaces the remainder * with asterisks, and keeps the full domain. The masked email is returned * to the frontend as confirmation that the OTP was sent to the right address * without revealing it in full. * * Example: "john.doe@example.com" → "jo******@example.com" * * @param string $email The full email address to mask. * @return string Masked email address. */ private function maskEmail(string $email): string { $at = strpos($email, '@'); return substr($email, 0, 2) . str_repeat('*', max(1, $at - 2)) . substr($email, $at); } /** * Remove reset-related keys from the current session. * * Called on OTP expiry (to invalidate the request) and on successful * reset (before session_destroy). Does not destroy the full session — * only the reset-specific keys are unset. */ private function clearSession(): void { unset( $_SESSION['reset_otp'], $_SESSION['reset_otp_time'], $_SESSION['reset_reference'], $_SESSION['reset_user_id'], $_SESSION['reset_attempts'] ); } }