Jake Vanderwerf
2026-09-18 a6413da67a4579866f0fd0323ddb23a7b6b9654a
inc/rest/routes/LoginRoutes.php
@@ -1,9 +1,15 @@
<?php
namespace JVBase\rest\routes;
use Exception;
use JVBase\Instance;
use JVBase\managers\Emails;
use JVBase\managers\MagicLinks;
use JVBase\managers\Referrals;
use JVBase\registrar\Registrar;
use JVBase\rest\Rest;
use JVBase\rest\Route;
use JVBase\utility\Features;
use JVBase\base\Site;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -20,6 +26,7 @@
 */
class LoginRoutes extends Rest
{
   use Instance;
   protected ?string $requestId = null;
   protected bool $hasMagicLink = false;
@@ -29,8 +36,8 @@
      $this->cacheTtl = WEEK_IN_SECONDS;
      parent::__construct();
      $this->hasMagicLink = Site::has('magic_link');
      $this->hasMagicLink = Features::forSite()->has('magicLink');
   }
   public function registerRoutes(): void
@@ -93,7 +100,8 @@
         ->register();
      // Magic link endpoint
      if ($this->hasMagicLink) {
      if (Site::has('magic_link')) {
         Route::for('auth/magic')
            ->post([$this, 'handleMagicLink'])
            ->args([
@@ -134,29 +142,27 @@
    */
   public function handleLogin(WP_REST_Request $request): WP_REST_Response
   {
      error_log('Handling login...');
      $email = sanitize_email($request->get_param('user_email'));
      $password = $request->get_param('user_password');
      $remember = (bool) $request->get_param('remember_me');
      $redirect_to = $request->get_param('redirect_to');
      // Verify Turnstile
      if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) {
         error_log('[handleLogin]Turnstile failed');
         return $this->error(
            'Security verification failed. Please try again.',
            'turnstile_failed',
            403
         );
      } else {
         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(),
@@ -164,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);
@@ -190,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
    */
@@ -238,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(),
         ]);
@@ -261,7 +286,7 @@
      ]);
      // Process referral code if provided
      if (!empty($referral_code) && Features::forSite()->has('referrals')) {
      if (!empty($referral_code) && Site::has('referrals')) {
         $this->processReferralCode($user_id, $referral_code);
      }
@@ -272,7 +297,7 @@
      // Send magic link for email verification
      if ($this->hasMagicLink) {
         JVB()->magicLink()->sendMagicLink($email, 'signup', [
         MagicLinks::getInstance()->sendMagicLink($email, 'signup', [
            'name' => $name,
            'role' => $role,
         ]);
@@ -323,10 +348,10 @@
      // Use magic link if available, otherwise standard WP reset
      if ($this->hasMagicLink) {
         $result = JVB()->magicLink()->sendMagicLink($email, 'reset');
         $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(),
            ]);
@@ -335,12 +360,12 @@
         // 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(),
            ]);
         } else {
            $this->sendPasswordResetEmail($user, $key);
            $success = Emails::getInstance()->sendPasswordResetEmail($user, $key);
         }
      }
@@ -391,8 +416,7 @@
      if (is_wp_error($user)) {
         return $this->error(
            'Invalid or expired reset link.',
            'invalid_key',
            400
            'invalid_key'
         );
      }
@@ -414,11 +438,11 @@
    */
   public function handleMagicLink(WP_REST_Request $request): WP_REST_Response
   {
      if (!$this->hasMagicLink) {
      if (!Site::has('magic_link')) {
         return $this->error(
            'Magic link authentication is not enabled.',
            'feature_disabled',
            400
            'feature_disabled'
         );
      }
@@ -441,10 +465,10 @@
      }
      // Send magic link
      $result = JVB()->magicLink()->sendMagicLink($email, $type, $context);
      $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(),
@@ -452,8 +476,7 @@
         return $this->error(
            $result->get_error_message(),
            $result->get_error_code(),
            400
            $result->get_error_code()
         );
      }
@@ -494,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
    **************************************************************/
@@ -630,17 +572,17 @@
      }
      // Check if role is valid and can register
      $role_config = JVB_USER[$user_select] ?? null;
      $registrar = Registrar::getInstance($user_select);
      if (!$role_config) {
      if (!$registrar) {
         return new WP_Error('invalid_role', 'Invalid role selected.');
      }
      if (!($role_config['can_register'] ?? false)) {
      if (!($registrar->hasFeature('can_register') ?? false)) {
         return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.');
      }
      return BASE . $user_select;
      return $registrar->getBased();
   }
   /**
@@ -648,18 +590,18 @@
    */
   protected function processReferralCode(int $user_id, string $referral_code): void
   {
      if (!Features::forSite()->has('referrals')) {
      if (!Site::has('referrals')) {
         return;
      }
      try {
         JVB()->referrals()->processReferralCode($user_id, $referral_code);
      } catch (\Exception $e) {
         $this->logError('Referral processing failed', [
         Referrals::getInstance()->processReferralCode($user_id, $referral_code);
      } catch (Exception $e) {
         $this->logError('Referral processing failed', 'processReferralCode', [
            'user_id' => $user_id,
            'code' => $referral_code,
            'error' => $e->getMessage(),
         ], 'warning');
         ]);
      }
   }
@@ -696,75 +638,52 @@
    */
   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),
      };
   }
   /**
    * Send password reset email (fallback if magic links not available)
    */
   protected function sendPasswordResetEmail(WP_User $user, string $key): bool
   public static function auth():array
   {
      $reset_url = network_site_url(
         "wp-login.php?action=rp&key=$key&login=" . rawurlencode($user->user_login),
         'login'
      );
      $subject = 'Password Reset Request';
      $message = sprintf(
         "Hello %s,\n\nYou requested a password reset. Click the link below to reset your password:\n\n%s\n\nIf you didn't request this, please ignore this email.",
         $user->display_name,
         $reset_url
      );
      return wp_mail($user->user_email, $subject, $message);
      return (new self)->buildAuth();
   }
   protected function buildAuth(?int $user = null): array
   {
      if (is_user_logged_in()) {
         $user = ($user) ?: get_current_user_id();
      $userId = $user ?? (is_user_logged_in() ? get_current_user_id() : 0);
      if ($userId) {
         return [
            'authenticated' => true,
            'user' => $user,
            'nonces' => $this->getUserNonces($user)
            'user'          => $userId,
            'nonces'        => $this->getUserNonces($userId),
         ];
      }
      return [
         'authenticated' => false,
         'user' => false,
         'nonces' => [
            'wp_rest' => wp_create_nonce('wp_rest')
         ]
         'user'          => false,
         'nonces'        => ['wp_rest' => wp_create_nonce('wp_rest')],
      ];
   }
   protected function getUserNonces(int $userID):array {
      $nonces = [
         'wp_rest'   => wp_create_nonce('wp_rest'),
      ];
      if (Features::forSite()->has('dashboard')) {
      if (Site::has('dashboard')) {
         $nonces['dash'] = wp_create_nonce('dash-'.$userID);
      }
      if (Features::forSite()->has('favourites')) {
      if (Site::has('favourites')) {
         $nonces['favourites'] = wp_create_nonce('favourites-'.$userID);
      }
      if (Features::anyContentHas('karma') ||
         Features::anyTaxonomyHas('karma') ||
         Features::anyUserHas('karma')) {
      if (!empty(Registrar::withFeature('karma'))) {
         $nonces['votes'] = wp_create_nonce('votes-'.$userID);
      }
      if (Features::forSite()->has('notifications')) {
      if (Site::has('notifications')) {
         $nonces['notifications'] = wp_create_nonce('notifications-'.$userID);
      }
      return $nonces;