| | |
| | | <?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; |
| | |
| | | */ |
| | | class LoginRoutes extends Rest |
| | | { |
| | | use Instance; |
| | | protected ?string $requestId = null; |
| | | protected bool $hasMagicLink = false; |
| | | |
| | |
| | | $this->cacheTtl = WEEK_IN_SECONDS; |
| | | |
| | | parent::__construct(); |
| | | $this->hasMagicLink = Site::has('magic_link'); |
| | | |
| | | $this->hasMagicLink = Features::forSite()->has('magicLink'); |
| | | } |
| | | |
| | | public function registerRoutes(): void |
| | |
| | | Route::for('auth/status') |
| | | ->get([$this, 'getAuthStatus']) |
| | | ->auth('public') |
| | | ->rateLimit(60, 60); |
| | | ->rateLimit() |
| | | ->register(); |
| | | |
| | | // Standard login |
| | | Route::for('auth/login') |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 300); |
| | | ->rateLimit(5, 300) |
| | | ->register(); |
| | | |
| | | // User registration |
| | | Route::for('auth/register') |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(3, 3600); |
| | | ->rateLimit(3, 3600) |
| | | ->register(); |
| | | |
| | | // Request password reset |
| | | Route::for('auth/lostpassword') |
| | |
| | | 'user_email' => 'email|required', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(3, 3600); |
| | | ->rateLimit(3, 3600) |
| | | ->register(); |
| | | |
| | | // Reset password with token |
| | | Route::for('auth/resetpass') |
| | |
| | | 'pass2' => 'string|required', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 300); |
| | | ->rateLimit(5, 300) |
| | | ->register(); |
| | | |
| | | // Magic link endpoint |
| | | if ($this->hasMagicLink) { |
| | | |
| | | if (Site::has('magic_link')) { |
| | | Route::for('auth/magic') |
| | | ->post([$this, 'handleMagicLink']) |
| | | ->args([ |
| | |
| | | 'redirect_to' => 'string', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(5, 3600); |
| | | ->rateLimit(5, 3600) |
| | | ->register(); |
| | | } |
| | | |
| | | // Logout endpoint |
| | | Route::for('auth/logout') |
| | | ->post([$this, 'handleLogout']) |
| | | ->auth('logged_in') |
| | | ->rateLimit(10, 60); |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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(), |
| | |
| | | ); |
| | | } |
| | | |
| | | // 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); |
| | | |
| | |
| | | ]); |
| | | } |
| | | |
| | | 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 |
| | | */ |
| | |
| | | ); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->logError('Registration failed', [ |
| | | $this->logError('Registration failed', 'handleRegister', [ |
| | | 'email' => $email, |
| | | 'error' => $user_id->get_error_message(), |
| | | ]); |
| | |
| | | ]); |
| | | |
| | | // 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); |
| | | } |
| | | |
| | |
| | | |
| | | // Send magic link for email verification |
| | | if ($this->hasMagicLink) { |
| | | JVB()->magicLink()->sendMagicLink($email, 'signup', [ |
| | | MagicLinks::getInstance()->sendMagicLink($email, 'signup', [ |
| | | 'name' => $name, |
| | | 'role' => $role, |
| | | ]); |
| | |
| | | |
| | | // 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(), |
| | | ]); |
| | |
| | | // 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); |
| | | } |
| | | } |
| | | |
| | |
| | | if (is_wp_error($user)) { |
| | | return $this->error( |
| | | 'Invalid or expired reset link.', |
| | | 'invalid_key', |
| | | 400 |
| | | 'invalid_key' |
| | | ); |
| | | } |
| | | |
| | |
| | | */ |
| | | 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' |
| | | ); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // 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(), |
| | |
| | | |
| | | return $this->error( |
| | | $result->get_error_message(), |
| | | $result->get_error_code(), |
| | | 400 |
| | | $result->get_error_code() |
| | | ); |
| | | } |
| | | |
| | |
| | | ]); |
| | | } |
| | | |
| | | /************************************************************ |
| | | * 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 |
| | | **************************************************************/ |
| | |
| | | } |
| | | |
| | | // 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(); |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | 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'); |
| | | ]); |
| | | } |
| | | } |
| | | |
| | |
| | | */ |
| | | 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; |