From c9783bd57bcec1eaabba1e88c949213bc34fa537 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Thu, 6 Nov 2025 20:35:49 +0100 Subject: [PATCH] stage 1 of oauth removal, switch to sanctum pat tokens --- .../Api/Tenant/TenantAdminTokenController.php | 184 ++++++++++++++++++ .../Middleware/EnsureTenantAdminToken.php | 112 +++++++++++ .../Requests/Auth/TenantAdminTokenRequest.php | 36 ++++ .../Feature/Auth/TenantAdminTokenAuthTest.php | 125 ++++++++++++ tests/Feature/Auth/TenantProfileApiTest.php | 68 +++++++ 5 files changed, 525 insertions(+) create mode 100644 app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php create mode 100644 app/Http/Middleware/EnsureTenantAdminToken.php create mode 100644 app/Http/Requests/Auth/TenantAdminTokenRequest.php create mode 100644 tests/Feature/Auth/TenantAdminTokenAuthTest.php create mode 100644 tests/Feature/Auth/TenantProfileApiTest.php diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php new file mode 100644 index 0000000..dba861e --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php @@ -0,0 +1,184 @@ +credentials(); + + /** @var User|null $user */ + $user = User::query()->when( + isset($credentials['email']), + fn ($query) => $query->where('email', $credentials['email']), + fn ($query) => $query->where('username', $credentials['username'] ?? null) + )->first(); + + if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) { + throw ValidationException::withMessages([ + 'login' => [trans('auth.failed')], + ]); + } + + if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + throw ValidationException::withMessages([ + 'login' => [trans('auth.not_authorized')], + ]); + } + + if ($user->email_verified_at === null) { + throw ValidationException::withMessages([ + 'login' => [trans('auth.unverified')], + ]); + } + + [$token, $abilities] = $this->issueTokenFor($user); + + return response()->json([ + 'token' => $token, + 'token_type' => 'Bearer', + 'abilities' => $abilities, + 'user' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + 'role' => $user->role, + 'tenant_id' => $user->tenant_id, + ], + ]); + } + + public function destroy(Request $request): JsonResponse + { + $user = $request->user(); + + $token = $user?->currentAccessToken(); + + if ($token) { + $token->delete(); + } + + return response()->json(['ok' => true]); + } + + public function me(Request $request): JsonResponse + { + $user = $request->user(); + + /** @var Tenant|null $tenant */ + $tenant = $request->attributes->get('tenant'); + + if (! $tenant && $user?->tenant_id) { + $tenant = Tenant::query()->find($user->tenant_id); + } + + $tenantPayload = null; + if ($tenant) { + $tenantPayload = [ + 'id' => $tenant->id, + 'tenant_id' => $tenant->id, + 'name' => $tenant->name, + 'slug' => $tenant->slug, + 'event_credits_balance' => $tenant->event_credits_balance, + 'features' => $tenant->features, + ]; + } + + return response()->json([ + 'user' => $user ? Arr::only($user->toArray(), [ + 'id', + 'name', + 'email', + 'role', + 'tenant_id', + ]) : null, + 'tenant' => $tenantPayload, + 'abilities' => $user?->currentAccessToken()?->abilities ?? [], + ]); + } + + public function exchange(Request $request): JsonResponse + { + /** @var User|null $user */ + $user = Auth::guard('web')->user(); + + if (! $user) { + return response()->json([ + 'error' => 'unauthenticated', + 'message' => trans('auth.failed'), + ], 401); + } + + if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + return response()->json([ + 'error' => 'forbidden', + 'message' => trans('auth.not_authorized'), + ], 403); + } + + if ($user->email_verified_at === null) { + return response()->json([ + 'error' => 'unverified', + 'message' => trans('auth.unverified'), + ], 422); + } + + [$token, $abilities] = $this->issueTokenFor($user); + + return response()->json([ + 'token' => $token, + 'token_type' => 'Bearer', + 'abilities' => $abilities, + 'user' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + 'role' => $user->role, + 'tenant_id' => $user->tenant_id, + ], + ]); + } + + /** + * @return array + */ + private function resolveTokenAbilities(User $user): array + { + $abilities = ['tenant-admin']; + + if ($user->tenant_id) { + $abilities[] = 'tenant:'.$user->tenant_id; + } + + if ($user->role === 'super_admin') { + $abilities[] = 'super-admin'; + } + + return $abilities; + } + + /** + * @return array{0: string, 1: array} + */ + private function issueTokenFor(User $user): array + { + $user->tokens()->where('name', 'tenant-admin')->delete(); + + $abilities = $this->resolveTokenAbilities($user); + $token = $user->createToken('tenant-admin', $abilities); + + return [$token->plainTextToken, $abilities]; + } +} diff --git a/app/Http/Middleware/EnsureTenantAdminToken.php b/app/Http/Middleware/EnsureTenantAdminToken.php new file mode 100644 index 0000000..df621cb --- /dev/null +++ b/app/Http/Middleware/EnsureTenantAdminToken.php @@ -0,0 +1,112 @@ +user(); + + if (! $user) { + return $this->unauthorizedResponse('Unauthenticated request.'); + } + + $accessToken = $user->currentAccessToken(); + + if (! $accessToken instanceof PersonalAccessToken) { + return $this->unauthorizedResponse('Missing personal access token context.'); + } + + if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + return $this->forbiddenResponse('Only tenant administrators may access this resource.'); + } + + if (! $accessToken->can('tenant-admin')) { + return $this->forbiddenResponse('Access token does not include the tenant-admin ability.'); + } + + /** @var Tenant|null $tenant */ + $tenant = $user->tenant; + + if (! $tenant && $user->role === 'super_admin') { + $requestedTenantId = $this->resolveRequestedTenantId($request); + + if ($requestedTenantId !== null) { + $tenant = Tenant::query()->find($requestedTenantId); + } + } + + if (! $tenant && $user->role !== 'super_admin') { + return $this->forbiddenResponse('Tenant context missing for user.'); + } + + if ($tenant) { + $request->attributes->set('tenant_id', $tenant->id); + $request->attributes->set('tenant', $tenant); + } elseif ($user->role === 'super_admin') { + $requestedTenantId = $this->resolveRequestedTenantId($request); + if ($requestedTenantId !== null) { + $request->attributes->set('tenant_id', $requestedTenantId); + } + } + + $request->attributes->set('sanctum_token_id', $accessToken->id); + + Auth::shouldUse('sanctum'); + + return $next($request); + } + + private function unauthorizedResponse(string $message): JsonResponse + { + return ApiError::response( + 'unauthenticated', + 'Unauthenticated', + $message, + Response::HTTP_UNAUTHORIZED + ); + } + + private function forbiddenResponse(string $message): JsonResponse + { + return ApiError::response( + 'tenant_admin_only', + 'Forbidden', + $message, + Response::HTTP_FORBIDDEN + ); + } + + private function resolveRequestedTenantId(Request $request): ?int + { + $routeTenant = $request->route('tenant'); + if (is_numeric($routeTenant)) { + return (int) $routeTenant; + } + + $queryTenant = $request->query('tenant_id'); + if (is_numeric($queryTenant)) { + return (int) $queryTenant; + } + + $headerTenant = $request->header('X-Tenant-ID'); + if (is_numeric($headerTenant)) { + return (int) $headerTenant; + } + + return null; + } +} diff --git a/app/Http/Requests/Auth/TenantAdminTokenRequest.php b/app/Http/Requests/Auth/TenantAdminTokenRequest.php new file mode 100644 index 0000000..a2be200 --- /dev/null +++ b/app/Http/Requests/Auth/TenantAdminTokenRequest.php @@ -0,0 +1,36 @@ +|string> + */ + public function rules(): array + { + return [ + 'login' => ['required', 'string'], + 'password' => ['required', 'string'], + ]; + } + + public function credentials(): array + { + $login = $this->string('login')->trim()->value(); + + if (filter_var($login, FILTER_VALIDATE_EMAIL)) { + return ['email' => $login, 'password' => $this->string('password')->value()]; + } + + return ['username' => $login, 'password' => $this->string('password')->value()]; + } +} diff --git a/tests/Feature/Auth/TenantAdminTokenAuthTest.php b/tests/Feature/Auth/TenantAdminTokenAuthTest.php new file mode 100644 index 0000000..0028961 --- /dev/null +++ b/tests/Feature/Auth/TenantAdminTokenAuthTest.php @@ -0,0 +1,125 @@ +create(); + + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'password' => Hash::make('secret-password'), + 'email_verified_at' => now(), + ]); + + $response = $this->postJson(route('api.v1.tenant-auth.login'), [ + 'login' => $user->email, + 'password' => 'secret-password', + ]); + + $response->assertOk(); + $response->assertJsonStructure([ + 'token', + 'token_type', + 'abilities', + 'user' => ['id', 'email', 'name', 'role', 'tenant_id'], + ]); + + $this->assertDatabaseCount('personal_access_tokens', 1); + } + + public function test_regular_user_cannot_login(): void + { + $user = User::factory()->create([ + 'role' => 'user', + 'password' => Hash::make('secret-password'), + 'email_verified_at' => now(), + ]); + + $response = $this->postJson(route('api.v1.tenant-auth.login'), [ + 'login' => $user->email, + 'password' => 'secret-password', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('login'); + } + + public function test_unverified_user_cannot_login(): void + { + $user = User::factory()->create([ + 'role' => 'tenant_admin', + 'password' => Hash::make('secret-password'), + 'email_verified_at' => null, + ]); + + $response = $this->postJson(route('api.v1.tenant-auth.login'), [ + 'login' => $user->email, + 'password' => 'secret-password', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('login'); + } + + public function test_me_endpoint_returns_user_details(): void + { + $tenant = Tenant::factory()->create(); + + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'password' => Hash::make('secret-password'), + 'email_verified_at' => now(), + ]); + + $token = $user->createToken('tenant-admin', ['tenant-admin']); + + $response = $this + ->withToken($token->plainTextToken) + ->getJson(route('api.v1.tenant-auth.me')); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $user->id, + 'email' => $user->email, + 'role' => 'tenant_admin', + 'tenant_id' => $tenant->id, + ]); + } + + public function test_logout_revokes_current_token(): void + { + $tenant = Tenant::factory()->create(); + + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'password' => Hash::make('secret-password'), + 'email_verified_at' => now(), + ]); + + $token = $user->createToken('tenant-admin', ['tenant-admin']); + + $response = $this + ->withToken($token->plainTextToken) + ->postJson(route('api.v1.tenant-auth.logout')); + + $response->assertOk(); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'id' => $token->accessToken->id, + ]); + } +} diff --git a/tests/Feature/Auth/TenantProfileApiTest.php b/tests/Feature/Auth/TenantProfileApiTest.php new file mode 100644 index 0000000..093c740 --- /dev/null +++ b/tests/Feature/Auth/TenantProfileApiTest.php @@ -0,0 +1,68 @@ +create([ + 'name' => 'Test Tenant GmbH', + 'slug' => 'test-tenant', + 'event_credits_balance' => 12, + 'features' => ['custom_branding' => true], + ]); + + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'tenant_admin', + 'password' => Hash::make('secret-password'), + 'email' => 'tenant@example.com', + 'name' => 'Max Mustermann', + ]); + + $login = $this->postJson('/api/v1/tenant-auth/login', [ + 'login' => 'tenant@example.com', + 'password' => 'secret-password', + ]); + + $login->assertOk()->assertJsonStructure(['token', 'token_type', 'abilities']); + + $token = $login->json('token'); + + $me = $this->withHeader('Authorization', 'Bearer '.$token)->getJson('/api/v1/tenant-auth/me'); + + $me->assertOk(); + $me->assertJsonFragment([ + 'id' => $user->id, + 'email' => 'tenant@example.com', + 'role' => 'tenant_admin', + 'tenant_id' => $tenant->id, + ]); + + $me->assertJsonFragment([ + 'name' => 'Test Tenant GmbH', + 'slug' => 'test-tenant', + 'event_credits_balance' => 12, + ]); + + $data = $me->json(); + $this->assertEquals('Max Mustermann', data_get($data, 'user.name')); + $this->assertContains('tenant-admin', $data['abilities']); + } + + public function test_me_requires_valid_token(): void + { + $response = $this->getJson('/api/v1/tenant-auth/me'); + + $response->assertStatus(401); + } +}