- Wired the checkout wizard for Google “comfort login”: added Socialite controller + dependency, new Google env

hooks in config/services.php/.env.example, and updated wizard steps/controllers to store session payloads,
attach packages, and surface localized success/error states.
- Retooled payment handling for both Stripe and PayPal, adding richer status management in CheckoutController/
PayPalController, fallback flows in the wizard’s PaymentStep.tsx, and fresh feature tests for intent
creation, webhooks, and the wizard CTA.
- Introduced a consent-aware Matomo analytics stack: new consent context, cookie-banner UI, useAnalytics/
useCtaExperiment hooks, and MatomoTracker component, then instrumented marketing pages (Home, Packages,
Checkout) with localized copy and experiment tracking.
- Polished package presentation across marketing UIs by centralizing formatting in PresentsPackages, surfacing
localized description tables/placeholders, tuning badges/layouts, and syncing guest/marketing translations.
- Expanded docs & reference material (docs/prp/*, TODOs, public gallery overview) and added a Playwright smoke
test for the hero CTA while reconciling outstanding checklist items.
This commit is contained in:
Codex Agent
2025-10-19 11:41:03 +02:00
parent ae9b9160ac
commit a949c8d3af
113 changed files with 5169 additions and 712 deletions

View File

@@ -10,6 +10,7 @@ use App\Models\TenantToken;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -22,7 +23,7 @@ class OAuthController extends Controller
private const AUTH_CODE_TTL_MINUTES = 5;
private const ACCESS_TOKEN_TTL_SECONDS = 3600;
private const REFRESH_TOKEN_TTL_DAYS = 30;
private const TOKEN_HEADER_KID = 'fotospiel-jwt';
private const LEGACY_TOKEN_HEADER_KID = 'fotospiel-jwt';
/**
* Authorize endpoint - PKCE flow
@@ -286,9 +287,16 @@ class OAuthController extends Controller
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
$currentIp = (string) ($request->ip() ?? '');
if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) {
if (config('oauth.refresh_tokens.enforce_ip_binding', true) && ! $this->ipMatches($storedIp, $currentIp)) {
$storedRefreshToken->update(['revoked_at' => now()]);
Log::warning('[OAuth] Refresh token rejected due to IP mismatch', [
'client_id' => $request->client_id,
'refresh_token_id' => $storedRefreshToken->id,
'stored_ip' => $storedIp,
'current_ip' => $currentIp,
]);
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
}
@@ -387,7 +395,7 @@ class OAuthController extends Controller
int $issuedAt,
int $expiresAt
): string {
[$publicKey, $privateKey] = $this->ensureKeysExist();
[$kid, , $privateKey] = $this->getSigningKeyPair();
$payload = [
'iss' => url('/'),
@@ -403,47 +411,94 @@ class OAuthController extends Controller
'jti' => $jti,
];
return JWT::encode($payload, $privateKey, 'RS256', self::TOKEN_HEADER_KID, ['kid' => self::TOKEN_HEADER_KID]);
return JWT::encode($payload, $privateKey, 'RS256', $kid, ['kid' => $kid]);
}
private function ensureKeysExist(): array
private function getSigningKeyPair(): array
{
$publicKeyPath = storage_path('app/public.key');
$privateKeyPath = storage_path('app/private.key');
$kid = $this->currentKid();
[$publicKey, $privateKey] = $this->ensureKeysForKid($kid);
$publicKey = @file_get_contents($publicKeyPath);
$privateKey = @file_get_contents($privateKeyPath);
return [$kid, $publicKey, $privateKey];
}
if ($publicKey && $privateKey) {
return [$publicKey, $privateKey];
private function currentKid(): string
{
return config('oauth.keys.current_kid', self::LEGACY_TOKEN_HEADER_KID);
}
private function ensureKeysForKid(string $kid): array
{
$paths = $this->keyPaths($kid);
if (! File::exists($paths['directory'])) {
File::makeDirectory($paths['directory'], 0700, true);
}
$this->generateKeyPair();
$this->maybeMigrateLegacyKeys($paths);
if (! File::exists($paths['public']) || ! File::exists($paths['private'])) {
$this->generateKeyPair($paths['directory']);
}
return [
file_get_contents($publicKeyPath),
file_get_contents($privateKeyPath),
File::get($paths['public']),
File::get($paths['private']),
];
}
private function generateKeyPair(): void
private function keyPaths(string $kid): array
{
$base = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
$directory = $base.DIRECTORY_SEPARATOR.$kid;
return [
'directory' => $directory,
'public' => $directory.DIRECTORY_SEPARATOR.'public.key',
'private' => $directory.DIRECTORY_SEPARATOR.'private.key',
];
}
private function maybeMigrateLegacyKeys(array $paths): void
{
$legacyPublic = storage_path('app/public.key');
$legacyPrivate = storage_path('app/private.key');
if (! File::exists($paths['public']) && File::exists($legacyPublic)) {
File::copy($legacyPublic, $paths['public']);
}
if (! File::exists($paths['private']) && File::exists($legacyPrivate)) {
File::copy($legacyPrivate, $paths['private']);
}
}
private function generateKeyPair(string $directory): void
{
$config = [
'digest_alg' => OPENSSL_ALGO_SHA256,
'private_key_bits' => 2048,
'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$res = openssl_pkey_new($config);
if (! $res) {
$resource = openssl_pkey_new($config);
if (! $resource) {
throw new \RuntimeException('Failed to generate key pair');
}
openssl_pkey_export($res, $privKey);
$pubKey = openssl_pkey_get_details($res);
openssl_pkey_export($resource, $privateKey);
$details = openssl_pkey_get_details($resource);
$publicKey = $details['key'] ?? null;
file_put_contents(storage_path('app/private.key'), $privKey);
file_put_contents(storage_path('app/public.key'), $pubKey['key']);
if (! $publicKey) {
throw new \RuntimeException('Failed to extract public key');
}
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey, true);
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey, true);
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
}
private function scopesAreAllowed(array $requestedScopes, array $availableScopes): bool
{
@@ -480,6 +535,32 @@ class OAuthController extends Controller
return response()->json($response, $status);
}
private function ipMatches(string $storedIp, string $currentIp): bool
{
if ($storedIp === '' || $currentIp === '') {
return true;
}
if (hash_equals($storedIp, $currentIp)) {
return true;
}
if (! config('oauth.refresh_tokens.allow_subnet_match', false)) {
return false;
}
if (filter_var($storedIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($currentIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$storedParts = explode('.', $storedIp);
$currentParts = explode('.', $currentIp);
return $storedParts[0] === $currentParts[0]
&& $storedParts[1] === $currentParts[1]
&& $storedParts[2] === $currentParts[2];
}
return false;
}
private function base64urlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');