Files
wc-licensed-product/src/Api/RestApiController.php

426 lines
13 KiB
PHP
Raw Normal View History

<?php
/**
* REST API Controller
*
* @package Jeremias\WcLicensedProduct\Api
*/
declare(strict_types=1);
namespace Jeremias\WcLicensedProduct\Api;
use Jeremias\WcLicensedProduct\License\LicenseManager;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Handles REST API endpoints for license validation
*/
final class RestApiController
{
private const NAMESPACE = 'wc-licensed-product/v1';
/**
* Rate limit: requests per minute per IP
*/
private const RATE_LIMIT_REQUESTS = 30;
/**
* Rate limit window in seconds
*/
private const RATE_LIMIT_WINDOW = 60;
private LicenseManager $licenseManager;
public function __construct(LicenseManager $licenseManager)
{
$this->licenseManager = $licenseManager;
$this->registerHooks();
}
/**
* Register WordPress hooks
*/
private function registerHooks(): void
{
add_action('rest_api_init', [$this, 'registerRoutes']);
}
/**
* Check rate limit for current IP
*
* @return WP_REST_Response|null Returns error response if rate limited, null if OK
*/
private function checkRateLimit(): ?WP_REST_Response
{
$ip = $this->getClientIp();
$transientKey = 'wclp_rate_' . md5($ip);
$data = get_transient($transientKey);
if ($data === false) {
// First request, start counting
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
$count = (int) ($data['count'] ?? 0);
$start = (int) ($data['start'] ?? time());
// Check if window has expired
if (time() - $start >= self::RATE_LIMIT_WINDOW) {
// Reset counter
set_transient($transientKey, ['count' => 1, 'start' => time()], self::RATE_LIMIT_WINDOW);
return null;
}
// Check if limit exceeded
if ($count >= self::RATE_LIMIT_REQUESTS) {
$retryAfter = self::RATE_LIMIT_WINDOW - (time() - $start);
$response = new WP_REST_Response([
'success' => false,
'error' => 'rate_limit_exceeded',
'message' => __('Too many requests. Please try again later.', 'wc-licensed-product'),
'retry_after' => $retryAfter,
], 429);
$response->header('Retry-After', (string) $retryAfter);
return $response;
}
// Increment counter
set_transient($transientKey, ['count' => $count + 1, 'start' => $start], self::RATE_LIMIT_WINDOW);
return null;
}
/**
* Get client IP address
*
* Security note: Only trust proxy headers when explicitly configured.
* Set WC_LICENSE_TRUSTED_PROXIES constant or configure trusted_proxies
* in wp-config.php to enable proxy header support.
*
* @return string Client IP address
*/
private function getClientIp(): string
{
// Get the direct connection IP first
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
// Only check proxy headers if we're behind a trusted proxy
if ($this->isTrustedProxy($remoteAddr)) {
// Check headers in order of trust preference
$headers = [
'HTTP_CF_CONNECTING_IP', // Cloudflare
'HTTP_X_FORWARDED_FOR',
'HTTP_X_REAL_IP',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
$ips = explode(',', $_SERVER[$header]);
$ip = trim($ips[0]);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip;
}
}
}
}
// Validate and return direct connection IP
if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) {
return $remoteAddr;
}
return '0.0.0.0';
}
/**
* Check if the given IP is a trusted proxy
*
* @param string $ip The IP address to check
* @return bool Whether the IP is a trusted proxy
*/
private function isTrustedProxy(string $ip): bool
{
// Check if trusted proxies are configured
if (!defined('WC_LICENSE_TRUSTED_PROXIES')) {
return false;
}
$trustedProxies = WC_LICENSE_TRUSTED_PROXIES;
// Handle string constant (comma-separated list)
if (is_string($trustedProxies)) {
$trustedProxies = array_map('trim', explode(',', $trustedProxies));
}
if (!is_array($trustedProxies)) {
return false;
}
// Check for special keywords
if (in_array('CLOUDFLARE', $trustedProxies, true)) {
// Cloudflare IP ranges (simplified - in production, fetch from Cloudflare API)
if ($this->isCloudflareIp($ip)) {
return true;
}
}
// Check direct IP match or CIDR notation
foreach ($trustedProxies as $proxy) {
if ($proxy === $ip) {
return true;
}
// Support CIDR notation
if (str_contains($proxy, '/') && $this->ipMatchesCidr($ip, $proxy)) {
return true;
}
}
return false;
}
/**
* Check if IP is in Cloudflare range
*
* @param string $ip The IP to check
* @return bool Whether IP belongs to Cloudflare
*/
private function isCloudflareIp(string $ip): bool
{
// Cloudflare IPv4 ranges (as of 2024)
$cloudflareRanges = [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
];
foreach ($cloudflareRanges as $range) {
if ($this->ipMatchesCidr($ip, $range)) {
return true;
}
}
return false;
}
/**
* Check if an IP matches a CIDR range
*
* @param string $ip The IP to check
* @param string $cidr The CIDR range (e.g., "192.168.1.0/24")
* @return bool Whether the IP matches the CIDR range
*/
private function ipMatchesCidr(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ||
!filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
$mask = -1 << (32 - (int) $bits);
return ($ipLong & $mask) === ($subnetLong & $mask);
}
/**
* Register REST API routes
*/
public function registerRoutes(): void
{
// Validate license endpoint (public)
register_rest_route(self::NAMESPACE, '/validate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'validateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 64;
},
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'validate_callback' => function ($value): bool {
return !empty($value) && strlen($value) <= 255;
},
],
],
]);
// Check license status endpoint (public)
register_rest_route(self::NAMESPACE, '/status', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'checkStatus'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
// Activate license on domain endpoint (public)
register_rest_route(self::NAMESPACE, '/activate', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'activateLicense'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
'domain' => [
'required' => true,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
}
/**
* Validate license endpoint
*/
public function validateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$result = $this->licenseManager->validateLicense($licenseKey, $domain);
$statusCode = $result['valid'] ? 200 : 403;
return new WP_REST_Response($result, $statusCode);
}
/**
* Check license status endpoint
*/
public function checkStatus(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'valid' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
return new WP_REST_Response([
'valid' => $license->isValid(),
'status' => $license->getStatus(),
'domain' => $license->getDomain(),
'expires_at' => $license->getExpiresAt()?->format('Y-m-d'),
'activations_count' => $license->getActivationsCount(),
'max_activations' => $license->getMaxActivations(),
]);
}
/**
* Activate license on domain endpoint
*/
public function activateLicense(WP_REST_Request $request): WP_REST_Response
{
$rateLimitResponse = $this->checkRateLimit();
if ($rateLimitResponse !== null) {
return $rateLimitResponse;
}
$licenseKey = $request->get_param('license_key');
$domain = $request->get_param('domain');
$license = $this->licenseManager->getLicenseByKey($licenseKey);
if (!$license) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_not_found',
'message' => __('License key not found.', 'wc-licensed-product'),
], 404);
}
if (!$license->isValid()) {
return new WP_REST_Response([
'success' => false,
'error' => 'license_invalid',
'message' => __('This license is not valid.', 'wc-licensed-product'),
], 403);
}
$normalizedDomain = $this->licenseManager->normalizeDomain($domain);
// Check if already activated on this domain
if ($license->getDomain() === $normalizedDomain) {
return new WP_REST_Response([
'success' => true,
'message' => __('License is already activated for this domain.', 'wc-licensed-product'),
]);
}
// Check if can activate on another domain
if (!$license->canActivate()) {
return new WP_REST_Response([
'success' => false,
'error' => 'max_activations_reached',
'message' => __('Maximum number of activations reached.', 'wc-licensed-product'),
], 403);
}
// Update domain (in this simple implementation, we replace the domain)
$success = $this->licenseManager->updateLicenseDomain($license->getId(), $domain);
if (!$success) {
return new WP_REST_Response([
'success' => false,
'error' => 'activation_failed',
'message' => __('Failed to activate license.', 'wc-licensed-product'),
], 500);
}
return new WP_REST_Response([
'success' => true,
'message' => __('License activated successfully.', 'wc-licensed-product'),
]);
}
}