| | |
| | | <?php |
| | | namespace JVBase\rest; |
| | | |
| | | use DateTime; |
| | | use DateTimeZone; |
| | | use JVBase\JVB; |
| | | use JVBase\rest\RateLimiter; |
| | | use JVBase\managers\OperationQueue; |
| | | use JVBase\managers\CacheManager; |
| | | use JVBase\managers\NotificationManager; |
| | | use JVBase\utility\Features; |
| | | use WP_REST_Request; |
| | | use WP_Error; |
| | | use Exception; |
| | |
| | | protected NotificationManager $notifications; |
| | | protected string $cache_name =''; |
| | | protected int $cache_ttl = 3600; //1 hour default |
| | | |
| | | protected array $response_headers = []; |
| | | |
| | | // Error code constants for consistency |
| | | const ERROR_MISSING_PARAMS = 'missing_parameters'; |
| | |
| | | return true; |
| | | } |
| | | |
| | | public function checkRateLimit(WP_REST_Request $request):bool|WP_Error |
| | | { |
| | | if (!$this->rate_limiter->checkLimit($request)) { |
| | | error_log('Rate Limit Reached'); |
| | | return new WP_Error( |
| | | 'rate_limit', |
| | | 'Too many attempts. Please wait a moment before trying again.', |
| | | ['status' => 429] |
| | | ); |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Convert MySQL datetime to ISO 8601 timestamp with proper timezone |
| | | */ |
| | | public function formatTimestamp(?string $mysql_datetime): ?string |
| | | { |
| | | if (empty($mysql_datetime)) { |
| | | return null; |
| | | } |
| | | |
| | | try { |
| | | // Get WordPress timezone - dates are stored in this timezone |
| | | $wp_timezone = wp_timezone(); |
| | | |
| | | // Parse the datetime in WordPress timezone |
| | | $date = new DateTime($mysql_datetime, $wp_timezone); |
| | | |
| | | // Convert to UTC for API consistency |
| | | $date->setTimezone(new DateTimeZone('UTC')); |
| | | |
| | | // Return ISO 8601 format |
| | | return $date->format('c'); |
| | | } catch (Exception $e) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | protected function checkContent(string $content, bool $bool = false):string|bool |
| | | { |
| | | $result = JVB_CONTENT[$content]??JVB_TAXONOMY[$content]??JVB_USER[$content]??''; |
| | |
| | | |
| | | return $this->checkHeaders($request, $types, ['user_id' => $user_id]); |
| | | } |
| | | |
| | | /** |
| | | * Helper to return error response |
| | | */ |
| | | protected function error(string $message, string $code, int $status = 400, ?string $field = null): WP_REST_Response |
| | | { |
| | | $data = [ |
| | | 'success' => false, |
| | | 'message' => $message, |
| | | 'code' => $code |
| | | ]; |
| | | |
| | | if ($field) { |
| | | $data['field'] = $field; |
| | | } |
| | | |
| | | return new WP_REST_Response($data, $status); |
| | | } |
| | | /** |
| | | * Helper to return success response |
| | | */ |
| | | protected function success(array $data, int $status = 200): WP_REST_Response |
| | | { |
| | | $data['success'] = true; |
| | | return new WP_REST_Response($data, $status); |
| | | } |
| | | |
| | | /************************************************************ |
| | | SESSION FINGERPRINT |
| | | ************************************************************/ |
| | | /** |
| | | * 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 |
| | | * |
| | | * @param WP_REST_Request $request The REST request |
| | | * @return string Hashed fingerprint |
| | | */ |
| | | 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 |
| | | * This allows for minor IP changes (common with mobile networks) |
| | | * |
| | | * @param string $ip IP address |
| | | * @return string First 3 octets |
| | | */ |
| | | protected function getIPClass(string $ip): string |
| | | { |
| | | $parts = explode('.', $ip); |
| | | return implode('.', array_slice($parts, 0, 3)); |
| | | } |
| | | |
| | | /** |
| | | * Validate session fingerprint against stored value |
| | | * |
| | | * @param int $user_id User ID to validate |
| | | * @param WP_REST_Request $request Current request |
| | | * @return bool True if valid, false if potential hijacking |
| | | */ |
| | | 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) |
| | | * |
| | | * @param int $user_id User ID |
| | | * @return void |
| | | */ |
| | | protected function clearSessionFingerprint(int $user_id): void |
| | | { |
| | | delete_user_meta($user_id, BASE . 'session_fingerprint'); |
| | | delete_user_meta($user_id, BASE . 'session_timestamp'); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CSRF PROTECTION |
| | | ******************************************************************/ |
| | | /** |
| | | * Generate CSRF token for a user |
| | | * |
| | | * @param int $user_id User ID |
| | | * @return string CSRF token |
| | | */ |
| | | protected function generateCSRFToken(int $user_id): string |
| | | { |
| | | $token = wp_generate_password(32, false); |
| | | set_transient(BASE . 'csrf_' . $user_id, $token, HOUR_IN_SECONDS); |
| | | return $token; |
| | | } |
| | | |
| | | /** |
| | | * Validate CSRF token from request header |
| | | * |
| | | * @param WP_REST_Request $request The REST request |
| | | * @return bool True if valid or not required |
| | | */ |
| | | protected function validateCSRFToken(WP_REST_Request $request): bool |
| | | { |
| | | // Only for authenticated requests |
| | | if (!is_user_logged_in()) { |
| | | return true; |
| | | } |
| | | |
| | | // Only for state-changing operations |
| | | if (in_array($request->get_method(), ['GET', 'HEAD', 'OPTIONS'])) { |
| | | return true; |
| | | } |
| | | |
| | | $user_id = get_current_user_id(); |
| | | $token = $request->get_header('X-CSRF-Token'); |
| | | $stored = get_transient(BASE . 'csrf_' . $user_id); |
| | | |
| | | if (empty($stored) || empty($token)) { |
| | | return false; |
| | | } |
| | | |
| | | return hash_equals($stored, $token); |
| | | } |
| | | |
| | | /** |
| | | * Get current CSRF token for user (for frontend to use) |
| | | * |
| | | * @param int $user_id User ID |
| | | * @return string|null CSRF token or null |
| | | */ |
| | | protected function getCSRFToken(int $user_id): ?string |
| | | { |
| | | $token = get_transient(BASE . 'csrf_' . $user_id); |
| | | |
| | | if (!$token) { |
| | | $token = $this->generateCSRFToken($user_id); |
| | | } |
| | | |
| | | return $token; |
| | | } |
| | | |
| | | /*********************************************************** |
| | | * REQUEST SIGNATURE VERIFICATION |
| | | ***********************************************************/ |
| | | /** |
| | | * Verify request signature for sensitive operations |
| | | * Adds an additional layer of security for critical endpoints |
| | | * |
| | | * @param WP_REST_Request $request The REST request |
| | | * @param array $sensitive_params Params to include in signature |
| | | * @return bool True if valid signature |
| | | */ |
| | | protected function verifyRequestSignature(WP_REST_Request $request, array $sensitive_params): bool |
| | | { |
| | | $signature = $request->get_header('X-Request-Signature'); |
| | | if (empty($signature)) { |
| | | return false; |
| | | } |
| | | |
| | | // Get user's signing key (rotated on each login) |
| | | $user_id = get_current_user_id(); |
| | | if (!$user_id) { |
| | | return false; |
| | | } |
| | | |
| | | $signing_key = get_user_meta($user_id, BASE . 'signing_key', true); |
| | | if (empty($signing_key)) { |
| | | // Generate new key if missing |
| | | $signing_key = wp_generate_password(64, false); |
| | | update_user_meta($user_id, BASE . 'signing_key', $signing_key); |
| | | } |
| | | |
| | | // Build signature from sensitive params |
| | | ksort($sensitive_params); |
| | | $message = json_encode($sensitive_params); |
| | | $expected = hash_hmac('sha256', $message, $signing_key); |
| | | |
| | | return hash_equals($expected, $signature); |
| | | } |
| | | |
| | | /** |
| | | * Rotate signing key (call on login) |
| | | * |
| | | * @param int $user_id User ID |
| | | * @return string New signing key |
| | | */ |
| | | protected function rotateSigningKey(int $user_id): string |
| | | { |
| | | $new_key = wp_generate_password(64, false); |
| | | update_user_meta($user_id, BASE . 'signing_key', $new_key); |
| | | return $new_key; |
| | | } |
| | | /********************************************************** |
| | | * AUDIT LOGGING |
| | | **********************************************************/ |
| | | /** |
| | | * Log security-relevant events |
| | | * |
| | | * @param string $event Event name |
| | | * @param array $data Additional event data |
| | | * @return void |
| | | */ |
| | | protected function auditLog(string $event, array $data = []): void |
| | | { |
| | | // Add standard context |
| | | $context = array_merge($data, [ |
| | | 'timestamp' => current_time('mysql'), |
| | | 'user_id' => get_current_user_id() ?: 0, |
| | | 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', |
| | | 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', |
| | | ]); |
| | | |
| | | // Use existing error handler |
| | | try { |
| | | JVB()->error()->log( |
| | | 'security_audit', |
| | | $event, |
| | | $context, |
| | | 'info' |
| | | ); |
| | | } catch (\Exception $e) { |
| | | // Fallback to error_log if handler fails |
| | | error_log("Security Audit: {$event} - " . json_encode($context)); |
| | | } |
| | | } |
| | | |
| | | /*************************************************************** |
| | | * SANITIZATION HELPERS |
| | | ***************************************************************/ |
| | | /** |
| | | * Sanitize array of IDs |
| | | * |
| | | * @param array $ids Array of IDs |
| | | * @return array Sanitized array of integers |
| | | */ |
| | | protected function sanitizeIDs(array $ids): array |
| | | { |
| | | return array_map('absint', array_filter($ids, function($id) { |
| | | return is_numeric($id) && $id > 0; |
| | | })); |
| | | } |
| | | |
| | | /*************************************************************** |
| | | * RESPONSE HELPERS |
| | | ***************************************************************/ |
| | | /** |
| | | * Return validation error response |
| | | * |
| | | * @param array $errors Validation errors (field => message) |
| | | * @return WP_REST_Response 422 response with errors |
| | | */ |
| | | protected function validationError(array $errors): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => 'Validation failed', |
| | | 'errors' => $errors |
| | | ], 422); |
| | | } |
| | | |
| | | /** |
| | | * Return unauthorized error response |
| | | * |
| | | * @param string $message Error message |
| | | * @return WP_REST_Response 401 response |
| | | */ |
| | | protected function unauthorized(string $message = 'Unauthorized'): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $message |
| | | ], 401); |
| | | } |
| | | |
| | | /** |
| | | * Return forbidden error response |
| | | * |
| | | * @param string $message Error message |
| | | * @return WP_REST_Response 403 response |
| | | */ |
| | | protected function forbidden(string $message = 'Forbidden'): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $message |
| | | ], 403); |
| | | } |
| | | |
| | | /** |
| | | * Return not found error response |
| | | * |
| | | * @param string $message Error message |
| | | * @return WP_REST_Response 404 response |
| | | */ |
| | | protected function notFound(string $message = 'Not found'): WP_REST_Response |
| | | { |
| | | return new WP_REST_Response([ |
| | | 'success' => false, |
| | | 'message' => $message |
| | | ], 404); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CONCURRENT CHECKS |
| | | *****************************************************************/ |
| | | /** |
| | | * Prevent concurrent requests for the same operation |
| | | * Useful for preventing double-submissions |
| | | * |
| | | * @param string $operation_key Unique key for this operation |
| | | * @param int $lock_duration Lock duration in seconds |
| | | * @return bool True if lock acquired, false if already locked |
| | | */ |
| | | protected function acquireOperationLock(string $operation_key, int $lock_duration = 5): bool |
| | | { |
| | | $lock_key = 'op_lock_' . md5($operation_key); |
| | | |
| | | // Try to acquire lock |
| | | $locked = get_transient($lock_key); |
| | | if ($locked) { |
| | | return false; // Already locked |
| | | } |
| | | |
| | | // Set lock |
| | | set_transient($lock_key, true, $lock_duration); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Release operation lock |
| | | * |
| | | * @param string $operation_key Unique key for this operation |
| | | * @return void |
| | | */ |
| | | protected function releaseOperationLock(string $operation_key): void |
| | | { |
| | | $lock_key = 'op_lock_' . md5($operation_key); |
| | | delete_transient($lock_key); |
| | | } |
| | | |
| | | protected function verifyTurnstile(string $token): bool |
| | | { |
| | | if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) { |
| | | return true; |
| | | } |
| | | |
| | | if (empty($token)) { |
| | | return false; |
| | | } |
| | | |
| | | return JVB()->connect('cloudflare')->verifyTurnstile($token); |
| | | } |
| | | } |
| | | // |
| | | //Simple example: |
| | |
| | | // $response = new WP_REST_Response($data); |
| | | // return $this->addCacheHeaders($response); |
| | | //} |
| | | |
| | | |
| | | |
| | | /** |
| | | * Use operation lock in your methods like this: |
| | | * |
| | | * public function updateContent(WP_REST_Request $request): WP_REST_Response |
| | | * { |
| | | * $user_id = get_current_user_id(); |
| | | * $content_id = $request->get_param('content_id'); |
| | | * |
| | | * // Prevent concurrent updates |
| | | * $lock_key = "update_{$user_id}_{$content_id}"; |
| | | * if (!$this->acquireOperationLock($lock_key)) { |
| | | * return $this->error( |
| | | * 'An update is already in progress. Please wait.', |
| | | * 'concurrent_operation', |
| | | * 409 |
| | | * ); |
| | | * } |
| | | * |
| | | * try { |
| | | * // Do your operation |
| | | * $result = $this->doUpdate($content_id); |
| | | * |
| | | * $this->releaseOperationLock($lock_key); |
| | | * return $this->success($result); |
| | | * |
| | | * } catch (\Exception $e) { |
| | | * $this->releaseOperationLock($lock_key); |
| | | * return $this->error($e->getMessage(), 'operation_failed'); |
| | | * } |
| | | * } |
| | | */ |