Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
inc/managers/MagicLinkManager.php
@@ -11,8 +11,8 @@
/**
 * Magic Link Authentication Manager
 *
 * Handles passwordless authentication via email magic links.
 * Can be used for referral signups, password resets, or general login.
 * NOTE: Login form integration is now handled by LoginManager.php
 * This class focuses solely on magic link generation and verification
 */
class MagicLinkManager
{
@@ -24,7 +24,7 @@
   protected int $rate_limit_window = 3600; // 1 hour
   protected int $max_attempts_per_hour = 5;
   // Link types - allows different flows for different purposes
   // Link types
   const TYPE_LOGIN = 'login';
   const TYPE_SIGNUP = 'signup';
   const TYPE_REFERRAL = 'referral';
@@ -32,16 +32,15 @@
   public function __construct()
   {
      $this->cache = new CacheManager('magic_links', $this->token_expiry);
      $this->cache = CacheManager::for('magic_links', $this->token_expiry);
      $this->email = new EmailManager();
      // Hook into WordPress auth flow
      add_action('template_redirect', [$this, 'handleMagicLinkClick']);
      add_action('wp_login_failed', [$this, 'handleFailedLogin']);
      // Add magic link option to login page
      add_action('login_form', [$this, 'addMagicLinkOption']);
      add_filter('authenticate', [$this, 'blockStandardAuth'], 30, 3);
      // NOTE: LoginManager now handles the login form UI
      // If magic_link integration is enabled, LoginManager will call addMagicLinkOption()
   }
   /**
@@ -86,34 +85,100 @@
   }
   /**
    * Generate a secure token
    */
   protected function generateToken(string $email, string $type, array $data = []): string
   {
      $token = wp_generate_password(32, false);
      $token_data = array_merge([
         'email' => $email,
         'type' => $type,
         'created' => time()
      ], $data);
      $this->cache->set($token, $token_data);
      return $token;
   }
   /**
    * Verify a token
    */
   protected function verifyToken(string $token, string $email): array|WP_Error
   {
      $token_data = $this->cache->get($token);
      if (!$token_data) {
         return new WP_Error('invalid_token', 'Invalid or expired token');
      }
      if ($token_data['email'] !== $email) {
         return new WP_Error('email_mismatch', 'Token does not match email');
      }
      // Delete token after verification (single use)
      $this->cache->delete($token);
      return $token_data;
   }
   /**
    * Check rate limiting for sending magic links
    */
   protected function checkRateLimit(string $email): bool|WP_Error
   {
      $cache_key = 'rate_limit_' . md5($email);
      $attempts = $this->cache->get($cache_key);
      if (!$attempts) {
         $attempts = ['count' => 0, 'timestamp' => time()];
      }
      // Reset counter if window has passed
      if (time() - $attempts['timestamp'] > $this->rate_limit_window) {
         $attempts = ['count' => 0, 'timestamp' => time()];
      }
      // Check if limit exceeded
      if ($attempts['count'] >= $this->max_attempts_per_hour) {
         return new WP_Error(
            'rate_limit_exceeded',
            'Too many magic link requests. Please try again in an hour.'
         );
      }
      // Increment counter
      $attempts['count']++;
      $this->cache->set($cache_key, $attempts, $this->rate_limit_window);
      return true;
   }
   /**
    * Send login magic link to existing user
    */
   protected function sendLoginLink(string $email, array $context): bool|WP_Error
   {
      // Check if user exists
      $user = get_user_by('email', $email);
      if (!$user) {
         return new WP_Error('user_not_found', 'No account found with this email');
      }
      // Generate token
      $token = $this->generateToken($email, self::TYPE_LOGIN, [
         'user_id' => $user->ID
      ]);
      // Build magic link URL
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'action' => 'magic_login'
      ], home_url('/'));
      // Add redirect if specified
      if (!empty($context['redirect_to'])) {
         $magic_url = add_query_arg('redirect_to', urlencode($context['redirect_to']), $magic_url);
      }
      // Send email
      $subject = 'Sign in to ' . get_bloginfo('name');
      $message = $this->getLoginEmailTemplate($user->display_name, $magic_url);
@@ -132,7 +197,6 @@
         return $this->sendLoginLink($email, $context);
      }
      // Generate token with signup data
      $token_data = [
         'name' => $context['name'] ?? '',
         'role' => $context['role'] ?? 'subscriber',
@@ -141,18 +205,16 @@
      $token = $this->generateToken($email, self::TYPE_SIGNUP, $token_data);
      // Build signup completion URL
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'action' => 'magic_signup'
      ], home_url('/'));
      // Send welcome email
      $subject = 'Complete your ' . get_bloginfo('name') . ' registration';
      $message = $this->getSignupEmailTemplate($context['name'] ?? '', $magic_url);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Confirm Your Account');
      $sent = $this->email->sendEmail($email, $subject, $message, 'Complete Registration');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send signup link');
   }
@@ -162,47 +224,33 @@
    */
   protected function sendReferralLink(string $email, array $context):bool|WP_Error
   {
      // Check if user already exists
      if (email_exists($email)) {
         return new WP_Error('user_exists', 'This person already has an account');
      }
      // Validate referral code
      if (empty($context['referral_code'])) {
         return new WP_Error('missing_referral_code', 'Referral code is required');
         return new WP_Error('missing_referral', 'Referral code is required');
      }
      // Get referrer info for personalized email
      $referrer_name = $context['referrer_name'] ?? 'A friend';
      // Generate token with referral context
      $token_data = [
         'name' => $context['name'] ?? '',
         'referral_code' => $context['referral_code'],
         'referrer_id' => $context['referrer_id'] ?? 0
         'name' => $context['name'] ?? '',
         'role' => $context['role'] ?? 'subscriber'
      ];
      $token = $this->generateToken($email, self::TYPE_REFERRAL, $token_data);
      // Build referral signup URL
      $magic_url = add_query_arg([
         'magic_token' => $token,
         'email' => urlencode($email),
         'action' => 'magic_referral'
      ], home_url('/'));
      // Send personalized referral email
      $subject = $referrer_name . ' invited you to ' . get_bloginfo('name');
      $message = $this->getReferralEmailTemplate(
         $context['name'] ?? '',
         $referrer_name,
         $magic_url,
         $context['reward_text'] ?? ''
      );
      $referrer_name = $context['referrer_name'] ?? 'A friend';
      $reward_text = $context['reward_text'] ?? '';
      $sent = $this->email->sendEmail($email, $subject, $message, $referrer_name.' invites you to see the difference at Legacy');
      $subject = $referrer_name . ' invited you to join ' . get_bloginfo('name');
      $message = $this->getReferralEmailTemplate($context['name'] ?? '', $referrer_name, $magic_url, $reward_text);
      return $sent ? true : new WP_Error('email_failed', 'Failed to send referral invitation');
      $sent = $this->email->sendEmail($email, $subject, $message, 'Accept Invitation');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send referral link');
   }
   /**
@@ -212,8 +260,7 @@
   {
      $user = get_user_by('email', $email);
      if (!$user) {
         // Return success even if user doesn't exist (security best practice)
         return true;
         return new WP_Error('user_not_found', 'No account found with this email');
      }
      $token = $this->generateToken($email, self::TYPE_RESET, [
@@ -229,7 +276,7 @@
      $subject = 'Reset your password';
      $message = $this->getResetEmailTemplate($user->display_name, $magic_url);
      $sent = $this->email->sendEmail($email, $subject, $message);
      $sent = $this->email->sendEmail($email, $subject, $message, 'Reset Password');
      return $sent ? true : new WP_Error('email_failed', 'Failed to send reset link');
   }
@@ -239,7 +286,6 @@
    */
   public function handleMagicLinkClick(): void
   {
      // Check if this is a magic link request
      if (!isset($_GET['action']) || !isset($_GET['magic_token']) || !isset($_GET['email'])) {
         return;
      }
@@ -248,12 +294,10 @@
      $token = sanitize_text_field($_GET['magic_token']);
      $email = sanitize_email($_GET['email']);
      // Only handle magic link actions
      if (!in_array($action, ['magic_login', 'magic_signup', 'magic_referral', 'magic_reset'])) {
         return;
      }
      // Verify token
      $token_data = $this->verifyToken($token, $email);
      if (is_wp_error($token_data)) {
@@ -261,7 +305,6 @@
         return;
      }
      // Handle different action types
      switch ($action) {
         case 'magic_login':
            $this->processLogin($token_data);
@@ -292,18 +335,14 @@
         wp_die('Invalid user');
      }
      // Log the user in
      wp_clear_auth_cookie();
      wp_set_current_user($user->ID);
      wp_set_auth_cookie($user->ID, true);
      // Trigger login action
      do_action('wp_login', $user->user_login, $user);
      // Determine redirect
      $redirect = isset($_GET['redirect_to']) ? esc_url_raw($_GET['redirect_to']) : home_url('/dash');
      // Redirect
      wp_safe_redirect($redirect);
      exit;
   }
@@ -313,56 +352,6 @@
    */
   protected function processSignup(array $token_data): void
   {
      // Create the user account
      $user_id = wp_create_user(
         $token_data['email'],
         wp_generate_password(20, true, true), // Random password
         $token_data['email']
      );
      if (is_wp_error($user_id)) {
         wp_die('Failed to create account: ' . $user_id->get_error_message());
      }
      // Set role
      $user = get_user_by('ID', $user_id);
      $user->set_role($token_data['role']);
      // Update display name if provided
      if (!empty($token_data['name'])) {
         wp_update_user([
            'ID' => $user_id,
            'display_name' => $token_data['name'],
            'first_name' => $token_data['name']
         ]);
      }
      // Save any additional meta
      if (!empty($token_data['meta'])) {
         foreach ($token_data['meta'] as $key => $value) {
            update_user_meta($user_id, BASE . $key, $value);
         }
      }
      // Log the user in
      wp_set_current_user($user_id);
      wp_set_auth_cookie($user_id, true);
      // Trigger registration actions
      do_action('user_register', $user_id);
      do_action('wp_login', $user->user_login, $user);
      // Redirect to welcome page or dashboard
      wp_safe_redirect(home_url('/dash?welcome=1'));
      exit;
   }
   /**
    * Process referral signup via magic link
    */
   protected function processReferralSignup(array $token_data): void
   {
      // Create user account
      $user_id = wp_create_user(
         $token_data['email'],
         wp_generate_password(20, true, true),
@@ -373,7 +362,9 @@
         wp_die('Failed to create account: ' . $user_id->get_error_message());
      }
      // Update user info
      $user = get_user_by('ID', $user_id);
      $user->set_role($token_data['role']);
      if (!empty($token_data['name'])) {
         wp_update_user([
            'ID' => $user_id,
@@ -382,192 +373,109 @@
         ]);
      }
      // Store referral code in session for ReferralManager to pick up
      if (!empty($token_data['meta'])) {
         foreach ($token_data['meta'] as $key => $value) {
            update_user_meta($user_id, BASE . $key, $value);
         }
      }
      wp_set_current_user($user_id);
      wp_set_auth_cookie($user_id, true);
      do_action('user_register', $user_id);
      do_action('wp_login', $user->user_login, $user);
      wp_safe_redirect(home_url('/dash?welcome=1'));
      exit;
   }
   /**
    * Process referral signup via magic link
    */
   protected function processReferralSignup(array $token_data): void
   {
      $user_id = wp_create_user(
         $token_data['email'],
         wp_generate_password(20, true, true),
         $token_data['email']
      );
      if (is_wp_error($user_id)) {
         wp_die('Failed to create account: ' . $user_id->get_error_message());
      }
      if (!empty($token_data['name'])) {
         wp_update_user([
            'ID' => $user_id,
            'display_name' => $token_data['name'],
            'first_name' => $token_data['name']
         ]);
      }
      // Store referral code for ReferralManager
      if (session_status() === PHP_SESSION_NONE) {
         session_start();
      }
      $_SESSION[BASE . 'referral_code'] = $token_data['referral_code'];
      setcookie(BASE . 'referral_code', $token_data['referral_code'], time() + (30 * DAY_IN_SECONDS), '/');
      setcookie(
         BASE . 'referral_code',
         $token_data['referral_code'],
         time() + (86400 * 30),
         '/'
      );
      // Process referral (this will be picked up by ReferralManager::processReferral)
      do_action('user_register', $user_id);
      // Log the user in
      $user = get_user_by('ID', $user_id);
      wp_set_current_user($user_id);
      wp_set_auth_cookie($user_id, true);
      do_action('wp_login', get_user_by('ID', $user_id)->user_login, get_user_by('ID', $user_id));
      // Redirect with referral welcome message
      wp_safe_redirect(home_url('/dash?referral_welcome=1'));
      do_action('user_register', $user_id);
      do_action('wp_login', $user->user_login, $user);
      wp_safe_redirect(home_url('/dash?welcome=1&referral=1'));
      exit;
   }
   /**
    * Process password reset
    * Process password reset via magic link
    */
   protected function processPasswordReset(array $token_data): void
   {
      // Redirect to password reset form with token
      wp_safe_redirect(add_query_arg([
         'action' => 'rp',
         'key' => $token_data['token'], // Could use magic token or generate WP reset key
         'login' => $token_data['email']
      ], wp_login_url()));
      $user = get_user_by('ID', $token_data['user_id']);
      if (!$user) {
         wp_die('Invalid user');
      }
      // Log user in and redirect to password change page
      wp_set_current_user($user->ID);
      wp_set_auth_cookie($user->ID, true);
      wp_safe_redirect(admin_url('profile.php?password_reset=1'));
      exit;
   }
   /**
    * Generate a secure token
    */
   protected function generateToken(string $email, string $type, array $data): string
   {
      // Create unique token
      $token = wp_generate_password(64, false, false);
      // Store token data in transient
      $token_data = [
         'email' => $email,
         'type' => $type,
         'created_at' => time(),
         'expires_at' => time() + $this->token_expiry,
         'data' => $data
      ];
      $cache_key = 'magic_token_' . $token;
      set_transient($cache_key, $token_data, $this->token_expiry);
      // Also index by email for rate limiting
      $this->recordTokenGeneration($email);
      return $token;
   }
   /**
    * Verify a magic link token
    */
   protected function verifyToken(string $token, string $email)
   {
      // Retrieve token data
      $cache_key = 'magic_token_' . $token;
      $token_data = get_transient($cache_key);
      if (!$token_data) {
         return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
      }
      // Verify email matches
      if ($token_data['email'] !== $email) {
         return new WP_Error('invalid_token', 'Invalid magic link');
      }
      // Check expiration
      if (time() > $token_data['expires_at']) {
         delete_transient($cache_key);
         return new WP_Error('expired_token', 'This link has expired. Please request a new one.');
      }
      // Token is valid - delete it (single use)
      delete_transient($cache_key);
      // Return merged data
      return array_merge($token_data['data'], [
         'email' => $token_data['email'],
         'type' => $token_data['type']
      ]);
   }
   /**
    * Rate limiting for magic link generation
    */
   protected function checkRateLimit(string $email):bool|WP_Error
   {
      $limit_key = 'magic_link_limit_' . md5($email);
      $attempts = (int) get_transient($limit_key);
      if ($attempts >= $this->max_attempts_per_hour) {
         return new WP_Error(
            'rate_limit_exceeded',
            'Too many login attempts. Please try again in an hour.'
         );
      }
      return true;
   }
   /**
    * Record token generation for rate limiting
    */
   protected function recordTokenGeneration(string $email): void
   {
      $limit_key = 'magic_link_limit_' . md5($email);
      $attempts = (int) get_transient($limit_key);
      set_transient($limit_key, $attempts + 1, $this->rate_limit_window);
   }
   /**
    * Handle invalid/expired tokens
    * Handle invalid token
    */
   protected function handleInvalidToken(WP_Error $error): void
   {
      wp_die(
         $error->get_error_message(),
         'Invalid Link',
         [
            'response' => 400,
            'back_link' => true
         ]
      );
      wp_die($error->get_error_message());
   }
   /**
    * Add "Send me a magic link" option to login form
    * Handle failed login - offer magic link option
    */
   public function addMagicLinkOption(): void
   public function handleFailedLogin(string $username): void
   {
      ?>
      <p class="magic-link-option">
         <a href="#" id="use-magic-link">Send me a login link instead</a>
      </p>
      <script>
         document.getElementById('use-magic-link')?.addEventListener('click', function(e) {
            e.preventDefault();
            const email = document.getElementById('user_login')?.value;
            if (!email) {
               alert('Please enter your email address first');
               return;
            }
            // Send magic link request
            fetch('<?php echo rest_url(BASE . '/v1/magic-link/send'); ?>', {
               method: 'POST',
               headers: {
                  'Content-Type': 'application/json',
               },
               body: JSON.stringify({
                  email: email,
                  type: 'login'
               })
            })
               .then(r => r.json())
               .then(data => {
                  if (data.success) {
                     alert('Check your email! We sent you a login link.');
                  } else {
                     alert(data.message || 'Failed to send link');
                  }
               });
         });
      </script>
      <?php
      // Could add logic here to automatically offer magic link
      // after multiple failed attempts
   }
   /**
    * Optionally block standard password auth for certain users
    * Optionally block standard password auth for magic-link-only users
    */
   public function blockStandardAuth($user, $username, $password)
   {
      // Only block if user has magic-link-only flag
      if ($user instanceof WP_User) {
         $magic_only = get_user_meta($user->ID, BASE . 'magic_link_only', true);
         if ($magic_only) {
@@ -609,17 +517,14 @@
   protected function getReferralEmailTemplate(string $name, string $referrer_name, string $magic_url, string $reward_text): string
   {
      $content = '<h2>Hey' . ($name ? ' ' . esc_html($name) : '') . '!</h2>';
      $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . ' and invited you to join!</p>';
      $content .= '<p><strong>' . esc_html($referrer_name) . '</strong> thinks you\'d love ' . get_bloginfo('name') . '!</p>';
      if ($reward_text) {
         $content .= '<div style="background: #e7f5ff; padding: 20px; border-radius: 8px; margin: 20px 0;">';
         $content .= '<h3 style="margin-top: 0;">🎉 Special Offer</h3>';
         $content .= '<p>' . esc_html($reward_text) . '</p>';
         $content .= '</div>';
      }
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Accept Invitation</a>';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Join Now</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">This link expires in 15 minutes.</p>';
@@ -628,12 +533,12 @@
   protected function getResetEmailTemplate(string $name, string $magic_url): string
   {
      $content = '<h2>Reset Your Password</h2>';
      $content .= '<p>Hey ' . esc_html($name) . ', we received a request to reset your password.</p>';
      $content = '<h2>Hey ' . esc_html($name) . '!</h2>';
      $content .= '<p>We received a request to reset your password. Click the button below to sign in and update your password.</p>';
      $content .= '<p style="text-align: center; margin: 30px 0;">';
      $content .= '<a href="' . $magic_url . '" style="background: #2271b1; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">Reset Password</a>';
      $content .= '</p>';
      $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email.</p>';
      $content .= '<p style="color: #666; font-size: 14px;">If you didn\'t request this, you can safely ignore this email. This link expires in 15 minutes.</p>';
      return $content;
   }