cache_name = 'auth'; $this->cache_ttl = WEEK_IN_SECONDS; parent::__construct(); } public function registerRoutes(): void { // Login endpoint register_rest_route($this->namespace, '/auth/login', [ 'methods' => 'POST', 'callback' => [$this, 'handleLogin'], 'permission_callback' => [$this, 'checkRateLimit'] ]); // Logout endpoint register_rest_route($this->namespace, '/auth/logout', [ 'methods' => 'POST', 'callback' => [$this, 'handleLogout'], 'permission_callback' => 'is_user_logged_in' ]); // Check auth status register_rest_route($this->namespace, '/auth/status', [ 'methods' => 'GET', 'callback' => [$this, 'getAuthStatus'], 'permission_callback' => '__return_true' ]); // Request password reset register_rest_route($this->namespace, '/auth/lostpassword', [ 'methods' => 'POST', 'callback' => [$this, 'requestPasswordReset'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => [ 'user_email' => [ 'required' => true, 'type' => 'string', 'format' => 'email', 'sanitize_callback' => 'sanitize_email' ] ] ]); // Complete password reset (with token) register_rest_route($this->namespace, '/auth/reset-password', [ 'methods' => 'POST', 'callback' => [$this, 'resetPassword'], 'permission_callback' => [$this, 'checkRateLimit'], 'args' => [ 'token' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'user_email' => [ 'required' => true, 'type' => 'string', 'format' => 'email', 'sanitize_callback' => 'sanitize_email' ], 'password' => [ 'required' => true, 'type' => 'string' ] ] ]); register_rest_route($this->namespace, '/auth/register', [ 'methods' => 'POST', 'callback' => [$this, 'handleRegister'], 'permission_callback' => [$this, 'checkRateLimit'], ]); // Refresh session register_rest_route($this->namespace, '/auth/refresh', [ 'methods' => 'POST', 'callback' => [$this, 'refreshSession'], 'permission_callback' => 'is_user_logged_in' ]); register_rest_route($this->namespace, '/auth/email', [ 'methods' => 'POST', 'callback' => [$this, 'checkEmailExists'], 'permission_callback' => [$this, 'checkRateLimit'], ]); } public function handleLogin(WP_REST_Request $request): WP_REST_Response { $data = $request->get_params(); // Verify Turnstile if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) { return $this->error('Security verification failed', 'turnstile_failed', 403); } $username = sanitize_email($data['user_email'] ?? ''); $password = $data['user_password'] ?? ''; $remember = (bool)($data['remember_me'] ?? false); // Check for account lockout $lockout = $this->checkAccountLockout($username); if (is_wp_error($lockout)) { return $this->error( $lockout->get_error_message(), 'account_locked', 429 ); } return $this->login($username, $password, $remember, $request); } public function login(string $username, string $password, bool $remember, ?WP_REST_Request $request = null):WP_REST_Response|bool { // Attempt login $user = wp_signon([ 'user_login' => $username, 'user_password' => $password, 'remember' => $remember ], is_ssl()); if (is_wp_error($user)) { // Track failed attempt $this->trackFailedLogin($username); return ($request) ? $this->error( 'Invalid username or password', 'login_failed', 401 ) : false; } // Clear failed attempts on success $this->clearFailedAttempts($username); // Set auth cookie with remember me flag wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID, $remember, is_ssl()); // Store session fingerprint for hijacking protection if ($request) { $this->storeSessionFingerprint($user->ID, $request); } // Trigger WordPress login action do_action('wp_login', $user->user_login, $user); // Audit log $this->auditLog('user_login', [ 'user_id' => $user->ID, 'remember' => $remember ]); return ($request) ? $this->success([ 'message' => 'Login successful', 'user' => $this->formatUserData($user), 'redirect' => $this->getRedirect($user, $request->get_param('redirect_to')), 'auth' => $this->buildAuth($user->ID) ]) : true; } 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; } /** * Handle logout request */ public function handleLogout(WP_REST_Request $request): WP_REST_Response { $user_id = get_current_user_id(); // Clear session fingerprint $this->clearSessionFingerprint($user_id); // Audit log $this->auditLog('user_logout', ['user_id' => $user_id]); // WordPress logout wp_logout(); return $this->success([ 'message' => 'Logged out successfully', 'redirect' => $this->getRedirect(get_userdata($user_id), $request->get_param('redirect_to'), 'logout') ]); } 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), 'session_id' => $this->getSessionId($user) ]; } return [ 'authenticated' => false, 'currentUser' => false, 'nonces' => [ 'wp_rest' => wp_create_nonce('wp_rest') ], 'session_id' => null ]; } /** * Get unique session identifier that changes on login/logout */ protected function getSessionId(int $user_id): string { $token = wp_get_session_token(); // Current session token if (!$token) { // Fallback to a hash based on user ID and current timestamp // This will be replaced once the session token is available return md5($user_id . time()); } return md5($token); } /** * Get current authentication status */ public function getAuthStatus(WP_REST_Request $request): WP_REST_Response { $responseData = $this->buildAuth(); $response = $this->success($responseData); // Add caching headers $response->header('Cache-Control', 'private, max-age=300'); // 5 minutes $response->header('Vary', 'Cookie'); // Important for nginx return $response; } /** * Request password reset */ public function requestPasswordReset(WP_REST_Request $request): WP_REST_Response { $email = $request->get_param('user_email')??''; $email = sanitize_email($email); $token = $request->get_param('cf-turnstile-response')??''; if (!$this->verifyTurnstile($token)){ return $this->error('Security verification failed', 'turnstile_failed', 403); } // Use WordPress's built-in function $result = retrieve_password($email); // Log but don't expose if (is_wp_error($result)) { $this->auditLog('password_reset_failed', [ 'email' => $email, 'reason' => $result->get_error_code() ]); } else { $this->auditLog('password_reset_sent', [ 'email' => $email ]); } return $this->success([ 'message' => 'If an account exists with this email, you will receive a password reset link.' ]); } /** * Complete password reset with token */ public function resetPassword(WP_REST_Request $request): WP_REST_Response { $key = sanitize_text_field($request->get_param('key')); $login = sanitize_text_field($request->get_param('login')); $password = $request->get_param('password'); if (empty($key) || empty($login)) { return $this->error('Invalid reset link', 'invalid_key', 400); } // Use WordPress's native key verification $user = check_password_reset_key($key, $login); if (is_wp_error($user)) { return $this->error( 'Invalid or expired reset link', 'invalid_token', 400 ); } // Validate password strength $validation = $this->validatePassword($password); if (is_wp_error($validation)) { return $this->error( $validation->get_error_message(), 'weak_password', 400 ); } // Reset the password reset_password($user, $password); // Log them in wp_set_current_user($user->ID); wp_set_auth_cookie($user->ID, true); if (session_status() === PHP_SESSION_ACTIVE) { session_regenerate_id(true); } // Store session fingerprint $this->storeSessionFingerprint($user->ID, $request); // Audit log $this->auditLog('password_reset_complete', [ 'user_id' => $user->ID ]); return $this->success([ 'message' => 'Password reset successfully', 'redirect' => home_url('/dash') ]); } /** * Refresh session (extends session duration) */ public function refreshSession(WP_REST_Request $request): WP_REST_Response { $user_id = get_current_user_id(); // Validate session fingerprint if (!$this->validateSessionFingerprint($user_id, $request)) { wp_logout(); return $this->unauthorized('Session validation failed'); } // Refresh auth cookie wp_set_auth_cookie($user_id, true); return $this->success([ 'message' => 'Session refreshed' ]); } public function handleRegister(WP_REST_Request $request): WP_REST_Response { $data = $request->get_json_params(); // Duplicate submission check if (!$this->checkRequestId($data['request_id'] ?? '')) { return $this->error('Duplicate request detected', 'duplicate_request', 409); } // Verify Turnstile if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) { return $this->error('Security verification failed', 'turnstile_failed', 403); } $name = sanitize_text_field($data['name'] ?? ''); $email = sanitize_email($data['email'] ?? ''); $referral_code = $request->get_param('referral_code')??''; $user_type = sanitize_text_field($data['user_select'] ?? 'subscriber'); // Validate fields if (empty($name)) { return $this->error('Name is required', 'missing_name', 400, 'name'); } if (empty($email)) { return $this->error('Email is required', 'missing_email', 400, 'email'); } // Check if role can register if ($user_type !== 'subscriber') { if (!isset(JVB_USER[$user_type]) || empty(JVB_USER[$user_type]['can_register'])) { return $this->error('Invalid account type', 'invalid_user_type', 400, 'user_select'); } } // Check if email exists if (email_exists($email)) { return $this->error('Email already registered', 'duplicate_email', 400, 'email'); } // Validate referral code if provided $referrer_id = null; if ($referral_code) { $code = strtoupper(sanitize_text_field($referral_code)); $referrer = JVB()->referrals()->getUserByReferralCode($code); if (!$referrer) { return $this->error('Invalid referral code', 'invalid_code', 400); } $referrer_id = $referrer->ID; } // Allow WP plugins to add registration errors $errors = new WP_Error(); $errors = apply_filters('registration_errors', $errors, $email, $email); if ($errors->has_errors()) { return $this->error( $errors->get_error_message(), $errors->get_error_code(), 400 ); } // Update user data $role = ($referrer_id) ? get_option(BASE . 'referral_role', BASE . 'client') : jvbCheckBase($user_type); $userData = [ 'user_login' => $email, 'user_email' => $email, 'display_name' => $name, 'first_name' => strtok($name, ' '), 'role' => $role ]; // Add password if provided, otherwise generate one $password = $request->get_param('password'); if ($password) { $userData['user_pass'] = $password; } else { $userData['user_pass'] = wp_generate_password(20, true, true); } $user_id = wp_insert_user($userData); if (is_wp_error($user_id)) { return $this->error( $user_id->get_error_message(), 'registration_failed', 500 ); } // Process referral if code was provided if ($referrer_id) { update_user_meta($user_id, BASE . 'pending_referral_code', $referral_code); } // Set role $user = get_userdata($user_id); if ($user_type !== 'subscriber') { // Check if needs approval if (Features::forMembership()->has('memberVerified') && in_array($role, JVB_MEMBERSHIP['memberVerified'] ?? [])) { $user->add_cap('skip_moderation', false); update_user_meta($user_id, BASE . 'pending_approval', true); } } if (Features::forUser($user_type)->has('namedDirectory')) { $upload_dir = wp_upload_dir(); $user_directory = $user_type.'/'.$user_id; $target_dir = $upload_dir['basedir'].'/'.$user_directory; wp_mkdir_p($target_dir); } // Process additional fields from form foreach ($data as $key => $value) { if (in_array($key, ['name', 'email', 'action', 'request_id', 'user_select', 'cf-turnstile-response'])) { continue; } update_user_meta($user_id, BASE . $key, sanitize_text_field($value)); } $redirect = $this->getRedirect($user, $request->get_param('redirect_to')??get_home_url(null,'/dash'), 'register'); // Handle token handlers do_action('jvbUserRegistered', $user_id, $email, $data); $magic_link_result = JVB()->magicLink()?->sendMagicLink( $email, 'login', [ 'user_id' => $user_id, 'redirect' => $redirect ] ); if (is_wp_error($magic_link_result)) { return $this->error( 'Account created but failed to send verification email. Please use password reset.', 'magic_link_failed', 500 ); } return $this->success([ 'message' => 'Registration successful! Check your email.', 'user_id' => $user_id, 'redirect' => $redirect ]); } /************************************************************** HELPERS **************************************************************/ /** * Format user data for response */ protected function formatUserData(WP_User $user): array { return [ 'id' => $user->ID, 'username' => $user->user_login, 'email' => $user->user_email, 'display_name' => $user->display_name, 'roles' => $user->roles, 'capabilities' => [ 'manage_options' => user_can($user, 'manage_options'), 'skip_moderation' => user_can($user, 'skip_moderation'), // Add other relevant capabilities ] ]; } protected function getRedirect(WP_User $user, ?string $url=null, string $context = 'login'):string { if (!empty($url)) { $url = sanitize_url($url); if (wp_validate_redirect($url)) { return $url; } } // Redirect to custom dashboard for members if (function_exists('isOurPeople') && isOurPeople()) { return home_url('/dash'); } // Admins can go to wp-admin if they want (but only if not using custom dashboard) if (user_can($user, 'manage_options')) { return admin_url(); } $custom_redirect = get_option(BASE . 'after_'.$context.'_redirect'); if ($custom_redirect) { return $custom_redirect; } return home_url(); } /** * Validate password strength */ protected function validatePassword(string $password): bool|WP_Error { if (strlen($password) < 8) { return new WP_Error( 'weak_password', 'Password must be at least 8 characters long' ); } // Add additional strength requirements as needed // - Must contain uppercase // - Must contain number // - Must contain special character return true; } /** * Check if account is locked out due to failed attempts */ protected function checkAccountLockout(string $username): bool|WP_Error { if (!defined('JVB_MAX_LOGIN_ATTEMPTS')) { return true; } $cache_key = 'login_attempts_' . md5($username); $attempts = $this->cache->get($cache_key); if (!$attempts || !isset($attempts['count'])) { return true; } if ($attempts['count'] >= JVB_MAX_LOGIN_ATTEMPTS) { $lockout_duration = defined('JVB_LOCKOUT_DURATION') ? JVB_LOCKOUT_DURATION : 15 * MINUTE_IN_SECONDS; $time_remaining = $lockout_duration - (time() - $attempts['timestamp']); if ($time_remaining > 0) { return new WP_Error( 'account_locked', sprintf( 'Too many failed login attempts. Please try again in %d minutes.', ceil($time_remaining / 60) ) ); } // Lockout expired - clear attempts $this->cache->forget($cache_key); return true; } return true; } /** * Track failed login attempt */ protected function trackFailedLogin(string $username): void { $cache_key = 'login_attempts_' . md5($username); $attempts = $this->cache->get($cache_key) ?: [ 'count' => 0, 'timestamp' => time() ]; $attempts['count']++; $attempts['timestamp'] = time(); $lockout_duration = defined('JVB_LOCKOUT_DURATION') ? JVB_LOCKOUT_DURATION : 15 * MINUTE_IN_SECONDS; $this->cache->set($cache_key, $attempts, $lockout_duration); // Audit log $this->auditLog('failed_login', [ 'username' => $username, 'attempts' => $attempts['count'] ]); } /** * Clear failed login attempts on successful login */ protected function clearFailedAttempts(string $username): void { $cache_key = 'login_attempts_' . md5($username); $this->cache->forget($cache_key); } public function checkEmailExists(WP_REST_Request $request): WP_REST_Response { $data = $request->get_json_params(); $email = sanitize_email($data['email'] ?? ''); return $this->success([ 'exists' => is_email($email) && email_exists($email) ]); } /*********************************************************************** * HELPER METHODS ***********************************************************************/ protected function checkRequestId(string $request_id): bool { if (empty($request_id)) { return true; } $cache_key = 'request_' . $request_id; if (get_transient($cache_key)) { return false; } set_transient($cache_key, true, 60); return true; } /** * Helper to return error response */ protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response { if ($this->requestId) { delete_transient('request_'.$this->requestId); $this->requestId = null; } return parent::error($message, $code, $status, $field); } }