You've already forked wc-licensed-product-client
Implement /update-check endpoint aligned with remote OpenAPI spec: - Add checkForUpdates() method to LicenseClientInterface - Add UpdateInfo DTO for update check responses - Add ProductNotFoundException for product_not_found error - Update local openapi.json to v0.4.0 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1089 lines
31 KiB
Markdown
1089 lines
31 KiB
Markdown
# Client-Side Implementation Guide
|
|
|
|
This document provides a comprehensive guide for integrating the WooCommerce Licensed Product Client into existing PHP projects. It covers architecture, classes, configuration, and best practices.
|
|
|
|
## Overview
|
|
|
|
The `magdev/wc-licensed-product-client` library provides a complete PHP client for interacting with the WooCommerce Licensed Product REST API. It supports:
|
|
|
|
- License validation, status checking, and activation
|
|
- Response signature verification (HMAC-SHA256)
|
|
- PSR-3 logging integration
|
|
- PSR-6 caching integration
|
|
- Comprehensive exception handling
|
|
- Optional code integrity verification
|
|
|
|
## Requirements
|
|
|
|
- **PHP:** 8.3 or higher
|
|
- **Dependencies:**
|
|
- `symfony/http-client` ^7.0
|
|
- `psr/log` ^3.0
|
|
- `psr/cache` ^3.0
|
|
- `psr/http-client` ^1.0
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
composer require magdev/wc-licensed-product-client
|
|
```
|
|
|
|
## Architecture
|
|
|
|
### Class Diagram
|
|
|
|
```text
|
|
┌─────────────────────────────┐
|
|
│ LicenseClientInterface │
|
|
│ (Public API Contract) │
|
|
├─────────────────────────────┤
|
|
│ + validate() │
|
|
│ + status() │
|
|
│ + activate() │
|
|
│ + checkForUpdates() │
|
|
└─────────────┬───────────────┘
|
|
│ implements
|
|
├───────────────────────┐
|
|
▼ ▼
|
|
┌─────────────────────┐ ┌──────────────────────┐
|
|
│ LicenseClient │ │ SecureLicenseClient │
|
|
│ (Basic Client) │ │ (With Signatures) │
|
|
└─────────────────────┘ └──────────────────────┘
|
|
│ │
|
|
└───────────┬───────────┘
|
|
▼
|
|
┌─────────────────────┐
|
|
│ DTOs │
|
|
├─────────────────────┤
|
|
│ LicenseInfo │
|
|
│ LicenseStatus │
|
|
│ ActivationResult │
|
|
│ UpdateInfo │
|
|
│ LicenseState (enum) │
|
|
└─────────────────────┘
|
|
```
|
|
|
|
### Directory Structure
|
|
|
|
```text
|
|
src/
|
|
├── LicenseClientInterface.php # Public API contract
|
|
├── LicenseClient.php # Basic implementation
|
|
├── SecureLicenseClient.php # Secure implementation with signatures
|
|
├── Dto/
|
|
│ ├── LicenseInfo.php # Validation response DTO
|
|
│ ├── LicenseStatus.php # Status response DTO + LicenseState enum
|
|
│ ├── ActivationResult.php # Activation response DTO
|
|
│ └── UpdateInfo.php # Update check response DTO
|
|
├── Exception/
|
|
│ ├── LicenseException.php # Base exception
|
|
│ ├── LicenseNotFoundException.php
|
|
│ ├── LicenseExpiredException.php
|
|
│ ├── LicenseRevokedException.php
|
|
│ ├── LicenseInactiveException.php
|
|
│ ├── LicenseInvalidException.php
|
|
│ ├── DomainMismatchException.php
|
|
│ ├── MaxActivationsReachedException.php
|
|
│ ├── ActivationFailedException.php
|
|
│ ├── ProductNotFoundException.php
|
|
│ └── RateLimitExceededException.php
|
|
└── Security/
|
|
├── ResponseSignature.php # HMAC signature verification
|
|
├── SignatureException.php # Signature verification error
|
|
├── StringEncoder.php # XOR-based string obfuscation
|
|
├── IntegrityChecker.php # Source file hash verification
|
|
└── IntegrityException.php # Integrity check failure
|
|
```
|
|
|
|
## Client Classes
|
|
|
|
### LicenseClientInterface
|
|
|
|
The public API contract that both client implementations follow:
|
|
|
|
```php
|
|
interface LicenseClientInterface
|
|
{
|
|
/**
|
|
* Validate a license key for a specific domain.
|
|
*
|
|
* @throws LicenseException When validation fails
|
|
*/
|
|
public function validate(string $licenseKey, string $domain): LicenseInfo;
|
|
|
|
/**
|
|
* Get detailed status information for a license key.
|
|
*
|
|
* @throws LicenseException When status check fails
|
|
*/
|
|
public function status(string $licenseKey): LicenseStatus;
|
|
|
|
/**
|
|
* Activate a license on a specific domain.
|
|
*
|
|
* @throws LicenseException When activation fails
|
|
*/
|
|
public function activate(string $licenseKey, string $domain): ActivationResult;
|
|
|
|
/**
|
|
* Check for available plugin updates.
|
|
*
|
|
* @throws LicenseException When update check fails
|
|
*/
|
|
public function checkForUpdates(
|
|
string $licenseKey,
|
|
string $domain,
|
|
?string $pluginSlug = null,
|
|
?string $currentVersion = null,
|
|
): UpdateInfo;
|
|
}
|
|
```
|
|
|
|
### LicenseClient (Basic)
|
|
|
|
The basic client without signature verification. Use this when:
|
|
|
|
- The server does not support response signing
|
|
- You're in a development/testing environment
|
|
- Security is handled at another layer
|
|
|
|
**Constructor Parameters:**
|
|
|
|
| Parameter | Type | Required | Default | Description |
|
|
| --------- | ---- | -------- | ------- | ----------- |
|
|
| `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance |
|
|
| `baseUrl` | `string` | Yes | - | WordPress site URL (e.g., `https://example.com`) |
|
|
| `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger for debugging |
|
|
| `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache for responses |
|
|
| `cacheTtl` | `int` | No | `300` | Cache TTL in seconds (5 minutes) |
|
|
|
|
**Example:**
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\LicenseClient;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
|
|
$client = new LicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: 'https://your-wordpress-site.com',
|
|
);
|
|
```
|
|
|
|
### SecureLicenseClient (Recommended)
|
|
|
|
The secure client with response signature verification. Use this for production environments.
|
|
|
|
**Constructor Parameters:**
|
|
|
|
| Parameter | Type | Required | Default | Description |
|
|
| --------- | ---- | -------- | ------- | ----------- |
|
|
| `httpClient` | `HttpClientInterface` | Yes | - | Symfony HTTP client instance |
|
|
| `baseUrl` | `string` | Yes | - | WordPress site URL |
|
|
| `serverSecret` | `string` | Yes | - | Shared secret (same as server) |
|
|
| `logger` | `LoggerInterface\|null` | No | `NullLogger` | PSR-3 logger |
|
|
| `cache` | `CacheItemPoolInterface\|null` | No | `null` | PSR-6 cache |
|
|
| `cacheTtl` | `int` | No | `300` | Cache TTL in seconds |
|
|
| `verifyIntegrity` | `bool` | No | `false` | Enable source file integrity checks |
|
|
| `encoder` | `StringEncoder\|null` | No | Default instance | Custom string encoder |
|
|
|
|
**Example:**
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
|
|
$client = new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: 'https://your-wordpress-site.com',
|
|
serverSecret: 'your-shared-server-secret-min-32-chars',
|
|
);
|
|
```
|
|
|
|
**Security Features:**
|
|
|
|
1. **Response Signature Verification**: Every response is verified using HMAC-SHA256
|
|
2. **Timestamp Validation**: Prevents replay attacks (5-minute tolerance)
|
|
3. **Per-License Key Derivation**: Each license gets a unique signing key
|
|
4. **Obfuscated API Paths**: API endpoint paths are XOR-encoded in source code
|
|
5. **Optional Integrity Checking**: Verify critical source files haven't been modified
|
|
|
|
## Data Transfer Objects (DTOs)
|
|
|
|
### LicenseInfo
|
|
|
|
Returned by `validate()`. Contains license details for a valid license.
|
|
|
|
```php
|
|
final readonly class LicenseInfo
|
|
{
|
|
public function __construct(
|
|
public int $productId, // WooCommerce product ID
|
|
public ?\DateTimeImmutable $expiresAt, // null = lifetime license
|
|
public ?int $versionId, // Product version (if applicable)
|
|
) {}
|
|
|
|
public static function fromArray(array $data): self;
|
|
|
|
public function isLifetime(): bool; // Returns true if no expiration
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```php
|
|
$info = $client->validate('ABCD-1234-EFGH-5678', 'example.com');
|
|
|
|
echo "Product ID: " . $info->productId;
|
|
echo "Lifetime: " . ($info->isLifetime() ? 'Yes' : 'No');
|
|
|
|
if (!$info->isLifetime()) {
|
|
echo "Expires: " . $info->expiresAt->format('Y-m-d');
|
|
}
|
|
```
|
|
|
|
### LicenseStatus
|
|
|
|
Returned by `status()`. Contains comprehensive license information.
|
|
|
|
```php
|
|
final readonly class LicenseStatus
|
|
{
|
|
public function __construct(
|
|
public bool $valid, // Is license currently valid
|
|
public LicenseState $status, // Status enum value
|
|
public string $domain, // Bound domain
|
|
public ?\DateTimeImmutable $expiresAt, // Expiration date
|
|
public int $activationsCount, // Current activations
|
|
public int $maxActivations, // Maximum allowed
|
|
) {}
|
|
|
|
public static function fromArray(array $data): self;
|
|
|
|
public function isLifetime(): bool;
|
|
|
|
public function hasAvailableActivations(): bool;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```php
|
|
$status = $client->status('ABCD-1234-EFGH-5678');
|
|
|
|
echo "Valid: " . ($status->valid ? 'Yes' : 'No');
|
|
echo "Status: " . $status->status->value; // active, inactive, expired, revoked
|
|
echo "Domain: " . $status->domain;
|
|
echo "Activations: " . $status->activationsCount . "/" . $status->maxActivations;
|
|
|
|
if ($status->hasAvailableActivations()) {
|
|
echo "More activations available!";
|
|
}
|
|
```
|
|
|
|
### LicenseState (Enum)
|
|
|
|
Represents possible license states:
|
|
|
|
```php
|
|
enum LicenseState: string
|
|
{
|
|
case Active = 'active'; // License is active and valid
|
|
case Inactive = 'inactive'; // License exists but not activated
|
|
case Expired = 'expired'; // License has passed expiration date
|
|
case Revoked = 'revoked'; // License was manually revoked
|
|
}
|
|
```
|
|
|
|
### ActivationResult
|
|
|
|
Returned by `activate()`. Contains activation outcome.
|
|
|
|
```php
|
|
final readonly class ActivationResult
|
|
{
|
|
public function __construct(
|
|
public bool $success, // Whether activation succeeded
|
|
public string $message, // Human-readable message
|
|
) {}
|
|
|
|
public static function fromArray(array $data): self;
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```php
|
|
$result = $client->activate('ABCD-1234-EFGH-5678', 'newdomain.com');
|
|
|
|
if ($result->success) {
|
|
echo "Activated: " . $result->message;
|
|
} else {
|
|
echo "Failed: " . $result->message;
|
|
}
|
|
```
|
|
|
|
### UpdateInfo
|
|
|
|
Returned by `checkForUpdates()`. Contains plugin update information.
|
|
|
|
```php
|
|
final readonly class UpdateInfo
|
|
{
|
|
public function __construct(
|
|
public bool $updateAvailable, // Whether an update is available
|
|
public ?string $version, // Latest available version
|
|
public ?string $slug, // Plugin slug for WordPress
|
|
public ?string $plugin, // Plugin basename (slug/slug.php)
|
|
public ?string $downloadUrl, // Secure download URL
|
|
public ?\DateTimeImmutable $lastUpdated,// Date of the latest release
|
|
public ?string $tested, // Highest WordPress version tested
|
|
public ?string $requires, // Minimum WordPress version
|
|
public ?string $requiresPhp, // Minimum PHP version
|
|
public ?string $changelog, // Release notes
|
|
public ?string $packageHash, // SHA256 hash for integrity
|
|
public ?string $name, // Product name
|
|
public ?string $homepage, // Product homepage URL
|
|
public ?array $icons, // Plugin icons for WordPress admin
|
|
public ?array $sections, // Content sections for plugin info
|
|
) {}
|
|
|
|
public static function fromArray(array $data): self;
|
|
|
|
public function hasValidPackageHash(): bool; // Check package hash format
|
|
}
|
|
```
|
|
|
|
**Usage:**
|
|
|
|
```php
|
|
$updateInfo = $client->checkForUpdates(
|
|
'ABCD-1234-EFGH-5678',
|
|
'example.com',
|
|
'my-plugin',
|
|
'1.0.0',
|
|
);
|
|
|
|
if ($updateInfo->updateAvailable) {
|
|
echo "New version available: " . $updateInfo->version;
|
|
echo "Download URL: " . $updateInfo->downloadUrl;
|
|
|
|
if ($updateInfo->hasValidPackageHash()) {
|
|
echo "Package hash: " . $updateInfo->packageHash;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Exception Hierarchy
|
|
|
|
All exceptions extend `LicenseException` and include an `errorCode` property for programmatic handling.
|
|
|
|
```text
|
|
LicenseException (base)
|
|
├── LicenseNotFoundException // error: license_not_found
|
|
├── LicenseExpiredException // error: license_expired
|
|
├── LicenseRevokedException // error: license_revoked
|
|
├── LicenseInactiveException // error: license_inactive
|
|
├── LicenseInvalidException // error: license_invalid
|
|
├── DomainMismatchException // error: domain_mismatch
|
|
├── MaxActivationsReachedException // error: max_activations_reached
|
|
├── ActivationFailedException // error: activation_failed
|
|
├── ProductNotFoundException // error: product_not_found
|
|
├── RateLimitExceededException // error: rate_limit_exceeded (has retryAfter)
|
|
├── Security\SignatureException // error: signature_invalid
|
|
└── Security\IntegrityException // error: integrity_check_failed
|
|
```
|
|
|
|
### Exception Properties
|
|
|
|
**LicenseException (Base):**
|
|
|
|
```php
|
|
class LicenseException extends \RuntimeException
|
|
{
|
|
public readonly ?string $errorCode; // API error code for programmatic handling
|
|
}
|
|
```
|
|
|
|
**RateLimitExceededException:**
|
|
|
|
```php
|
|
final class RateLimitExceededException extends LicenseException
|
|
{
|
|
public readonly ?int $retryAfter; // Seconds until rate limit resets
|
|
}
|
|
```
|
|
|
|
**IntegrityException:**
|
|
|
|
```php
|
|
final class IntegrityException extends LicenseException
|
|
{
|
|
public readonly array $failures; // List of failed integrity checks
|
|
}
|
|
```
|
|
|
|
### Catching Exceptions
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\Exception\{
|
|
LicenseException,
|
|
LicenseNotFoundException,
|
|
LicenseExpiredException,
|
|
DomainMismatchException,
|
|
RateLimitExceededException,
|
|
};
|
|
use Magdev\WcLicensedProductClient\Security\SignatureException;
|
|
|
|
try {
|
|
$info = $client->validate($licenseKey, $domain);
|
|
} catch (SignatureException $e) {
|
|
// Response was tampered with or server secret mismatch
|
|
error_log("SECURITY: Response signature invalid");
|
|
die("License verification failed. Please contact support.");
|
|
} catch (LicenseNotFoundException $e) {
|
|
echo "License key not found.";
|
|
} catch (LicenseExpiredException $e) {
|
|
echo "Your license has expired. Please renew.";
|
|
} catch (DomainMismatchException $e) {
|
|
echo "This license is not valid for this domain.";
|
|
} catch (RateLimitExceededException $e) {
|
|
echo "Too many requests. Try again in {$e->retryAfter} seconds.";
|
|
} catch (LicenseException $e) {
|
|
// Catch-all for any license error
|
|
echo "License error: " . $e->getMessage();
|
|
error_log("License error [{$e->errorCode}]: " . $e->getMessage());
|
|
}
|
|
```
|
|
|
|
## Security Classes
|
|
|
|
### ResponseSignature
|
|
|
|
Verifies HMAC-SHA256 signatures on API responses to prevent tampering.
|
|
|
|
**Key Derivation:**
|
|
|
|
Each license key derives a unique signing key from the server secret:
|
|
|
|
```php
|
|
// HKDF-like key derivation (internal implementation)
|
|
$prk = hash_hmac('sha256', $licenseKey, $serverSecret, true);
|
|
$derivedKey = hash_hmac('sha256', $prk . "\x01", $serverSecret);
|
|
```
|
|
|
|
**Signature Format:**
|
|
|
|
```text
|
|
signature = HMAC-SHA256(
|
|
key = derived_key,
|
|
message = timestamp + ":" + canonical_json(response_body)
|
|
)
|
|
```
|
|
|
|
Where:
|
|
|
|
- `canonical_json` sorts keys alphabetically
|
|
- JSON uses `JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE` flags
|
|
- Result is hex-encoded (64 characters)
|
|
|
|
**Response Headers:**
|
|
|
|
| Header | Description | Example |
|
|
| ------ | ----------- | ------- |
|
|
| `X-License-Signature` | HMAC-SHA256 signature (hex) | `a1b2c3...` (64 chars) |
|
|
| `X-License-Timestamp` | Unix timestamp when signed | `1706000000` |
|
|
|
|
**Custom Timestamp Tolerance:**
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\Security\ResponseSignature;
|
|
|
|
// Default is 300 seconds (5 minutes)
|
|
$signature = new ResponseSignature($secretKey, timestampTolerance: 600); // 10 minutes
|
|
```
|
|
|
|
### StringEncoder
|
|
|
|
Simple XOR-based string obfuscation for hiding API paths in source code.
|
|
|
|
**WARNING:** This is NOT encryption. It only prevents casual inspection.
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\Security\StringEncoder;
|
|
|
|
$encoder = new StringEncoder();
|
|
|
|
// Encode strings for embedding in source code
|
|
$encoded = $encoder->encode('/wp-json/wc-licensed-product/v1');
|
|
// Result: "MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE="
|
|
|
|
// Decode at runtime
|
|
$decoded = $encoder->decode('MR4xBi8rPDUcHhk2EQ0TICsvMBo9Mw0HJRE=');
|
|
// Result: "/wp-json/wc-licensed-product/v1"
|
|
|
|
// Generate multiple encoded constants
|
|
$constants = $encoder->generateEncodedConstants([
|
|
'API_PATH' => '/wp-json/wc-licensed-product/v1',
|
|
'VALIDATE' => 'validate',
|
|
'STATUS' => 'status',
|
|
]);
|
|
```
|
|
|
|
**Custom Encoder Key:**
|
|
|
|
```php
|
|
$encoder = new StringEncoder('my_custom_encoder_key');
|
|
```
|
|
|
|
### IntegrityChecker
|
|
|
|
Verifies that critical source files haven't been modified by comparing SHA256 hashes.
|
|
|
|
```php
|
|
use Magdev\WcLicensedProductClient\Security\IntegrityChecker;
|
|
|
|
// Define expected hashes (generated during build)
|
|
$expectedHashes = [
|
|
'src/SecureLicenseClient.php' => 'abc123...',
|
|
'src/Security/ResponseSignature.php' => 'def456...',
|
|
];
|
|
|
|
$checker = new IntegrityChecker($expectedHashes, '/path/to/project');
|
|
|
|
// Verify all files
|
|
try {
|
|
$checker->verify();
|
|
echo "All files intact";
|
|
} catch (IntegrityException $e) {
|
|
echo "Tampered files: " . implode(', ', $e->failures);
|
|
}
|
|
|
|
// Or check silently
|
|
if (!$checker->isValid()) {
|
|
die("Critical files have been modified!");
|
|
}
|
|
```
|
|
|
|
**Generating Hashes During Build:**
|
|
|
|
```php
|
|
$checker = new IntegrityChecker([], '/path/to/project');
|
|
|
|
$hashes = $checker->generateHashes([
|
|
'src/SecureLicenseClient.php',
|
|
'src/Security/ResponseSignature.php',
|
|
'src/Security/SignatureException.php',
|
|
]);
|
|
|
|
// Output as PHP code for embedding
|
|
echo $checker->generateHashesAsPhpCode([
|
|
'src/SecureLicenseClient.php',
|
|
'src/Security/ResponseSignature.php',
|
|
]);
|
|
```
|
|
|
|
## Caching
|
|
|
|
Both clients support PSR-6 caching to reduce API calls. Cache keys use SHA256 hashes of license keys to avoid exposing sensitive data.
|
|
|
|
**Cache Key Format:**
|
|
|
|
```text
|
|
wc_license_{operation}_{sha256(license_key)}[_{sha256(domain)}]
|
|
```
|
|
|
|
**Example with Symfony Cache:**
|
|
|
|
```php
|
|
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
|
|
|
$cache = new FilesystemAdapter(
|
|
namespace: 'license_client',
|
|
defaultLifetime: 300,
|
|
directory: '/tmp/license-cache'
|
|
);
|
|
|
|
$client = new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: 'https://example.com',
|
|
serverSecret: 'your-secret',
|
|
cache: $cache,
|
|
cacheTtl: 600, // 10 minutes
|
|
);
|
|
```
|
|
|
|
**Cache Behavior:**
|
|
|
|
- `validate()` and `status()` responses are cached
|
|
- `activate()` responses are NOT cached
|
|
- After `activate()`, related cache entries are automatically invalidated
|
|
|
|
## Logging
|
|
|
|
Both clients support PSR-3 logging for debugging and monitoring.
|
|
|
|
**Log Levels Used:**
|
|
|
|
| Level | When Used |
|
|
| ----- | --------- |
|
|
| `debug` | Cache hits, signature verification success |
|
|
| `info` | API calls, successful operations |
|
|
| `warning` | API error responses, missing signatures |
|
|
| `error` | Request failures, network errors |
|
|
| `critical` | Integrity check failures |
|
|
|
|
**Example with Monolog:**
|
|
|
|
```php
|
|
use Monolog\Logger;
|
|
use Monolog\Handler\StreamHandler;
|
|
|
|
$logger = new Logger('license');
|
|
$logger->pushHandler(new StreamHandler('/var/log/license.log', Logger::DEBUG));
|
|
|
|
$client = new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: 'https://example.com',
|
|
serverSecret: 'your-secret',
|
|
logger: $logger,
|
|
);
|
|
```
|
|
|
|
## Integration Guide
|
|
|
|
### Basic Integration
|
|
|
|
The simplest integration for existing PHP projects:
|
|
|
|
```php
|
|
<?php
|
|
// license-check.php
|
|
|
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
|
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
|
|
// Configuration (load from environment/config file)
|
|
$licenseKey = getenv('MY_PRODUCT_LICENSE_KEY');
|
|
$serverSecret = getenv('LICENSE_SERVER_SECRET');
|
|
$licenseServer = 'https://your-license-server.com';
|
|
$currentDomain = $_SERVER['HTTP_HOST'] ?? php_uname('n');
|
|
|
|
// Create client
|
|
$client = new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: $licenseServer,
|
|
serverSecret: $serverSecret,
|
|
);
|
|
|
|
// Validate license
|
|
try {
|
|
$info = $client->validate($licenseKey, $currentDomain);
|
|
|
|
// Define constant for use throughout application
|
|
define('MY_PRODUCT_LICENSED', true);
|
|
define('MY_PRODUCT_ID', $info->productId);
|
|
|
|
} catch (LicenseException $e) {
|
|
define('MY_PRODUCT_LICENSED', false);
|
|
define('MY_PRODUCT_ID', null);
|
|
|
|
// Log the error but don't expose details to users
|
|
error_log("License validation failed: " . $e->getMessage());
|
|
}
|
|
```
|
|
|
|
### WordPress Plugin Integration
|
|
|
|
```php
|
|
<?php
|
|
/**
|
|
* Plugin Name: My Licensed Plugin
|
|
*/
|
|
|
|
namespace MyPlugin;
|
|
|
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
|
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
|
|
class LicenseManager
|
|
{
|
|
private const LICENSE_OPTION = 'my_plugin_license_key';
|
|
private const SERVER_URL = 'https://your-license-server.com';
|
|
private const SERVER_SECRET = 'your-server-secret'; // Or load from wp-config.php
|
|
|
|
private ?SecureLicenseClient $client = null;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->client = new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: self::SERVER_URL,
|
|
serverSecret: self::SERVER_SECRET,
|
|
);
|
|
}
|
|
|
|
public function isLicenseValid(): bool
|
|
{
|
|
$licenseKey = get_option(self::LICENSE_OPTION);
|
|
|
|
if (empty($licenseKey)) {
|
|
return false;
|
|
}
|
|
|
|
// Use transient for caching
|
|
$cacheKey = 'my_plugin_license_valid_' . md5($licenseKey);
|
|
$cached = get_transient($cacheKey);
|
|
|
|
if ($cached !== false) {
|
|
return $cached === 'valid';
|
|
}
|
|
|
|
try {
|
|
$domain = wp_parse_url(home_url(), PHP_URL_HOST);
|
|
$this->client->validate($licenseKey, $domain);
|
|
|
|
set_transient($cacheKey, 'valid', HOUR_IN_SECONDS);
|
|
return true;
|
|
|
|
} catch (LicenseException $e) {
|
|
set_transient($cacheKey, 'invalid', 5 * MINUTE_IN_SECONDS);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function activateLicense(string $licenseKey): array
|
|
{
|
|
try {
|
|
$domain = wp_parse_url(home_url(), PHP_URL_HOST);
|
|
$result = $this->client->activate($licenseKey, $domain);
|
|
|
|
if ($result->success) {
|
|
update_option(self::LICENSE_OPTION, $licenseKey);
|
|
delete_transient('my_plugin_license_valid_' . md5($licenseKey));
|
|
}
|
|
|
|
return [
|
|
'success' => $result->success,
|
|
'message' => $result->message,
|
|
];
|
|
|
|
} catch (LicenseException $e) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Laravel Integration
|
|
|
|
**Service Provider:**
|
|
|
|
```php
|
|
<?php
|
|
// app/Providers/LicenseServiceProvider.php
|
|
|
|
namespace App\Providers;
|
|
|
|
use Illuminate\Support\ServiceProvider;
|
|
use Magdev\WcLicensedProductClient\LicenseClientInterface;
|
|
use Magdev\WcLicensedProductClient\SecureLicenseClient;
|
|
use Symfony\Component\HttpClient\HttpClient;
|
|
use Illuminate\Log\Logger;
|
|
use Illuminate\Cache\CacheManager;
|
|
|
|
class LicenseServiceProvider extends ServiceProvider
|
|
{
|
|
public function register(): void
|
|
{
|
|
$this->app->singleton(LicenseClientInterface::class, function ($app) {
|
|
return new SecureLicenseClient(
|
|
httpClient: HttpClient::create(),
|
|
baseUrl: config('services.license.url'),
|
|
serverSecret: config('services.license.secret'),
|
|
logger: $app->make(Logger::class),
|
|
cache: $app->make(CacheManager::class)->store('file')->getStore(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
**Configuration (config/services.php):**
|
|
|
|
```php
|
|
return [
|
|
// ...
|
|
'license' => [
|
|
'url' => env('LICENSE_SERVER_URL'),
|
|
'secret' => env('LICENSE_SERVER_SECRET'),
|
|
],
|
|
];
|
|
```
|
|
|
|
**Usage in Controller:**
|
|
|
|
```php
|
|
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use Magdev\WcLicensedProductClient\LicenseClientInterface;
|
|
use Magdev\WcLicensedProductClient\Exception\LicenseException;
|
|
|
|
class LicenseController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly LicenseClientInterface $licenseClient,
|
|
) {}
|
|
|
|
public function validate(Request $request)
|
|
{
|
|
try {
|
|
$info = $this->licenseClient->validate(
|
|
$request->input('license_key'),
|
|
$request->getHost(),
|
|
);
|
|
|
|
return response()->json([
|
|
'valid' => true,
|
|
'product_id' => $info->productId,
|
|
'expires_at' => $info->expiresAt?->format('Y-m-d'),
|
|
]);
|
|
|
|
} catch (LicenseException $e) {
|
|
return response()->json([
|
|
'valid' => false,
|
|
'error' => $e->errorCode,
|
|
'message' => $e->getMessage(),
|
|
], 403);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Symfony Integration
|
|
|
|
**Service Configuration (config/services.yaml):**
|
|
|
|
```yaml
|
|
services:
|
|
Magdev\WcLicensedProductClient\LicenseClientInterface:
|
|
class: Magdev\WcLicensedProductClient\SecureLicenseClient
|
|
arguments:
|
|
$httpClient: '@Symfony\Contracts\HttpClient\HttpClientInterface'
|
|
$baseUrl: '%env(LICENSE_SERVER_URL)%'
|
|
$serverSecret: '%env(LICENSE_SERVER_SECRET)%'
|
|
$logger: '@logger'
|
|
$cache: '@cache.app'
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### 1. Use the Secure Client in Production
|
|
|
|
Always use `SecureLicenseClient` in production to verify response signatures:
|
|
|
|
```php
|
|
// Development
|
|
$client = new LicenseClient(...);
|
|
|
|
// Production
|
|
$client = new SecureLicenseClient(..., serverSecret: $secret);
|
|
```
|
|
|
|
### 2. Handle All Exception Types
|
|
|
|
Don't catch just the base exception. Handle specific cases:
|
|
|
|
```php
|
|
try {
|
|
$client->validate($key, $domain);
|
|
} catch (SignatureException $e) {
|
|
// Critical: possible tampering
|
|
notifyAdmin("Signature verification failed!");
|
|
die("Security error");
|
|
} catch (RateLimitExceededException $e) {
|
|
// Retry later
|
|
sleep($e->retryAfter ?? 60);
|
|
retry();
|
|
} catch (LicenseExpiredException $e) {
|
|
// Prompt renewal
|
|
showRenewalForm();
|
|
} catch (LicenseException $e) {
|
|
// Generic handling
|
|
showError($e->getMessage());
|
|
}
|
|
```
|
|
|
|
### 3. Cache Validation Results
|
|
|
|
Use caching to minimize API calls:
|
|
|
|
```php
|
|
$cache = new FilesystemAdapter('licenses', 300);
|
|
$client = new SecureLicenseClient(..., cache: $cache, cacheTtl: 300);
|
|
```
|
|
|
|
### 4. Store Secrets Securely
|
|
|
|
Never hardcode secrets in source code:
|
|
|
|
```php
|
|
// Bad
|
|
$client = new SecureLicenseClient(..., serverSecret: 'abc123');
|
|
|
|
// Good
|
|
$client = new SecureLicenseClient(..., serverSecret: getenv('LICENSE_SECRET'));
|
|
```
|
|
|
|
### 5. Validate on Application Startup
|
|
|
|
Check license validity early, not on every request:
|
|
|
|
```php
|
|
// bootstrap.php
|
|
$isLicensed = checkLicense();
|
|
define('APP_LICENSED', $isLicensed);
|
|
|
|
// Later in code
|
|
if (APP_LICENSED) {
|
|
showPremiumFeatures();
|
|
}
|
|
```
|
|
|
|
### 6. Log License Events
|
|
|
|
Enable logging for debugging and audit trails:
|
|
|
|
```php
|
|
$client = new SecureLicenseClient(
|
|
...,
|
|
logger: new Logger('license'),
|
|
);
|
|
```
|
|
|
|
### 7. Implement Graceful Degradation
|
|
|
|
Don't crash the entire application on license failure:
|
|
|
|
```php
|
|
try {
|
|
$info = $client->validate($key, $domain);
|
|
enablePremiumFeatures($info);
|
|
} catch (LicenseException $e) {
|
|
// Fall back to free tier
|
|
enableFreeFeatures();
|
|
logLicenseIssue($e);
|
|
}
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### API Endpoints
|
|
|
|
The client communicates with these REST API endpoints:
|
|
|
|
| Endpoint | Method | Description |
|
|
| -------- | ------ | ----------- |
|
|
| `/wp-json/wc-licensed-product/v1/validate` | POST | Validate license for domain |
|
|
| `/wp-json/wc-licensed-product/v1/status` | POST | Get license status details |
|
|
| `/wp-json/wc-licensed-product/v1/activate` | POST | Activate license on domain |
|
|
| `/wp-json/wc-licensed-product/v1/update-check` | POST | Check for plugin updates |
|
|
|
|
### Request Format
|
|
|
|
All requests use JSON bodies:
|
|
|
|
```json
|
|
{
|
|
"license_key": "ABCD-1234-EFGH-5678",
|
|
"domain": "example.com"
|
|
}
|
|
```
|
|
|
|
### Response Format
|
|
|
|
**Successful Validation:**
|
|
|
|
```json
|
|
{
|
|
"valid": true,
|
|
"license": {
|
|
"product_id": 123,
|
|
"expires_at": "2027-01-21",
|
|
"version_id": null
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error Response:**
|
|
|
|
```json
|
|
{
|
|
"valid": false,
|
|
"error": "license_expired",
|
|
"message": "This license has expired."
|
|
}
|
|
```
|
|
|
|
### Error Codes
|
|
|
|
| Error Code | Exception Class | Description |
|
|
| ---------- | --------------- | ----------- |
|
|
| `license_not_found` | `LicenseNotFoundException` | License key doesn't exist |
|
|
| `license_expired` | `LicenseExpiredException` | License past expiration date |
|
|
| `license_revoked` | `LicenseRevokedException` | License manually revoked |
|
|
| `license_inactive` | `LicenseInactiveException` | License not activated |
|
|
| `license_invalid` | `LicenseInvalidException` | License invalid for operation |
|
|
| `domain_mismatch` | `DomainMismatchException` | Domain not authorized |
|
|
| `max_activations_reached` | `MaxActivationsReachedException` | Too many activations |
|
|
| `activation_failed` | `ActivationFailedException` | Server error during activation |
|
|
| `product_not_found` | `ProductNotFoundException` | Licensed product doesn't exist |
|
|
| `rate_limit_exceeded` | `RateLimitExceededException` | Too many requests |
|
|
| `signature_invalid` | `SignatureException` | Response signature invalid |
|
|
| `integrity_check_failed` | `IntegrityException` | Source files modified |
|
|
|
|
## Troubleshooting
|
|
|
|
### "Response is not signed by the server"
|
|
|
|
- Server not configured with `WC_LICENSE_SERVER_SECRET`
|
|
- Using `SecureLicenseClient` with non-signing server
|
|
- **Solution:** Configure server secret or use `LicenseClient`
|
|
|
|
### "Response signature verification failed"
|
|
|
|
- Different secrets on server/client
|
|
- Clock skew > 5 minutes between server and client
|
|
- Response modified by proxy/cache
|
|
- **Solution:** Verify secrets match, sync clocks, disable response caching
|
|
|
|
### Connection Timeouts
|
|
|
|
- Network issues between client and license server
|
|
- **Solution:** Configure HTTP client timeout
|
|
|
|
```php
|
|
$httpClient = HttpClient::create([
|
|
'timeout' => 30,
|
|
'max_duration' => 60,
|
|
]);
|
|
```
|
|
|
|
### Rate Limiting
|
|
|
|
- Too many requests from same IP
|
|
- **Solution:** Implement caching, handle `RateLimitExceededException`
|
|
|
|
```php
|
|
catch (RateLimitExceededException $e) {
|
|
$waitSeconds = $e->retryAfter ?? 60;
|
|
// Implement exponential backoff
|
|
}
|
|
```
|