| | |
| | | <?php |
| | | namespace JVBase\rest\routes; |
| | | |
| | | use JVBase\managers\EmailManager; |
| | | use JVBase\managers\LoginManager; |
| | | use JVBase\managers\MagicLinkManager; |
| | | use JVBase\rest\RestRouteManager; |
| | | use JVBase\utility\Features; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\rest\Rest; |
| | | use JVBase\rest\Route; |
| | | use JVBase\base\Site; |
| | | use WP_REST_Request; |
| | | use WP_REST_Response; |
| | | use WP_Error; |
| | |
| | | exit; |
| | | } |
| | | |
| | | class LoginRoutes extends RestRouteManager |
| | | /** |
| | | * Login Routes |
| | | * |
| | | * Handles all authentication-related endpoints with session security |
| | | */ |
| | | class LoginRoutes extends Rest |
| | | { |
| | | protected EmailManager $emailManager; |
| | | protected MagicLinkManager $magic_link; |
| | | protected LoginManager $loginManager; |
| | | protected ?string $requestId = null; |
| | | protected bool $hasMagicLink = false; |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->cache_name = 'auth'; |
| | | $this->cache_ttl = WEEK_IN_SECONDS; |
| | | $this->emailManager = new EmailManager(); |
| | | if (Features::forSite()->has('magicLink')) { |
| | | $this->magic_link = new MagicLinkManager(); |
| | | } |
| | | $this->cacheName = 'auth'; |
| | | $this->cacheTtl = WEEK_IN_SECONDS; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->hasMagicLink = Site::has('magicLink'); |
| | | } |
| | | |
| | | public function registerRoutes(): void |
| | | { |
| | | // Login endpoint |
| | | register_rest_route($this->namespace, '/auth/login', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleLogin'], |
| | | 'permission_callback' => [$this, 'checkRateLimit'], |
| | | 'args' => [ |
| | | 'user_email' => [ |
| | | 'required' => true, |
| | | 'type' => 'string', |
| | | 'sanitize_callback' => 'sanitize_email' |
| | | ], |
| | | 'user_password' => [ |
| | | 'required' => true, |
| | | 'type' => 'string' |
| | | ], |
| | | 'remember_me' => [ |
| | | 'required' => false, |
| | | 'type' => 'boolean', |
| | | 'default' => false |
| | | ] |
| | | ] |
| | | ]); |
| | | // Auth status endpoint |
| | | Route::for('auth/status') |
| | | ->get([$this, 'getAuthStatus']) |
| | | ->auth('public') |
| | | ->rateLimit() |
| | | ->register(); |
| | | |
| | | // Logout endpoint |
| | | register_rest_route($this->namespace, '/auth/logout', [ |
| | | 'methods' => 'POST', |
| | | 'callback' => [$this, 'handleLogout'], |
| | | 'permission_callback' => 'is_user_logged_in' |
| | | ]); |
| | | // 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) |
| | | ->register(); |
| | | |
| | | // Check auth status |
| | | register_rest_route($this->namespace, '/auth/status', [ |
| | | 'methods' => 'GET', |
| | | 'callback' => [$this, 'getAuthStatus'], |
| | | 'permission_callback' => '__return_true' |
| | | ]); |
| | | // 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) |
| | | ->register(); |
| | | |
| | | // 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' |
| | | ] |
| | | ] |
| | | ]); |
| | | Route::for('auth/lostpassword') |
| | | ->post([$this, 'handleLostPassword']) |
| | | ->args([ |
| | | 'user_email' => 'email|required', |
| | | ]) |
| | | ->auth('public') |
| | | ->rateLimit(3, 3600) |
| | | ->register(); |
| | | |
| | | // 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' |
| | | ] |
| | | ] |
| | | ]); |
| | | // 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) |
| | | ->register(); |
| | | |
| | | register_rest_route($this->namespace, '/auth/register', [ |
| | | 'method' => '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_json_params(); |
| | | // Verify Turnstile |
| | | if (!$this->verifyTurnstile($data['cf-turnstile-response'] ?? '')) { |
| | | return $this->error('Security verification failed', 'turnstile_failed', 403); |
| | | // 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) |
| | | ->register(); |
| | | } |
| | | |
| | | $username = sanitize_email($data['user_email'] ?? ''); |
| | | $password = $data['user_password'] ?? ''; |
| | | $remember = (bool)($data['remember_me'] ?? false); |
| | | // Logout endpoint |
| | | Route::for('auth/logout') |
| | | ->post([$this, 'handleLogout']) |
| | | ->auth('logged_in') |
| | | ->rateLimit(10) |
| | | ->register(); |
| | | } |
| | | |
| | | // Check for account lockout |
| | | $lockout = $this->checkAccountLockout($username); |
| | | if (is_wp_error($lockout)) { |
| | | /** |
| | | * 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( |
| | | $lockout->get_error_message(), |
| | | 'account_locked', |
| | | 429 |
| | | 'Security verification failed. Please try again.', |
| | | 'turnstile_failed', |
| | | 403 |
| | | ); |
| | | } |
| | | |
| | | // Attempt login |
| | | $user = wp_signon([ |
| | | 'user_login' => $username, |
| | | 'user_email' => $username, |
| | | 'user_password' => $password, |
| | | 'remember' => $remember |
| | | ], false); |
| | | // Attempt authentication |
| | | $user = wp_authenticate($email, $password); |
| | | |
| | | if (is_wp_error($user)) { |
| | | // Track failed attempt |
| | | $this->trackFailedLogin($username); |
| | | $this->auditLog('login_failed', [ |
| | | 'email' => $email, |
| | | 'error' => $user->get_error_code(), |
| | | ]); |
| | | |
| | | return $this->error( |
| | | 'Invalid username or password', |
| | | 'login_failed', |
| | | $this->getLoginErrorMessage($user), |
| | | $user->get_error_code(), |
| | | 401 |
| | | ); |
| | | } |
| | | |
| | | // Clear failed attempts on success |
| | | $this->clearFailedAttempts($username); |
| | | |
| | | // Set auth cookie with remember me flag |
| | | // Set auth cookie |
| | | wp_clear_auth_cookie(); |
| | | wp_set_current_user($user->ID); |
| | | wp_set_auth_cookie($user->ID, $remember); |
| | | |
| | | // Store session fingerprint for hijacking protection |
| | | // Store session fingerprint |
| | | $this->storeSessionFingerprint($user->ID, $request); |
| | | |
| | | // Trigger WordPress login action |
| | | do_action('wp_login', $user->user_login, $user); |
| | | |
| | | // Audit log |
| | | $this->auditLog('user_login', [ |
| | | $this->auditLog('login_success', [ |
| | | 'user_id' => $user->ID, |
| | | 'remember' => $remember |
| | | 'email' => $email, |
| | | ]); |
| | | |
| | | // Get redirect URL |
| | | $redirect = $this->getRedirectUrl($user, $redirect_to); |
| | | |
| | | // Return auth data for frontend |
| | | return $this->success([ |
| | | 'message' => 'Login successful', |
| | | 'user' => $this->formatUserData($user), |
| | | 'redirect' => $this->getLoginRedirect($user) |
| | | 'redirect' => $redirect, |
| | | 'auth' => $this->buildAuth($user->ID) |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Handle logout request |
| | | * 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) && Site::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 { |
| | | $success = JVB()->email()->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 |
| | | { |
| | |
| | | // 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' |
| | | $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()); |
| | | } |
| | | |
| | | /** |
| | | * Get current authentication status |
| | | * Generate session fingerprint for hijacking detection |
| | | */ |
| | | public function getAuthStatus(WP_REST_Request $request): WP_REST_Response |
| | | protected function generateSessionFingerprint(WP_REST_Request $request): string |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return $this->success([ |
| | | 'authenticated' => false |
| | | ]); |
| | | } |
| | | |
| | | $user = wp_get_current_user(); |
| | | |
| | | return $this->success([ |
| | | 'authenticated' => true, |
| | | 'user' => $this->formatUserData($user) |
| | | ]); |
| | | 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'] ?? '' |
| | | ) |
| | | ])); |
| | | } |
| | | |
| | | /** |
| | | * Request password reset |
| | | * Get IP class (first 3 octets) for session validation |
| | | * Allows for minor IP changes (common with mobile networks) |
| | | */ |
| | | public function requestPasswordReset(WP_REST_Request $request): WP_REST_Response |
| | | protected function getIPClass(string $ip): string |
| | | { |
| | | $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.' |
| | | ]); |
| | | $parts = explode('.', $ip); |
| | | return implode('.', array_slice($parts, 0, 3)); |
| | | } |
| | | |
| | | /** |
| | | * Complete password reset with token |
| | | * Validate session fingerprint against stored value |
| | | */ |
| | | public function resetPassword(WP_REST_Request $request): WP_REST_Response |
| | | protected function validateSessionFingerprint(int $user_id, WP_REST_Request $request): bool |
| | | { |
| | | $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); |
| | | // Only enforce if enabled in config |
| | | if (!defined('JVB_SESSION_FINGERPRINT') || !JVB_SESSION_FINGERPRINT) { |
| | | return true; |
| | | } |
| | | |
| | | // Use WordPress's native key verification |
| | | $user = check_password_reset_key($key, $login); |
| | | $stored = get_user_meta($user_id, BASE . 'session_fingerprint', true); |
| | | $current = $this->generateSessionFingerprint($request); |
| | | |
| | | if (is_wp_error($user)) { |
| | | return $this->error( |
| | | 'Invalid or expired reset link', |
| | | 'invalid_token', |
| | | 400 |
| | | ); |
| | | 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; |
| | | } |
| | | |
| | | // 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); |
| | | |
| | | // 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') |
| | | ]); |
| | | // Compare using timing-safe comparison |
| | | return hash_equals($stored, $current); |
| | | } |
| | | |
| | | /** |
| | | * Refresh session (extends session duration) |
| | | * Clear session fingerprint (call on logout) |
| | | */ |
| | | public function refreshSession(WP_REST_Request $request): WP_REST_Response |
| | | protected function clearSessionFingerprint(int $user_id): void |
| | | { |
| | | $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' |
| | | ]); |
| | | delete_user_meta($user_id, BASE . 'session_fingerprint'); |
| | | delete_user_meta($user_id, BASE . 'session_timestamp'); |
| | | } |
| | | |
| | | |
| | | 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'] ?? ''); |
| | | $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'); |
| | | } |
| | | |
| | | // Spam prevention |
| | | if ($user_type === 'subscriber' && count(JVB_USER) > 0) { |
| | | $registerable = array_filter(JVB_USER, fn($config) => $config['can_register'] ?? false); |
| | | if (!empty($registerable)) { |
| | | return $this->error('Please select a valid account type', 'invalid_user_type', 400, 'user_select'); |
| | | } |
| | | } |
| | | |
| | | // 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'); |
| | | } |
| | | |
| | | // 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 |
| | | ); |
| | | } |
| | | |
| | | // Create user |
| | | $user_id = wp_create_user($email, wp_generate_password(), $email); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | return $this->error($user_id->get_error_message(), 'user_creation_failed', 500); |
| | | } |
| | | |
| | | // Update user data |
| | | wp_update_user([ |
| | | 'ID' => $user_id, |
| | | 'display_name' => $name, |
| | | 'first_name' => strtok($name,' ') |
| | | ]); |
| | | |
| | | // Set role |
| | | $user = new WP_User($user_id); |
| | | if ($user_type === 'subscriber') { |
| | | $user->set_role('subscriber'); |
| | | } else { |
| | | $role = JVB_USER[$user_type]['role'] ?? 'subscriber'; |
| | | $user->set_role($role); |
| | | |
| | | // 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); |
| | | } |
| | | |
| | | // Save additional fields |
| | | update_user_meta($user_id, BASE . 'user_type', $user_type); |
| | | |
| | | // 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)); |
| | | } |
| | | |
| | | // Handle token handlers |
| | | do_action('jvbUserRegistered', $user_id, $email, $data); |
| | | |
| | | |
| | | return $this->success([ |
| | | 'message' => 'Registration successful! Check your email.', |
| | | 'user_id' => $user_id |
| | | ]); |
| | | } |
| | | /************************************************************** |
| | | HELPERS |
| | | * 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 |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get login redirect URL based on user role |
| | | * Get redirect URL after login |
| | | */ |
| | | protected function getLoginRedirect(WP_User $user): string |
| | | 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(); |
| | | } |
| | | |
| | | // Redirect to dashboard for members |
| | | if (function_exists('isOurPeople') && isOurPeople()) { |
| | | return home_url('/dash'); |
| | | if (isOurPeople($user->ID)) { |
| | | return home_url('/dash/'); |
| | | } |
| | | |
| | | return home_url(); |
| | | } |
| | | |
| | | /** |
| | | * Validate password strength |
| | | * Get user-friendly login error message |
| | | */ |
| | | protected function validatePassword(string $password): bool|WP_Error |
| | | protected function getLoginErrorMessage(WP_Error $error): string |
| | | { |
| | | if (strlen($password) < 8) { |
| | | return new WP_Error( |
| | | 'weak_password', |
| | | 'Password must be at least 8 characters long' |
| | | ); |
| | | } |
| | | $code = $error->get_error_code(); |
| | | |
| | | // 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->delete($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() |
| | | $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.', |
| | | ]; |
| | | |
| | | $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'] |
| | | ]); |
| | | return $messages[$code] ?? 'Login failed. Please check your credentials.'; |
| | | } |
| | | |
| | | /** |
| | | * Clear failed login attempts on successful login |
| | | * Validate user role selection during registration |
| | | */ |
| | | protected function clearFailedAttempts(string $username): void |
| | | protected function validateUserRole(string $user_select): string|WP_Error |
| | | { |
| | | $cache_key = 'login_attempts_' . md5($username); |
| | | $this->cache->delete($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; |
| | | // Default to subscriber |
| | | if (empty($user_select) || $user_select === 'subscriber') { |
| | | return 'subscriber'; |
| | | } |
| | | |
| | | $cache_key = 'request_' . $request_id; |
| | | if (get_transient($cache_key)) { |
| | | return false; |
| | | // Check if role is valid and can register |
| | | $registrar = Registrar::getInstance($user_select); |
| | | |
| | | if (!$registrar) { |
| | | return new WP_Error('invalid_role', 'Invalid role selected.'); |
| | | } |
| | | |
| | | set_transient($cache_key, true, 60); |
| | | return true; |
| | | } |
| | | if (!($registrar->hasFeature('can_register') ?? false)) { |
| | | return new WP_Error('role_not_allowed', 'This role cannot be selected during registration.'); |
| | | } |
| | | |
| | | return $registrar->getBased(); |
| | | } |
| | | |
| | | /** |
| | | * Helper to return error response |
| | | * Process referral code during registration |
| | | */ |
| | | protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response |
| | | protected function processReferralCode(int $user_id, string $referral_code): void |
| | | { |
| | | if ($this->requestId) { |
| | | delete_transient('request_'.$this->requestId); |
| | | $this->requestId = null; |
| | | if (!Site::has('referrals')) { |
| | | return; |
| | | } |
| | | return parent::error($message, $code, $status, $field); |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | 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 (Site::has('dashboard')) { |
| | | $nonces['dash'] = wp_create_nonce('dash-'.$userID); |
| | | } |
| | | if (Site::has('favourites')) { |
| | | $nonces['favourites'] = wp_create_nonce('favourites-'.$userID); |
| | | } |
| | | if (!empty(Registrar::getFeatured('karma'))) { |
| | | $nonces['votes'] = wp_create_nonce('votes-'.$userID); |
| | | } |
| | | if (Site::has('notifications')) { |
| | | $nonces['notifications'] = wp_create_nonce('notifications-'.$userID); |
| | | } |
| | | return $nonces; |
| | | } |
| | | } |