cacheName = 'auth'; $this->cacheTtl = WEEK_IN_SECONDS; parent::__construct(); $this->hasMagicLink = Features::forSite()->has('magicLink'); } public function registerRoutes(): void { // Auth status endpoint Route::for('auth/status') ->get([$this, 'getAuthStatus']) ->auth('public') ->rateLimit(60, 60); // Standard login Route::for('auth/login') ->post([$this, 'handleLogin']) ->args([ 'user_email' => 'email|required', 'user_password' => 'string|required', 'remember_me' => 'boolean', 'redirect_to' => 'string', ]) ->auth('public') ->rateLimit(5, 300); // User registration Route::for('auth/register') ->post([$this, 'handleRegister']) ->args([ 'user_name' => 'string|required', 'user_email' => 'email|required', 'user_select' => 'string', 'referral_code' => 'string', 'redirect_to' => 'string', ]) ->auth('public') ->rateLimit(3, 3600); // Request password reset Route::for('auth/lostpassword') ->post([$this, 'handleLostPassword']) ->args([ 'user_email' => 'email|required', ]) ->auth('public') ->rateLimit(3, 3600); // Reset password with token Route::for('auth/resetpass') ->post([$this, 'handleResetPassword']) ->args([ 'key' => 'string|required', 'login' => 'string|required', 'pass1' => 'string|required', 'pass2' => 'string|required', ]) ->auth('public') ->rateLimit(5, 300); // Magic link endpoint if ($this->hasMagicLink) { Route::for('auth/magic') ->post([$this, 'handleMagicLink']) ->args([ 'user_email' => 'email|required', 'type' => 'string|enum:login,signup,referral', 'redirect_to' => 'string', ]) ->auth('public') ->rateLimit(5, 3600); } // Logout endpoint Route::for('auth/logout') ->post([$this, 'handleLogout']) ->auth('logged_in') ->rateLimit(10, 60); } /** * Get authentication status */ public function getAuthStatus(WP_REST_Request $request): WP_REST_Response { $data = $this->buildAuth(); $response = $this->success($data); // Add caching headers $response->header('Cache-Control', 'private, max-age=300'); // 5 minutes $response->header('Vary', 'Cookie'); // Important for nginx return $response; } /** * Handle standard login */ public function handleLogin(WP_REST_Request $request): WP_REST_Response { $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') ?? '')) { return $this->error( 'Security verification failed. Please try again.', 'turnstile_failed', 403 ); } // Attempt authentication $user = wp_authenticate($email, $password); 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(), 401 ); } // 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); // Return auth data for frontend return $this->success([ 'message' => 'Login successful', 'redirect' => $redirect, 'auth' => $this->buildAuth($user->ID) ]); } /** * Handle user registration */ public function handleRegister(WP_REST_Request $request): WP_REST_Response { $email = sanitize_email($request->get_param('user_email')); $name = sanitize_text_field($request->get_param('user_name')); $user_select = sanitize_text_field($request->get_param('user_select') ?? 'subscriber'); $referral_code = sanitize_text_field($request->get_param('referral_code') ?? ''); // Verify Turnstile if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) { return $this->error( 'Security verification failed. Please try again.', 'turnstile_failed', 403 ); } // Check if email already exists if (email_exists($email)) { return $this->error( 'An account with this email already exists.', 'email_exists', 400, 'user_email' ); } // Validate role selection $role = $this->validateUserRole($user_select); if (is_wp_error($role)) { return $this->error( $role->get_error_message(), 'invalid_role', 400, 'user_select' ); } // Create user account $user_id = wp_create_user( $email, wp_generate_password(20, true, true), $email ); if (is_wp_error($user_id)) { $this->logError('Registration failed', [ 'email' => $email, 'error' => $user_id->get_error_message(), ]); return $this->error( 'Failed to create account. Please try again.', 'registration_failed', 500 ); } // Set user details $user = get_user_by('ID', $user_id); $user->set_role($role); wp_update_user([ 'ID' => $user_id, 'display_name' => $name, 'first_name' => $name, ]); // Process referral code if provided if (!empty($referral_code) && Features::forSite()->has('referrals')) { $this->processReferralCode($user_id, $referral_code); } // Process additional registration fields $this->processRegistrationFields($user_id, $request->get_params()); do_action('user_register', $user_id, $request->get_params()); // Send magic link for email verification if ($this->hasMagicLink) { JVB()->magicLink()->sendMagicLink($email, 'signup', [ 'name' => $name, 'role' => $role, ]); } $this->auditLog('registration_success', [ 'user_id' => $user_id, 'email' => $email, 'role' => $role, ]); return $this->success([ 'message' => 'Registration successful! Check your email to complete setup.', 'title' => 'Success!', 'description' => [ 'See your email for next steps', '(Check your spam folder if you cannot find it after a couple minutes.)' ] ]); } /** * Handle lost password request */ public function handleLostPassword(WP_REST_Request $request): WP_REST_Response { $email = sanitize_email($request->get_param('user_email')); // Verify Turnstile if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) { return $this->error( 'Security verification failed. Please try again.', 'turnstile_failed', 403 ); } // Check if user exists $user = get_user_by('email', $email); if (!$user) { // Don't reveal if email exists for security return $this->success([ 'message' => 'If that email address is in our system, we\'ve sent a password reset link.', 'title' => 'Success!', 'description' => ['Check your email for reset instructions'] ]); } // Use magic link if available, otherwise standard WP reset if ($this->hasMagicLink) { $result = JVB()->magicLink()->sendMagicLink($email, 'reset'); if (is_wp_error($result)) { $this->logError('Magic link send failed', [ 'email' => $email, 'error' => $result->get_error_message(), ]); } } else { // Standard WordPress password reset $key = get_password_reset_key($user); if (is_wp_error($key)) { $this->logError('Reset key generation failed', [ 'email' => $email, 'error' => $key->get_error_message(), ]); } else { $this->sendPasswordResetEmail($user, $key); } } $this->auditLog('password_reset_requested', [ 'user_id' => $user->ID, 'email' => $email, ]); return $this->success([ 'message' => 'Check your email for reset instructions.', 'title' => 'Success!', 'description' => ['Check your email for reset instructions'] ]); } /** * Handle password reset with token */ public function handleResetPassword(WP_REST_Request $request): WP_REST_Response { $key = sanitize_text_field($request->get_param('key')); $login = sanitize_text_field($request->get_param('login')); $pass1 = $request->get_param('pass1'); $pass2 = $request->get_param('pass2'); // Verify passwords match if ($pass1 !== $pass2) { return $this->error( 'Passwords do not match.', 'password_mismatch', 400, 'pass2' ); } // Validate password strength if (strlen($pass1) < 8) { return $this->error( 'Password must be at least 8 characters.', 'password_weak', 400, 'pass1' ); } // Verify reset key $user = check_password_reset_key($key, $login); if (is_wp_error($user)) { return $this->error( 'Invalid or expired reset link.', 'invalid_key', 400 ); } // Reset password reset_password($user, $pass1); $this->auditLog('password_reset_completed', [ 'user_id' => $user->ID, ]); return $this->success([ 'message' => 'Password reset successful! You can now log in.', 'redirect' => wp_login_url(), ]); } /** * Handle magic link request */ public function handleMagicLink(WP_REST_Request $request): WP_REST_Response { if (!$this->hasMagicLink) { return $this->error( 'Magic link authentication is not enabled.', 'feature_disabled', 400 ); } $email = sanitize_email($request->get_param('user_email')); $type = sanitize_text_field($request->get_param('type') ?? 'login'); $redirect_to = $request->get_param('redirect_to'); // Verify Turnstile if (!$this->verifyTurnstile($request->get_param('cf-turnstile-response') ?? '')) { return $this->error( 'Security verification failed. Please try again.', 'turnstile_failed', 403 ); } $context = []; if ($redirect_to) { $context['redirect_to'] = esc_url_raw($redirect_to); } // Send magic link $result = JVB()->magicLink()->sendMagicLink($email, $type, $context); if (is_wp_error($result)) { $this->logError('Magic link send failed', [ 'email' => $email, 'type' => $type, 'error' => $result->get_error_message(), ]); return $this->error( $result->get_error_message(), $result->get_error_code(), 400 ); } $this->auditLog('magic_link_sent', [ 'email' => $email, 'type' => $type, ]); return $this->success([ 'message' => 'Check your email for a magic link to sign in!', 'title' => 'Success!', 'description' => [ 'We\'ve sent you an email with a magic link.', 'Click it to sign in instantly!', ] ]); } /** * Handle logout */ public function handleLogout(WP_REST_Request $request): WP_REST_Response { $user_id = get_current_user_id(); // Clear session fingerprint $this->clearSessionFingerprint($user_id); wp_logout(); $this->auditLog('logout', [ 'user_id' => $user_id, ]); return $this->success([ 'message' => 'Logged out successfully', 'redirect' => home_url('/login/'), ]); } /************************************************************ * 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 **************************************************************/ /** * Get redirect URL after login */ protected function getRedirectUrl(WP_User $user, ?string $redirect_to = null): string { // Use provided redirect if safe if ($redirect_to && wp_validate_redirect($redirect_to, false)) { return esc_url_raw($redirect_to); } // Default redirect based on user capability if (user_can($user, 'manage_options')) { return admin_url(); } if (isOurPeople($user->ID)) { return home_url('/dash/'); } return home_url(); } /** * Get user-friendly login error message */ protected function getLoginErrorMessage(WP_Error $error): string { $code = $error->get_error_code(); $messages = [ 'invalid_email' => 'Invalid email address.', 'invalid_username' => 'Invalid email address.', 'incorrect_password' => 'Incorrect password.', 'empty_password' => 'Please enter your password.', 'empty_username' => 'Please enter your email address.', ]; return $messages[$code] ?? 'Login failed. Please check your credentials.'; } /** * Validate user role selection during registration */ protected function validateUserRole(string $user_select): string|WP_Error { // Default to subscriber if (empty($user_select) || $user_select === 'subscriber') { return 'subscriber'; } // Check if role is valid and can register $role_config = JVB_USER[$user_select] ?? null; if (!$role_config) { return new WP_Error('invalid_role', 'Invalid role selected.'); } if (!($role_config['can_register'] ?? false)) { return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.'); } return BASE . $user_select; } /** * Process referral code during registration */ protected function processReferralCode(int $user_id, string $referral_code): void { if (!Features::forSite()->has('referrals')) { return; } try { JVB()->referrals()->processReferralCode($user_id, $referral_code); } catch (\Exception $e) { $this->logError('Referral processing failed', [ 'user_id' => $user_id, 'code' => $referral_code, 'error' => $e->getMessage(), ], 'warning'); } } /** * Process additional registration fields */ protected function processRegistrationFields(int $user_id, array $data): void { // Get registration form configuration $form_fields = get_option(BASE . 'registration_form_fields', []); foreach ($form_fields as $field_name => $field_config) { // Skip system fields if (in_array($field_name, ['user_name', 'user_email', 'user_select', 'referral_code'])) { continue; } // Save field value if present if (isset($data[$field_name]) && !empty($data[$field_name])) { $value = $data[$field_name]; // Sanitize based on field type if (isset($field_config['type'])) { $value = $this->sanitizeFieldValue($value, $field_config['type']); } update_user_meta($user_id, BASE . $field_name, $value); } } } /** * Sanitize field value based on type */ 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); } } /** * Send password reset email (fallback if magic links not available) */ protected function sendPasswordResetEmail(WP_User $user, string $key): bool { $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); } protected function buildAuth(?int $user = null): array { if (is_user_logged_in()) { $user = ($user) ?: get_current_user_id(); return [ 'authenticated' => true, 'user' => $user, 'nonces' => $this->getUserNonces($user) ]; } return [ 'authenticated' => false, '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')) { $nonces['dash'] = wp_create_nonce('dash-'.$userID); } if (Features::forSite()->has('favourites')) { $nonces['favourites'] = wp_create_nonce('favourites-'.$userID); } if (Features::anyContentHas('karma') || Features::anyTaxonomyHas('karma') || Features::anyUserHas('karma')) { $nonces['votes'] = wp_create_nonce('votes-'.$userID); } if (Features::forSite()->has('notifications')) { $nonces['notifications'] = wp_create_nonce('notifications-'.$userID); } return $nonces; } }