New updates
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
final class LicenseIssuer
|
||||
{
|
||||
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
|
||||
{
|
||||
return DB::transaction(function () use ($product,$name,$email,$provider,$orderID,$customerID,$queueDelivery) {
|
||||
if ($provider && $orderID) {
|
||||
$existing = License::where('payment_provider',$provider)->where('external_order_id',$orderID)->first();
|
||||
if ($existing) return [$existing, null];
|
||||
}
|
||||
$plain = LicenseKey::generate();
|
||||
$license = License::create([
|
||||
'license_product_id'=>$product->id,
|
||||
'license_key_hash'=>LicenseKey::hash($plain),
|
||||
'license_key_last4'=>substr(LicenseKey::normalize($plain), -4),
|
||||
'customer_name'=>$name,
|
||||
'customer_email'=>$email ? strtolower(trim($email)) : null,
|
||||
'edition'=>$product->edition,
|
||||
'activation_limit'=>$product->activation_limit,
|
||||
'max_major_version'=>$product->max_major_version,
|
||||
'payment_provider'=>$provider,
|
||||
'external_order_id'=>$orderID,
|
||||
'external_customer_id'=>$customerID,
|
||||
]);
|
||||
if ($queueDelivery) {
|
||||
LicenseDeliveryOutbox::updateOrCreate(
|
||||
['license_id'=>$license->id],
|
||||
['encrypted_license_key'=>Crypt::encryptString($plain),'recipient_email'=>$license->customer_email,'delivered_at'=>null]
|
||||
);
|
||||
}
|
||||
return [$license, $plain];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
final class LicenseKey
|
||||
{
|
||||
public static function generate(): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
$raw = '';
|
||||
for ($i=0; $i<20; $i++) $raw .= $alphabet[random_int(0, strlen($alphabet)-1)];
|
||||
return implode('-', str_split($raw, 5));
|
||||
}
|
||||
|
||||
public static function normalize(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
|
||||
}
|
||||
|
||||
public static function hash(string $value): string
|
||||
{
|
||||
return hash('sha256', self::normalize($value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use App\Models\License;
|
||||
use Carbon\CarbonInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseSigner
|
||||
{
|
||||
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
|
||||
{
|
||||
$seed = $this->privateSeed();
|
||||
$pair = sodium_crypto_sign_seed_keypair($seed);
|
||||
$secret = sodium_crypto_sign_secretkey($pair);
|
||||
|
||||
$payload = [
|
||||
'customerEmail' => $license->customer_email,
|
||||
'customerName' => $license->customer_name,
|
||||
'edition' => $license->edition,
|
||||
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'installationID' => strtolower($installationID),
|
||||
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'licenseID' => strtolower((string) $license->id),
|
||||
'maxMajorVersion' => $license->max_major_version,
|
||||
];
|
||||
ksort($payload, SORT_STRING);
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$sig = sodium_crypto_sign_detached($json, $secret);
|
||||
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
|
||||
}
|
||||
|
||||
public function publicKeyBase64(): string
|
||||
{
|
||||
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
|
||||
return base64_encode(sodium_crypto_sign_publickey($pair));
|
||||
}
|
||||
|
||||
private function privateSeed(): string
|
||||
{
|
||||
$path = config('rsync-studio.private_key_path');
|
||||
if (!$path || !is_file($path)) throw new RuntimeException('RSYNC_STUDIO_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
||||
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
|
||||
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) throw new RuntimeException('The Rsync Studio private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private static function b64url(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
namespace App\Services\Licensing;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseTokenVerifier
|
||||
{
|
||||
public function verify(string $token): array
|
||||
{
|
||||
$parts = explode('.', trim($token));
|
||||
if (count($parts) !== 3 || $parts[0] !== 'RS3') throw new RuntimeException('Invalid licence token.');
|
||||
$payload = $this->decode($parts[1]);
|
||||
$signature = $this->decode($parts[2]);
|
||||
$public = base64_decode((string) config('rsync-studio.public_key'), true);
|
||||
if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) throw new RuntimeException('Server licence public key is invalid.');
|
||||
if (!sodium_crypto_sign_verify_detached($signature, $payload, $public)) throw new RuntimeException('Licence token signature is invalid.');
|
||||
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
|
||||
}
|
||||
private function decode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
|
||||
$out = base64_decode($value, true);
|
||||
if ($out === false) throw new RuntimeException('Invalid Base64URL value.');
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void
|
||||
{
|
||||
$secret = (string) config('rsync-studio.lemon_squeezy.webhook_secret');
|
||||
$signature = (string) $request->header('X-Signature');
|
||||
if ($secret === '' || $signature === '') throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
|
||||
$expected = hash_hmac('sha256',$rawBody,$secret);
|
||||
if (!hash_equals($expected,$signature)) throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class PaddleWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void
|
||||
{
|
||||
$secret = (string) config('rsync-studio.paddle.webhook_secret');
|
||||
$header = (string) $request->header('Paddle-Signature');
|
||||
if ($secret === '' || $header === '') throw new RuntimeException('Paddle webhook verification is not configured.');
|
||||
$parts = [];
|
||||
foreach (explode(';', $header) as $part) { [$k,$v] = array_pad(explode('=',trim($part),2),2,null); if ($k && $v) $parts[$k][]=$v; }
|
||||
$ts = isset($parts['ts'][0]) ? (int)$parts['ts'][0] : 0;
|
||||
$signatures = $parts['h1'] ?? [];
|
||||
$tolerance = max(1,(int)config('rsync-studio.paddle.timestamp_tolerance',300));
|
||||
if (!$ts || abs(time()-$ts)>$tolerance) throw new RuntimeException('Paddle webhook timestamp is outside the allowed tolerance.');
|
||||
$expected = hash_hmac('sha256', $ts.':'.$rawBody, $secret);
|
||||
foreach ($signatures as $signature) if (hash_equals($expected,$signature)) return;
|
||||
throw new RuntimeException('Paddle webhook signature is invalid.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Licensing\LicenseIssuer;
|
||||
use RuntimeException;
|
||||
|
||||
final class PurchaseProvisioner
|
||||
{
|
||||
public function __construct(private LicenseIssuer $issuer) {}
|
||||
|
||||
public function provision(string $provider, array $event): void
|
||||
{
|
||||
$normalized = match ($provider) {
|
||||
'paddle' => $this->fromPaddle($event),
|
||||
'lemon-squeezy' => $this->fromLemonSqueezy($event),
|
||||
default => throw new RuntimeException('Unsupported payment provider.'),
|
||||
};
|
||||
if ($normalized['action'] === 'ignore') return;
|
||||
if ($normalized['action'] === 'revoke') {
|
||||
License::where('payment_provider',$provider)->where('external_order_id',$normalized['orderID'])->whereNull('revoked_at')->update(['revoked_at'=>now(),'revoke_reason'=>'Payment refunded or reversed']);
|
||||
return;
|
||||
}
|
||||
$product = LicenseProduct::where('payment_provider',$provider)->where(function($q) use($normalized){ $q->where('external_price_id',$normalized['priceID'])->orWhere('external_product_id',$normalized['productID']); })->where('active',true)->first();
|
||||
if (!$product) throw new RuntimeException('No active licence product maps to this payment item.');
|
||||
$this->issuer->issue($product,$normalized['name'],$normalized['email'],$provider,$normalized['orderID'],$normalized['customerID'], true);
|
||||
}
|
||||
|
||||
private function fromPaddle(array $e): array
|
||||
{
|
||||
$type=(string)($e['event_type']??''); $d=$e['data']??[];
|
||||
if (in_array($type,['transaction.refunded','transaction.canceled'],true)) return ['action'=>'revoke','orderID'=>(string)($d['id']??''),'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
|
||||
if ($type!=='transaction.completed') return ['action'=>'ignore'];
|
||||
$item=$d['items'][0]??[]; $price=$item['price']??[]; $product=$price['product_id']??($item['product_id']??null);
|
||||
return ['action'=>'issue','orderID'=>(string)($d['id']??''),'priceID'=>(string)($price['id']??($item['price_id']??'')),'productID'=>(string)($product??''),'name'=>$d['custom_data']['customer_name']??null,'email'=>$d['custom_data']['customer_email']??null,'customerID'=>isset($d['customer_id'])?(string)$d['customer_id']:null];
|
||||
}
|
||||
|
||||
private function fromLemonSqueezy(array $e): array
|
||||
{
|
||||
$type=(string)($e['meta']['event_name']??''); $a=$e['data']['attributes']??[];
|
||||
$order=(string)($e['data']['id']??($a['order_id']??''));
|
||||
if (in_array($type,['order_refunded','subscription_expired'],true)) return ['action'=>'revoke','orderID'=>$order,'priceID'=>'','productID'=>'','name'=>null,'email'=>null,'customerID'=>null];
|
||||
if (!in_array($type,['order_created','subscription_created'],true)) return ['action'=>'ignore'];
|
||||
return ['action'=>'issue','orderID'=>$order,'priceID'=>(string)($a['variant_id']??''),'productID'=>(string)($a['product_id']??''),'name'=>$a['user_name']??null,'email'=>$a['user_email']??null,'customerID'=>isset($a['customer_id'])?(string)$a['customer_id']:null];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
namespace App\Services\Payments;
|
||||
use Illuminate\Http\Request;
|
||||
interface WebhookVerifier { public function verify(Request $request, string $rawBody): void; }
|
||||
Reference in New Issue
Block a user