Jake Vanderwerf
2026-09-18 a6413da67a4579866f0fd0323ddb23a7b6b9654a
inc/rest/routes/LoginRoutes.php
@@ -1,9 +1,11 @@
<?php
namespace JVBase\rest\routes;
use Exception;
use JVBase\Instance;
use JVBase\managers\Cache;
use JVBase\managers\Emails;
use JVBase\managers\MagicLinks;
use JVBase\managers\Referrals;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\Route;
@@ -159,15 +161,8 @@
         error_log('[handleLogin]Turnstile succeeded');
      }
      // Attempt authentication
      $user = wp_authenticate($email, $password);
      $user = $this->login($email, $password, $remember);
      if (is_wp_error($user)) {
         $this->auditLog('login_failed', [
            'email' => $email,
            'error' => $user->get_error_code(),
         ]);
         return $this->error(
            $this->getLoginErrorMessage($user),
            $user->get_error_code(),
@@ -175,21 +170,6 @@
         );
      }
      // Set auth cookie
      wp_clear_auth_cookie();
      wp_set_current_user($user->ID);
      wp_set_auth_cookie($user->ID, $remember);
      // Store session fingerprint
      $this->storeSessionFingerprint($user->ID, $request);
      do_action('wp_login', $user->user_login, $user);
      $this->auditLog('login_success', [
         'user_id' => $user->ID,
         'email' => $email,
      ]);
      // Get redirect URL
      $redirect = $this->getRedirectUrl($user, $redirect_to);
@@ -201,6 +181,40 @@
      ]);
   }
   public function login(string $email, string $password, bool $remember, ?WP_REST_Request $request = null):WP_User|WP_Error
   {
      // Attempt authentication
      $user = wp_authenticate($email, $password);
      if (is_wp_error($user)) {
         $this->auditLog('login_failed', [
            'email' => $email,
            'error' => $user->get_error_code(),
         ]);
         return $user;
      }
      // Set auth cookie
      wp_clear_auth_cookie();
      wp_set_current_user($user->ID);
      wp_set_auth_cookie($user->ID, $remember);
      // Store session fingerprint
      if ($request) {
         $this->storeSessionFingerprint($user->ID, $request);
      }
      do_action('wp_login', $user->user_login, $user);
      $this->auditLog('login_success', [
         'user_id' => $user->ID,
         'email' => $email,
      ]);
      return $user;
   }
   /**
    * Handle user registration
    */
@@ -249,7 +263,7 @@
      );
      if (is_wp_error($user_id)) {
         $this->logError('Registration failed', [
         $this->logError('Registration failed', 'handleRegister', [
            'email' => $email,
            'error' => $user_id->get_error_message(),
         ]);
@@ -337,7 +351,7 @@
         $result = MagicLinks::getInstance()->sendMagicLink($email, 'reset');
         if (is_wp_error($result)) {
            $this->logError('Magic link send failed', [
            $this->logError('Magic link send failed', 'handleLostPassword',[
               'email' => $email,
               'error' => $result->get_error_message(),
            ]);
@@ -346,7 +360,7 @@
         // Standard WordPress password reset
         $key = get_password_reset_key($user);
         if (is_wp_error($key)) {
            $this->logError('Reset key generation failed', [
            $this->logError('Reset key generation failed', 'handleLostPassword', [
               'email' => $email,
               'error' => $key->get_error_message(),
            ]);
@@ -402,8 +416,7 @@
      if (is_wp_error($user)) {
         return $this->error(
            'Invalid or expired reset link.',
            'invalid_key',
            400
            'invalid_key'
         );
      }
@@ -429,8 +442,7 @@
      if (!Site::has('magic_link')) {
         return $this->error(
            'Magic link authentication is not enabled.',
            'feature_disabled',
            400
            'feature_disabled'
         );
      }
@@ -456,7 +468,7 @@
      $result = MagicLinks::getInstance()->sendMagicLink($email, $type, $context);
      if (is_wp_error($result)) {
         $this->logError('Magic link send failed', [
         $this->logError('Magic link send failed', 'handleMagicLink', [
            'email' => $email,
            'type' => $type,
            'error' => $result->get_error_message(),
@@ -464,8 +476,7 @@
         return $this->error(
            $result->get_error_message(),
            $result->get_error_code(),
            400
            $result->get_error_code()
         );
      }
@@ -506,87 +517,6 @@
      ]);
   }
   /************************************************************
    * SESSION FINGERPRINTING
    *
    * Detects session hijacking by validating that session hasn't
    * moved to a different device/network. Uses IP class (not full IP)
    * to allow mobile network changes without breaking sessions.
    ************************************************************/
   /**
    * Store session fingerprint for hijacking detection
    */
   protected function storeSessionFingerprint(int $user_id, WP_REST_Request $request): void
   {
      if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
         return;
      }
      $fingerprint = $this->generateSessionFingerprint($request);
      update_user_meta($user_id, BASE . 'session_fingerprint', $fingerprint);
      update_user_meta($user_id, BASE . 'session_timestamp', time());
   }
   /**
    * Generate session fingerprint for hijacking detection
    */
   protected function generateSessionFingerprint(WP_REST_Request $request): string
   {
      return hash('sha256', implode('|', [
         $request->get_header('User-Agent') ?? '',
         // Use IP class instead of full IP to allow for mobile network changes
         $this->getIPClass(
            $request->get_header('X-Forwarded-For')
               ?: $request->get_header('X-Real-IP')
               ?: $_SERVER['REMOTE_ADDR'] ?? ''
         )
      ]));
   }
   /**
    * Get IP class (first 3 octets) for session validation
    * Allows for minor IP changes (common with mobile networks)
    */
   protected function getIPClass(string $ip): string
   {
      $parts = explode('.', $ip);
      return implode('.', array_slice($parts, 0, 3));
   }
   /**
    * Validate session fingerprint against stored value
    */
   protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool
   {
      // Only enforce if enabled in config
      if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) {
         return true;
      }
      $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true);
      $current = $this->generateSessionFingerprint($request);
      if (empty($stored)) {
         // First request - store fingerprint
         update_user_meta($user_id, BASE . 'session_fingerprint', $current);
         update_user_meta($user_id, BASE . 'session_timestamp', time());
         return true;
      }
      // Compare using timing-safe comparison
      return hash_equals($stored, $current);
   }
   /**
    * Clear session fingerprint (call on logout)
    */
   protected function clearSessionFingerprint(int $user_id): void
   {
      delete_user_meta($user_id, BASE . 'session_fingerprint');
      delete_user_meta($user_id, BASE . 'session_timestamp');
   }
   /**************************************************************
    * HELPERS
    **************************************************************/
@@ -666,12 +596,12 @@
      try {
         Referrals::getInstance()->processReferralCode($user_id, $referral_code);
      } catch (\Exception $e) {
         $this->logError('Referral processing failed', [
      } catch (Exception $e) {
         $this->logError('Referral processing failed', 'processReferralCode', [
            'user_id' => $user_id,
            'code' => $referral_code,
            'error' => $e->getMessage(),
         ], 'warning');
         ]);
      }
   }
@@ -708,18 +638,13 @@
    */
   protected function sanitizeFieldValue(mixed $value, string $type):string|int
   {
      switch ($type) {
         case 'email':
            return sanitize_email($value);
         case 'url':
            return esc_url_raw($value);
         case 'textarea':
            return sanitize_textarea_field($value);
         case 'number':
            return absint($value);
         default:
            return sanitize_text_field($value);
      }
      return match ($type) {
         'email' => sanitize_email($value),
         'url' => esc_url_raw($value),
         'textarea' => sanitize_textarea_field($value),
         'number' => absint($value),
         default => sanitize_text_field($value),
      };
   }
   public static function auth():array