From 91992ac4b05ff4c12d734a822498111d1c62361b Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 6 Jul 2026 11:55:22 +0000 Subject: [PATCH] Uebernahme bestehender Legacy-API-Integrationen (Keys, Auth, Pfade) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bestehende Kunden-Integrationen der Altportale laufen nach dem Relaunch weiter, ohne dass Kunden etwas umstellen muessen: 1. legacy:import-api-keys uebernimmt die api_keys aus den Legacy-DBs (sf_guard_user_profile) als Sanctum-Tokens mit identischem Secret (Sanctum loest Tokens ohne id|-Praefix per sha256-Hash auf) — nur fuer berechtigte Nutzer (aktiver Zahlungsstatus/Bestandsschutz, ApiAccessEligibilityService); idempotent, mit Dry-Run. Probe gegen die lokalen Snapshots: 406 Keys wuerden uebernommen. 2. Die Auth-Middleware uebersetzt die alten Uebergabeformen (X-ApiKey- Header, api_key-Parameter) fuer gueltige Keys in Bearer; unbekannte Keys erhalten weiterhin 410 mit Migrationshinweis. 3. Die dokumentierten /service/...-Pfade der Alt-API sind als Aliasse auf die v1-Controller gelegt (pressreleases inkl. Bilder, companies lesend); nicht uebernommene Alt-Endpoints (Pressemappen/Kontakte anlegen) antworten 410 mit Hinweis auf Kundenkonto und neue Doku. API-Seite + OpenAPI entsprechend aktualisiert; Go-Live-Checkliste um den Import-Schritt (nach dem Grandfathering) ergaenzt. Co-Authored-By: Claude Fable 5 --- app/Console/Commands/ImportLegacyApiKeys.php | 82 +++++++++++ app/Http/Middleware/RejectLegacyApiKeys.php | 53 +++++++- app/Services/Api/LegacyApiKeyImporter.php | 90 ++++++++++++ bootstrap/app.php | 6 + docs/api/v1.yml | 7 +- .../Live-Deployment-Checkliste (WS-7).md | 6 + resources/views/web/api.blade.php | 6 +- routes/legacy-api.php | 50 +++++++ .../Api/LegacyApiCompatibilityTest.php | 128 ++++++++++++++++++ tests/Feature/Api/V1/CustomerDataApiTest.php | 6 +- tests/Feature/ApiDocumentationTest.php | 4 +- 11 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/ImportLegacyApiKeys.php create mode 100644 app/Services/Api/LegacyApiKeyImporter.php create mode 100644 routes/legacy-api.php create mode 100644 tests/Feature/Api/LegacyApiCompatibilityTest.php diff --git a/app/Console/Commands/ImportLegacyApiKeys.php b/app/Console/Commands/ImportLegacyApiKeys.php new file mode 100644 index 00000000..6ee2ed0f --- /dev/null +++ b/app/Console/Commands/ImportLegacyApiKeys.php @@ -0,0 +1,82 @@ + 'mysql_presseecho', + 'businessportal24' => 'mysql_businessportal', + ]; + + public function handle(LegacyApiKeyImporter $importer): int + { + $portalOption = (string) $this->option('portal'); + $dryRun = (bool) $this->option('dry-run'); + + $portals = $portalOption === 'all' ? array_keys(self::CONNECTIONS) : [$portalOption]; + + if (array_diff($portals, array_keys(self::CONNECTIONS)) !== []) { + $this->error('Ungültiges Portal. Erlaubt: presseecho, businessportal24, all.'); + + return self::FAILURE; + } + + if ($dryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + foreach ($portals as $portal) { + $rows = DB::connection(self::CONNECTIONS[$portal]) + ->table('sf_guard_user_profile') + ->whereNotNull('api_key') + ->where('api_key', '!=', '') + ->get(['user_id', 'api_key']) + ->map(fn (object $row): array => [ + 'legacy_id' => (int) $row->user_id, + 'api_key' => (string) $row->api_key, + ]); + + $result = $importer->import($portal, $rows, $dryRun); + + $this->table( + ['Portal', 'Keys in Legacy-DB', 'Importiert', 'Schon vorhanden', 'Nicht berechtigt', 'Kein User-Mapping'], + [[ + $portal, + $rows->count(), + $result['imported'], + $result['already_present'], + $result['ineligible'], + $result['unknown_user'], + ]], + ); + + foreach ($result['ineligible_users'] as $email) { + $this->warn("Nicht berechtigt (kein aktiver Zahlungsstatus): {$email}"); + } + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Middleware/RejectLegacyApiKeys.php b/app/Http/Middleware/RejectLegacyApiKeys.php index cc3e8180..9330a3a4 100644 --- a/app/Http/Middleware/RejectLegacyApiKeys.php +++ b/app/Http/Middleware/RejectLegacyApiKeys.php @@ -4,8 +4,17 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; +use Laravel\Sanctum\PersonalAccessToken; use Symfony\Component\HttpFoundation\Response; +/** + * Legacy-API-Kompatibilität (Übernahme bestehender Integrationen, + * 06.07.2026): Die Altportale authentifizierten per `X-ApiKey`-Header bzw. + * `api_key`-Parameter. Importierte Legacy-Keys (legacy:import-api-keys) + * sind gültige Sanctum-Tokens — hier wird die alte Übergabeform in einen + * Bearer-Header übersetzt, damit `auth:sanctum` sie versteht. Unbekannte + * Keys erhalten weiterhin 410 mit Migrationshinweis. + */ class RejectLegacyApiKeys { /** @@ -15,14 +24,44 @@ class RejectLegacyApiKeys */ public function handle(Request $request, Closure $next): Response { - if ($request->has('api_key') || filled($request->header('X-Api-Key'))) { - return response()->json([ - 'message' => 'Legacy API keys are no longer supported.', - 'migration_url' => url('/customer/tokens'), - 'docs_url' => url('/docs/api/v1'), - ], 410); + $legacyKey = $this->legacyKey($request); + + if ($legacyKey === null) { + return $next($request); } - return $next($request); + // Ein explizit gesetzter Bearer-Token hat Vorrang. + if (filled($request->bearerToken())) { + return $next($request); + } + + if (PersonalAccessToken::findToken($legacyKey) !== null) { + $request->headers->set('Authorization', 'Bearer '.$legacyKey); + + return $next($request); + } + + return response()->json([ + 'message' => 'This API key is not valid (anymore). Please generate a token in your customer account.', + 'migration_url' => url('/customer/tokens'), + 'docs_url' => url('/docs/api/v1'), + ], 410); + } + + /** + * Legacy-Schlüssel aus den historischen Übergabeformen: `X-ApiKey` + * (dokumentierter Header der Altportale), `X-Api-Key` (verbreitete + * Schreibvariante) oder `api_key` als Query-/Body-Parameter. + */ + private function legacyKey(Request $request): ?string + { + $key = trim((string) ( + $request->header('X-ApiKey') + ?: $request->header('X-Api-Key') + ?: $request->query('api_key') + ?: $request->input('api_key') + )); + + return $key === '' ? null : $key; } } diff --git a/app/Services/Api/LegacyApiKeyImporter.php b/app/Services/Api/LegacyApiKeyImporter.php new file mode 100644 index 00000000..3cfce50d --- /dev/null +++ b/app/Services/Api/LegacyApiKeyImporter.php @@ -0,0 +1,90 @@ + $rows + * @return array{imported: int, already_present: int, ineligible: int, unknown_user: int, ineligible_users: list} + */ + public function import(string $portal, iterable $rows, bool $dryRun = false): array + { + $result = [ + 'imported' => 0, + 'already_present' => 0, + 'ineligible' => 0, + 'unknown_user' => 0, + 'ineligible_users' => [], + ]; + + foreach ($rows as $row) { + $key = trim((string) $row['api_key']); + + if ($key === '') { + continue; + } + + $user = User::query() + ->where('legacy_portal', $portal) + ->where('legacy_id', (int) $row['legacy_id']) + ->first(); + + if (! $user) { + $result['unknown_user']++; + + continue; + } + + $hash = hash('sha256', $key); + + if (PersonalAccessToken::query()->where('token', $hash)->exists()) { + $result['already_present']++; + + continue; + } + + if (! $this->eligibility->canCreateToken($user)) { + $result['ineligible']++; + $result['ineligible_users'][] = (string) $user->email; + + continue; + } + + if (! $dryRun) { + $user->tokens()->create([ + 'name' => self::TOKEN_NAME, + 'token' => $hash, + 'abilities' => self::ABILITIES, + ]); + } + + $result['imported']++; + } + + return $result; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 0fad0281..1d89694e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -10,6 +10,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; use Laravel\Sanctum\Http\Middleware\CheckAbilities; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; @@ -19,6 +20,11 @@ return Application::configure(basePath: dirname(__DIR__)) api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', + then: function (): void { + // Legacy-API-Aliasse (/service/… ohne /api-Präfix) in der + // api-Middleware-Gruppe — Übernahme bestehender Integrationen. + Route::middleware('api')->group(__DIR__.'/../routes/legacy-api.php'); + }, ) ->withMiddleware(function (Middleware $middleware) { diff --git a/docs/api/v1.yml b/docs/api/v1.yml index 9ab3d7c6..0e5ffab4 100644 --- a/docs/api/v1.yml +++ b/docs/api/v1.yml @@ -16,8 +16,11 @@ info: Rate-Limit: 60 Anfragen pro Minute je Token (Header X-RateLimit-Limit / X-RateLimit-Remaining, bei 429 zusätzlich Retry-After). - Migration: Der X-ApiKey-Header der Altportale wird nicht mehr - unterstützt — legacy API keys are no longer supported. + Migration: Bestehende API-Schlüssel der Altportale wurden übernommen + und bleiben gültig — auch in der alten Übergabeform (X-ApiKey-Header) + und auf den dokumentierten /service/…-Pfaden. Unbekannte Alt-Schlüssel + erhalten 410 mit Migrationshinweis. Neue Integrationen nutzen bitte + Bearer-Tokens auf /api/v1. servers: - url: https://pressekonto.de/api/v1 security: diff --git a/docs/weiteres/Live-Deployment-Checkliste (WS-7).md b/docs/weiteres/Live-Deployment-Checkliste (WS-7).md index efdad51b..19ec2ad2 100644 --- a/docs/weiteres/Live-Deployment-Checkliste (WS-7).md +++ b/docs/weiteres/Live-Deployment-Checkliste (WS-7).md @@ -130,6 +130,12 @@ php artisan domains:generate-favicons # fehlen AGB-Versionsangabe/Protokoll-Referenz und die /richtlinien-Kategorien. php artisan db:seed --class=LegalDocumentSeeder --force php artisan db:seed --class=RejectionCategorySeeder --force + +# Legacy-API-Schlüssel als Sanctum-Tokens übernehmen (bestehende +# Kunden-Integrationen bleiben gültig; erst Dry-Run prüfen). Läuft NACH dem +# Grandfathering, da die Berechtigung am aktiven Zahlungsstatus hängt. +php artisan legacy:import-api-keys --dry-run +php artisan legacy:import-api-keys ``` --- diff --git a/resources/views/web/api.blade.php b/resources/views/web/api.blade.php index 0cd1d100..25a7d7b6 100644 --- a/resources/views/web/api.blade.php +++ b/resources/views/web/api.blade.php @@ -104,7 +104,11 @@ (z. B. press-releases:write), die den Zugriff auf die jeweiligen Endpunkte steuern. Tokens erstellen und widerrufen Sie selbst in Ihrem Publisher-Konto unter „API-Tokens" — Voraussetzung ist eine aktive Buchung (Tarif oder Einzel-Pressemitteilung). - Der X-ApiKey-Header der Altportale wird nicht mehr unterstützt. + Bestandskunden der Altportale: Ihre bisherigen API-Schlüssel wurden + übernommen und funktionieren weiter — sowohl als Bearer-Token als auch in der alten + Form (X-ApiKey-Header) und auf den dokumentierten + /service/…-Pfaden. Für neue Integrationen empfehlen wir die aktuellen + /api/v1/…-Endpunkte mit Bearer-Token.

diff --git a/routes/legacy-api.php b/routes/legacy-api.php new file mode 100644 index 00000000..781c071b --- /dev/null +++ b/routes/legacy-api.php @@ -0,0 +1,50 @@ +name('legacy-service.') + ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class]) + ->group(function (): void { + Route::get('pressreleases', [PressReleaseController::class, 'index'])->name('pressreleases.index'); + Route::post('pressreleases', [PressReleaseController::class, 'store'])->name('pressreleases.store'); + Route::get('pressreleases/{pressRelease}', [PressReleaseController::class, 'show'])->name('pressreleases.show'); + Route::get('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'index'])->name('pressreleases.images.index'); + Route::post('pressreleases/{pressRelease}/images', [PressReleaseImageController::class, 'store'])->name('pressreleases.images.store'); + + Route::get('users/me/companies', [CompanyController::class, 'index'])->name('companies.index'); + Route::get('users/me/companies/{company}', [CompanyController::class, 'show'])->name('companies.show'); + }); + +// Alles Übrige unter /service/… (inkl. der nicht übernommenen +// Schreib-Endpoints für Pressemappen/Kontakte): klarer Hinweis statt 404. +Route::any('service/{path?}', function () { + return response()->json([ + 'message' => 'This legacy endpoint is not available anymore. Companies and press contacts are managed in your customer account; see the current API reference for everything else.', + 'account_url' => url('/customer'), + 'docs_url' => url('/docs/api/v1'), + ], 410); +})->where('path', '.*')->name('legacy-service.gone'); diff --git a/tests/Feature/Api/LegacyApiCompatibilityTest.php b/tests/Feature/Api/LegacyApiCompatibilityTest.php new file mode 100644 index 00000000..6e322833 --- /dev/null +++ b/tests/Feature/Api/LegacyApiCompatibilityTest.php @@ -0,0 +1,128 @@ +create([ + 'is_active' => true, + 'portal' => Portal::Businessportal24, + 'legacy_portal' => 'businessportal24', + 'legacy_id' => $legacyId, + ]); + + // Aktiver Zahlungsstatus → API-berechtigt (ApiAccessEligibilityService). + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'grandfathered_until' => null, + 'current_period_start' => now()->subMonth(), + 'current_period_end' => now()->addMonths(11), + 'stripe_subscription_id' => null, + ]); + + return $user; +} + +test('the importer turns legacy api keys into sanctum tokens idempotently', function () { + $user = eligibleLegacyApiUser(); + + // Nicht berechtigter Kandidat (inaktiv) und unbekanntes Legacy-Mapping. + $inactive = User::factory()->create([ + 'is_active' => false, + 'legacy_portal' => 'businessportal24', + 'legacy_id' => 999, + ]); + + $rows = [ + ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], + ['legacy_id' => 999, 'api_key' => 'legacy-key-inactive'], + ['legacy_id' => 123456, 'api_key' => 'legacy-key-unmapped'], + ]; + + $importer = app(LegacyApiKeyImporter::class); + $result = $importer->import('businessportal24', $rows); + + expect($result['imported'])->toBe(1); + expect($result['ineligible'])->toBe(1); + expect($result['ineligible_users'])->toBe([$inactive->email]); + expect($result['unknown_user'])->toBe(1); + + $token = PersonalAccessToken::findToken('legacy-key-abc123'); + expect($token)->not->toBeNull(); + expect($token->tokenable_id)->toBe($user->id); + expect($token->name)->toBe(LegacyApiKeyImporter::TOKEN_NAME); + expect($token->abilities)->toBe(LegacyApiKeyImporter::ABILITIES); + + // Re-Run legt keine Duplikate an. + $rerun = $importer->import('businessportal24', $rows); + expect($rerun['imported'])->toBe(0); + expect($rerun['already_present'])->toBe(1); + expect(PersonalAccessToken::query()->where('token', hash('sha256', 'legacy-key-abc123'))->count())->toBe(1); +}); + +test('an imported legacy key authenticates via the old header and query forms', function () { + /** @var TestCase $this */ + eligibleLegacyApiUser(); + app(LegacyApiKeyImporter::class)->import('businessportal24', [ + ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], + ]); + + // Dokumentierter Alt-Header der Portale: X-ApiKey. + $this->getJson('/api/v1/categories', ['X-ApiKey' => 'legacy-key-abc123']) + ->assertOk(); + + // Verbreitete Schreibvariante + Query-Parameter. + $this->getJson('/api/v1/categories', ['X-Api-Key' => 'legacy-key-abc123']) + ->assertOk(); + $this->getJson('/api/v1/categories?api_key=legacy-key-abc123') + ->assertOk(); +}); + +test('unknown legacy keys still get the 410 migration hint', function () { + /** @var TestCase $this */ + $this->getJson('/api/v1/categories', ['X-ApiKey' => 'gibt-es-nicht']) + ->assertStatus(410) + ->assertJsonStructure(['message', 'migration_url', 'docs_url']); +}); + +test('the documented legacy service paths keep working with an imported key', function () { + /** @var TestCase $this */ + eligibleLegacyApiUser(); + app(LegacyApiKeyImporter::class)->import('businessportal24', [ + ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], + ]); + + $this->getJson('/service/pressreleases', ['X-ApiKey' => 'legacy-key-abc123']) + ->assertOk(); + + $this->getJson('/service/users/me/companies', ['X-ApiKey' => 'legacy-key-abc123']) + ->assertOk(); +}); + +test('the legacy service paths require authentication', function () { + /** @var TestCase $this */ + $this->getJson('/service/pressreleases')->assertUnauthorized(); +}); + +test('retired legacy service endpoints answer with a helpful 410 instead of 404', function () { + /** @var TestCase $this */ + $this->postJson('/service/users/me/companies', ['company' => ['name' => 'Test']]) + ->assertStatus(410) + ->assertJsonStructure(['message', 'account_url', 'docs_url']); + + $this->getJson('/service/irgendwas')->assertStatus(410); +}); diff --git a/tests/Feature/Api/V1/CustomerDataApiTest.php b/tests/Feature/Api/V1/CustomerDataApiTest.php index 4b39515c..b5817d2f 100644 --- a/tests/Feature/Api/V1/CustomerDataApiTest.php +++ b/tests/Feature/Api/V1/CustomerDataApiTest.php @@ -7,11 +7,13 @@ use App\Models\User; use Laravel\Sanctum\Sanctum; use Tests\TestCase; -test('legacy api key requests receive gone response', function () { +test('unknown legacy api key requests receive gone response', function () { /** @var TestCase $this */ + // Übernahme 06.07.: importierte Legacy-Keys sind gültige Tokens + // (LegacyApiCompatibilityTest); nur unbekannte Keys bekommen 410. $this->getJson('/api/v1/categories?api_key=legacy-key') ->assertStatus(410) - ->assertJsonPath('message', 'Legacy API keys are no longer supported.'); + ->assertJsonStructure(['message', 'migration_url', 'docs_url']); }); test('api user can read only own companies', function () { diff --git a/tests/Feature/ApiDocumentationTest.php b/tests/Feature/ApiDocumentationTest.php index ed2d13e1..52a40c68 100644 --- a/tests/Feature/ApiDocumentationTest.php +++ b/tests/Feature/ApiDocumentationTest.php @@ -9,7 +9,9 @@ test('api v1 documentation is publicly available', function () { ->assertHeader('content-type', 'application/yaml; charset=UTF-8') ->assertSee('openapi: 3.1.0', false) ->assertSee('/press-releases:', false) - ->assertSee('legacy API keys are no longer supported', false); + // Übernahme der Legacy-Integrationen (06.07.): Alt-Schlüssel bleiben + // gültig, unbekannte bekommen den Migrationshinweis. + ->assertSee('Bestehende API-Schlüssel der Altportale wurden übernommen', false); }); test('the public api page documents the real endpoints', function () {