diff --git a/app/Exceptions/Api/PersonalAccessClientIsNotConfiguredException.php b/app/Exceptions/Api/PersonalAccessClientIsNotConfiguredException.php new file mode 100644 index 00000000..cb4b6e9d --- /dev/null +++ b/app/Exceptions/Api/PersonalAccessClientIsNotConfiguredException.php @@ -0,0 +1,10 @@ +columns(1) + ->schema([ + Forms\Components\TextInput::make('id') + ->label('ID') + ->disabled() + ->visibleOn(['update', 'show']) + ->readOnly() + ->maxLength(255), + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Forms\Components\Select::make('user_id') + ->label('User') + ->relationship(name: 'user', titleAttribute: 'name') + ->searchable(['name']) + ->disabled() + ->required(), + Forms\Components\Select::make('client_id') + ->label('Client') + ->relationship(name: 'client', titleAttribute: 'name') + ->searchable(['name']) + ->required(), + Forms\Components\Toggle::make('revoked') + ->label('Revoked') + ->required(), + Forms\Components\DateTimePicker::make('expires_at') + ->label('Expires At') + ->disabled(), + Forms\Components\DateTimePicker::make('created_at') + ->label('Created At') + ->disabled(), + Forms\Components\DateTimePicker::make('updated_at') + ->label('Updated At') + ->disabled(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('user.name') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('client.name') + ->searchable() + ->sortable(), + Tables\Columns\IconColumn::make('client.personal_access_client') + ->boolean() + ->label('API token?') + ->sortable(), + Tables\Columns\IconColumn::make('revoked') + ->boolean() + ->label('Revoked?') + ->sortable(), + Tables\Columns\TextColumn::make('expires_at') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime() + ->sortable(), + Tables\Columns\TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + TernaryFilter::make('is_personal_access_client') + ->queries( + true: function (Builder $query) { + /** @var Builder $query */ + return $query->whereHas('client', function (Builder $query) { + /** @var Builder $query */ + return $query->where('personal_access_client', true); + }); + }, + false: function (Builder $query) { + /** @var Builder $query */ + return $query->whereHas('client', function (Builder $query) { + /** @var Builder $query */ + return $query->where('personal_access_client', false); + }); + }, + blank: function (Builder $query) { + /** @var Builder $query */ + return $query; + }, + ) + ->label('API token?'), + TernaryFilter::make('revoked') + ->label('Revoked?'), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + ]); + } + + public static function getRelations(): array + { + return [ + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTokens::route('/'), + 'view' => Pages\ViewToken::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/TokenResource/Pages/ListTokens.php b/app/Filament/Resources/TokenResource/Pages/ListTokens.php new file mode 100644 index 00000000..f8445834 --- /dev/null +++ b/app/Filament/Resources/TokenResource/Pages/ListTokens.php @@ -0,0 +1,19 @@ +user(); - $tokens = $user->tokens()->get(); + $tokens = $user->tokens() + ->where('client_id', '=', config('passport.personal_access_client.id')) + ->get(); return new ApiTokenCollection($tokens); } @@ -39,12 +42,16 @@ class ApiTokenController extends Controller * * @operationId createApiToken * - * @throws AuthorizationException + * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException */ public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource { $user = $this->user(); + if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) { + throw new PersonalAccessClientIsNotConfiguredException; + } + $token = $user->createToken($request->getName(), ['*']); /** @var Token $tokenModel */ $tokenModel = $token->token; @@ -58,14 +65,21 @@ class ApiTokenController extends Controller * @operationId revokeApiToken * * @throws AuthorizationException + * @throws PersonalAccessClientIsNotConfiguredException */ public function revoke(Token $apiToken): JsonResponse { $user = $this->user(); + if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) { + throw new PersonalAccessClientIsNotConfiguredException; + } if ($apiToken->user_id !== $user->getKey()) { throw new AuthorizationException('API token does not belong to user'); } + if ($apiToken->client_id !== config('passport.personal_access_client.id')) { + throw new AuthorizationException('API token is not a personal access token'); + } $apiToken->revoke(); @@ -77,15 +91,21 @@ class ApiTokenController extends Controller * * @operationId deleteApiToken * - * @throws AuthorizationException + * @throws AuthorizationException|PersonalAccessClientIsNotConfiguredException */ public function destroy(Token $apiToken): JsonResponse { $user = $this->user(); + if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) { + throw new PersonalAccessClientIsNotConfiguredException; + } if ($apiToken->user_id !== $user->getKey()) { throw new AuthorizationException('API token does not belong to user'); } + if ($apiToken->client_id !== config('passport.personal_access_client.id')) { + throw new AuthorizationException('API token is not a personal access token'); + } $apiToken->delete(); diff --git a/app/Models/Passport/Token.php b/app/Models/Passport/Token.php index c15ad404..28e1033a 100644 --- a/app/Models/Passport/Token.php +++ b/app/Models/Passport/Token.php @@ -6,6 +6,7 @@ namespace App\Models\Passport; use Database\Factories\Passport\TokenFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Carbon; use Laravel\Passport\Token as PassportToken; @@ -24,4 +25,14 @@ class Token extends PassportToken { /** @use HasFactory */ use HasFactory; + + /** + * Get the client that the token belongs to. + * + * @return BelongsTo + */ + public function client(): BelongsTo + { + return $this->belongsTo(Client::class, 'client_id', 'id'); + } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 5beb16be..c5be5d30 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -69,6 +69,9 @@ class AdminPanelProvider extends PanelProvider NavigationGroup::make() ->label('System') ->collapsed(), + NavigationGroup::make() + ->label('Auth') + ->collapsed(), ]) ->middleware([ EncryptCookies::class, diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 629f6f6b..a69e2848 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -12,6 +12,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership; use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException; use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner; use App\Exceptions\Api\PdfRendererIsNotConfiguredException; +use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; @@ -37,6 +38,7 @@ return [ OrganizationHasNoSubscriptionButMultipleMembersException::KEY => 'Organization has no subscription but multiple members', PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured', FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan', + PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured', ], 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php index e7c3ec9e..c84418a6 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php @@ -19,10 +19,14 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission([]); - $client = $this->createPersonalAccessClient(); - $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + $personalAccessClient = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $personalAccessClient->id); + Config::set('passport.personal_access_client.secret', $personalAccessClient->secret); + $client = $this->createClient(); + $token = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create(); + $otherTokenType = Token::factory()->forUser($data->user)->forClient($client)->create(); $otherData = $this->createUserWithPermission([]); - $otherToken = Token::factory()->forUser($otherData->user)->forClient($client)->create(); + $otherToken = Token::factory()->forUser($otherData->user)->forClient($personalAccessClient)->create(); Passport::actingAs($data->user); // Act @@ -48,9 +52,9 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract { // Arrange $data = $this->createUserWithPermission([]); - $client = $this->createPersonalAccessClient(); - Config::set('passport.personal_access_client.id', $client->id); - Config::set('passport.personal_access_client.secret', $client->secret); + $personalAccessClient = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $personalAccessClient->id); + Config::set('passport.personal_access_client.secret', $personalAccessClient->secret); Passport::actingAs($data->user); // Act @@ -73,11 +77,33 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_fails_if_personal_access_client_is_not_configured(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.store'), [ + 'name' => 'Test Token', + ]); + + // Assert + $this->assertResponseCode($response, 400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'personal_access_client_is_not_configured', + 'message' => 'Personal access client is not configured', + ]); + } + public function test_revoke_endpoint_revokes_api_token(): void { // Arrange $data = $this->createUserWithPermission([]); $client = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $client->id); + Config::set('passport.personal_access_client.secret', $client->secret); $token = Token::factory()->forUser($data->user)->forClient($client)->create(); Passport::actingAs($data->user); @@ -92,6 +118,28 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_revoke_fails_if_token_is_not_personal_access_token(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $personalAccessClient = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $personalAccessClient->id); + Config::set('passport.personal_access_client.secret', $personalAccessClient->secret); + $client = $this->createClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id)); + + // Assert + $this->assertResponseCode($response, 403); + $this->assertDatabaseHas(Token::class, [ + 'id' => $token->id, + 'revoked' => false, + ]); + } + public function test_revoke_fails_if_token_with_id_does_not_exist(): void { // Arrange @@ -105,12 +153,34 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract $this->assertResponseCode($response, 404); } + public function test_revoke_fails_if_personal_access_client_is_not_configured(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id)); + + // Assert + $this->assertResponseCode($response, 400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'personal_access_client_is_not_configured', + 'message' => 'Personal access client is not configured', + ]); + } + public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void { // Arrange $data = $this->createUserWithPermission([]); $otherData = $this->createUserWithPermission([]); $client = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $client->id); + Config::set('passport.personal_access_client.secret', $client->secret); $token = Token::factory()->forUser($otherData->user)->forClient($client)->create(); Passport::actingAs($data->user); @@ -130,6 +200,8 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract // Arrange $data = $this->createUserWithPermission([]); $client = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $client->id); + Config::set('passport.personal_access_client.secret', $client->secret); $token = Token::factory()->forUser($data->user)->forClient($client)->create(); Passport::actingAs($data->user); @@ -141,6 +213,47 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract $this->assertDatabaseMissing(Token::class, ['id' => $token->id]); } + public function test_destroy_fails_if_personal_access_client_is_not_configured(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id)); + + // Assert + $this->assertResponseCode($response, 400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'personal_access_client_is_not_configured', + 'message' => 'Personal access client is not configured', + ]); + } + + public function test_destroy_fails_if_token_is_not_personal_access_token(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $personalAccessClient = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $personalAccessClient->id); + Config::set('passport.personal_access_client.secret', $personalAccessClient->secret); + $client = $this->createClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id)); + + // Assert + $this->assertResponseCode($response, 403); + $this->assertDatabaseHas(Token::class, [ + 'id' => $token->id, + ]); + } + public function test_destroy_fails_if_token_with_id_does_not_exist(): void { // Arrange @@ -160,6 +273,8 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([]); $otherData = $this->createUserWithPermission([]); $client = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $client->id); + Config::set('passport.personal_access_client.secret', $client->secret); $token = Token::factory()->forUser($otherData->user)->forClient($client)->create(); Passport::actingAs($data->user); @@ -183,4 +298,15 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract return $client; } + + private function createClient(): Client + { + $clientRepository = new ClientRepository; + /** @var Client $client */ + $client = $clientRepository->create( + null, 'Desktop App', 'http://localhost', null + ); + + return $client; + } } diff --git a/tests/Unit/Filament/Resources/TokenResourceTest.php b/tests/Unit/Filament/Resources/TokenResourceTest.php new file mode 100644 index 00000000..97d98499 --- /dev/null +++ b/tests/Unit/Filament/Resources/TokenResourceTest.php @@ -0,0 +1,94 @@ +withPersonalOrganization()->create([ + 'email' => 'admin@example.com', + ]); + + $this->actingAs($user); + } + + public function test_can_list_tokens(): void + { + // Arrange + $client = Client::factory()->create(); + $tokens = Token::factory()->forClient($client)->createMany(5); + + // Act + $response = Livewire::test(TokenResource\Pages\ListTokens::class); + + // Assert + $response->assertSuccessful(); + $response->assertCanSeeTableRecords($tokens); + } + + public function test_list_tokens_with_filter_is_personal_access_client_true(): void + { + // Arrange + $client = Client::factory()->create(); + $personalAccessClient = Client::factory()->personalAccessClient()->create(); + $tokens = Token::factory()->forClient($client)->createMany(5); + $personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5); + + // Act + $response = Livewire::test(TokenResource\Pages\ListTokens::class) + ->filterTable('is_personal_access_client', true); + + // Assert + $response->assertSuccessful(); + $response->assertCountTableRecords(5); + $response->assertCanSeeTableRecords($personalAccessTokens); + $response->assertCanNotSeeTableRecords($tokens); + } + + public function test_list_tokens_with_filter_is_personal_access_client_false(): void + { + // Arrange + $client = Client::factory()->create(); + $personalAccessClient = Client::factory()->personalAccessClient()->create(); + $tokens = Token::factory()->forClient($client)->createMany(5); + $personalAccessTokens = Token::factory()->forClient($personalAccessClient)->createMany(5); + + // Act + $response = Livewire::test(TokenResource\Pages\ListTokens::class) + ->filterTable('is_personal_access_client', false); + + // Assert + $response->assertSuccessful(); + $response->assertCountTableRecords(5); + $response->assertCanSeeTableRecords($tokens); + $response->assertCanNotSeeTableRecords($personalAccessTokens); + } + + public function test_can_see_view_page_of_token(): void + { + // Arrange + $client = Client::factory()->create(); + $token = Token::factory()->forClient($client)->create(); + + // Act + $response = Livewire::test(TokenResource\Pages\ViewToken::class, ['record' => $token->getKey()]); + + // Assert + $response->assertSuccessful(); + } +}