diff --git a/ROADMAP.md b/ROADMAP.md index dfa47f4b..c581c64a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,9 @@ ### Features -- [ ] Adding a command that lists all Restify registered routes `php artisan restify:routes` +- [x] Adding a command that lists all Restify registered routes `php artisan restify:routes` - [ ] UI for Restify -- [ ] Support for Laravel 10 -- [ ] Custom namespace and base directory for repositories +- [x] Support for Laravel 10 +- [x] Custom namespace and base directory for repositories +- [ ] Deprecate `show` and use `view` as default policy method for `show` requests +- [ ] Deprecate `store` and use `create` as default policy method for `store` requests so it's Laravel compatible diff --git a/config/restify.php b/config/restify.php index 7450578e..f17c4f0f 100644 --- a/config/restify.php +++ b/config/restify.php @@ -47,7 +47,7 @@ 'user_verify_url' => env('FRONTEND_APP_URL').'/verify/{id}/{emailHash}', - 'user_model' => \Illuminate\Foundation\Auth\User::class, + 'user_model' => "\App\Models\User", ], /* diff --git a/docs-v2/content/en/auth/authentication.md b/docs-v2/content/en/auth/authentication.md index e27101cf..e67cdc3c 100644 --- a/docs-v2/content/en/auth/authentication.md +++ b/docs-v2/content/en/auth/authentication.md @@ -9,6 +9,21 @@ Laravel Restify has the support for a facile authentication with [Laravel Sanctu Now you can finally enjoy the auth setup (`register`, `login`, `forgot`, and `reset password`). +## Quick start + +tl;dr: + +If you run on Laravel 10 or higher, you can use this command that will do all the setup for you: + +```shell script +php artisan restify:setup-auth +``` + +This command will: + +- **ensures** that `Sanctum` is installed and configured as the authentication provider in the `config/restify.php` file +- **appends** the `Route::restifyAuth();` line to the `routes/api.php` file to add the authentication routes + ## Prerequisites Migrate the `users`, `password_resets` table (they already exist into a fresh Laravel app). @@ -116,7 +131,7 @@ Next, add the `auth:sanctum` middleware after the `api` middleware in your confi ## Login -Let's ensure the authentication is working correctly. Create a user in the DatabaseSeeder class: +Let's ensure the authentication is working correctly. Create a user in the `DatabaseSeeder` class: ```php // DatabaseSeeder.php @@ -165,6 +180,42 @@ So you should see the response like this: } ``` +### Authorization + +We will discuss the authorization in more details here [Authorization](/auth/authorization). But for now let's see a simple example. + +After a successful login, you will receive an authentication token. You should include this token as a `Bearer` token in the Authorization header for your subsequent API requests using [Postman](https://learning.postman.com/docs/sending-requests/authorization/#bearer-token), axios library, or cURL. + +Here's an axios example for retrieving the user's profile with the generated token: + +```js +import axios from 'axios'; + +const token = '1|f7D1qkALtM9GKDkjREKpwMRKTZg2ZnFqDZTSe53k'; + +axios.get('http://restify-app.test/api/restify/profile', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } +}) +.then(response => { + console.log(response.data); +}) +.catch(error => { + console.error(error); +}); +``` + +Here's a cURL example for retrieving the user's profile with the generated token: +```bash +curl -X GET "http://restify-app.test/api/restify/profile" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer 1|f7D1qkALtM9GKDkjREKpwMRKTZg2ZnFqDZTSe53k" +``` + +Replace `http://restify-app.test` with your actual domain and use the authentication token you received after logging in. + ## Register Let's see how to register a new user in the application. You can test the registration using Curl or Postman. diff --git a/docs-v2/content/en/auth/authorization.md b/docs-v2/content/en/auth/authorization.md index a2be39f1..f2095fff 100644 --- a/docs-v2/content/en/auth/authorization.md +++ b/docs-v2/content/en/auth/authorization.md @@ -15,7 +15,7 @@ Before diving into details about authorization, it is important for you to under When you run a request (ie via Postman), it hits the Laravel application. Laravel will load every single Service Provider it has defined into `config/app.php` and [auto discovered ](https://laravel.com/docs/packages#package-discovery) providers as well. -Restify injects the `RestifyApplicationServiceProvider` in your `config/app.php` and it also has an auto discovered provider called `LaravelRestify\LaravelRestifyServiceProvider`. +Restify injects the `RestifyApplicationServiceProvider` in your `config/app.php` and it also has an auto discovered provider called `\Binaryk\LaravelRestify\LaravelRestifyServiceProvider`. - The `LaravelRestifyServiceProvider` is booted first. This will basically push the `RestifyInjector` middleware at the end of the middleware stack. @@ -23,7 +23,13 @@ Restify injects the `RestifyApplicationServiceProvider` in your `config/app.php` - The `RestifyInjector` will be handled. It will register all the routes. -- On each request, if the requested route is a Restify route, Laravel will handle other middlewares defined in the `restify.php` -> `middleware`. +- On each request, if the requested route is a Restify route, Laravel will handle other middlewares defined in the `restify.php` -> `middleware`. Here is where you should have the `auth:sanctum` middleware to protect your API against unauthenticated users. + +## Prerequisites + +Before we dive into the details of authorization, we need to make sure that you have a basic understanding of how Laravel's authorization works. If you are not familiar with it, we highly recommend reading the [documentation](https://laravel.com/docs/authorization) before you move forward. + +You may also visit the [Authentication/login](/auth/authentication#authorization) section to learn how to login and use the Bearer token. ## View Restify diff --git a/docs-v2/content/en/auth/profile.md b/docs-v2/content/en/auth/profile.md index 2284245e..a7a5db4b 100644 --- a/docs-v2/content/en/auth/profile.md +++ b/docs-v2/content/en/auth/profile.md @@ -5,29 +5,39 @@ category: Auth position: 1 --- -## Sanctum middleware +## Prerequisites -To make sure you can get your profile just right, you should add the `auth:sanctum` middleware to the restify middleware config: +Make sure you followed the [Authentication](/docs/auth/authentication) guide before, because one common mistake is that people do not add this middleware: ```php // config/restify.php - 'middleware' => [ - 'api', +// ... 'auth:sanctum', - \Binaryk\LaravelRestify\Http\Middleware\DispatchRestifyStartingEvent::class, - \Binaryk\LaravelRestify\Http\Middleware\AuthorizeRestify::class, +// ... ] ``` ## Get profile +Before retrieving the user's profile, you need to log in and obtain an authentication token. You can refer to the [login documentation](/auth/authentication#login) for details on how to authenticate a user. Make sure to include the `Bearer {$token}` in the `Authorization` header for the subsequent API requests, either using Postman or cURL. + When retrieving the user's profile, it is serialized by using the `UserRepository`. ```http request GET: /api/restify/profile ``` +Here's an example of a cURL request for retrieving the user's profile with a random token: + +```bash +curl -X GET "http://your-domain.com/api/restify/profile" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..." +``` + +Replace `http://your-domain.com` with your actual domain and `eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...` with the authentication token you obtained after logging in. + This is what we have for a basic profile: ```json diff --git a/src/Bootstrap/RoutesDefinition.php b/src/Bootstrap/RoutesDefinition.php index 15e84303..d409c230 100644 --- a/src/Bootstrap/RoutesDefinition.php +++ b/src/Bootstrap/RoutesDefinition.php @@ -21,51 +21,51 @@ public function __invoke(string $uriKey = null) Route::get( $prefix.'/filters', \Binaryk\LaravelRestify\Http\Controllers\RepositoryFilterController::class - ); + )->name('filters.index'); // Actions Route::get( $prefix.'/actions', \Binaryk\LaravelRestify\Http\Controllers\ListActionsController::class - )->name('restify.actions.index'); + )->name('actions.index'); Route::get( $prefix.'/{repositoryId}/actions', \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryActionsController::class - )->name('restify.actions.repository.index'); + )->name('actions.repository.index'); Route::post( $prefix.'/action', \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class - )->name('restify.actions.perform'); + )->name('actions.perform'); Route::post( $prefix.'/actions', \Binaryk\LaravelRestify\Http\Controllers\PerformActionController::class - ); // alias to the previous route + )->name('actions.performs'); // alias to the previous route Route::post( $prefix.'/{repositoryId}/action', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class - )->name('restify.actions.repository.perform'); + )->name('actions.repository.perform'); Route::post( $prefix.'/{repositoryId}/actions', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryActionController::class - ); // alias to the previous route + )->name('actions.repository.performs'); // alias to the previous route // Getters Route::get( $prefix.'/getters', \Binaryk\LaravelRestify\Http\Controllers\ListGettersController::class - )->name('restify.getters.index')->withoutMiddleware($this->excludedMiddleware); + )->name('getters.index')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/{repositoryId}/getters', \Binaryk\LaravelRestify\Http\Controllers\ListRepositoryGettersController::class - )->name('restify.getters.repository.index')->withoutMiddleware($this->excludedMiddleware); + )->name('getters.repository.index')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformGetterController::class - )->name('restify.getters.perform')->withoutMiddleware($this->excludedMiddleware); + )->name('getters.perform')->withoutMiddleware($this->excludedMiddleware); Route::get( $prefix.'/{repositoryId}/getters/{getter}', \Binaryk\LaravelRestify\Http\Controllers\PerformRepositoryGetterController::class - )->name('restify.getters.repository.perform')->withoutMiddleware($this->excludedMiddleware); + )->name('getters.repository.perform')->withoutMiddleware($this->excludedMiddleware); // API CRUD Route::get( @@ -75,39 +75,39 @@ public function __invoke(string $uriKey = null) Route::post( $prefix.'', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class - )->name('restify.store'); + )->name('store'); Route::post( $prefix.'/bulk', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreBulkController::class - )->name('restify.store.bulk'); + )->name('store.bulk'); Route::post( $prefix.'/bulk/update', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateBulkController::class - )->name('restify.update.bulk'); + )->name('update.bulk'); Route::delete( $prefix.'/bulk/delete', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyBulkController::class - )->name('restify.destroy.bulk'); + )->name('destroy.bulk'); Route::get( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class - )->name('restify.show')->withoutMiddleware($this->excludedMiddleware); + )->name('show')->withoutMiddleware($this->excludedMiddleware); Route::patch( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryPatchController::class - )->name('restify.patch'); + )->name('patch'); Route::put( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class - )->name('restify.put'); + )->name('put'); Route::post( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class - )->name('restify.update'); + )->name('update'); Route::delete( $prefix.'/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class - )->name('restify.destroy'); + )->name('destroy'); if ($uriKey) { return; @@ -117,61 +117,61 @@ public function __invoke(string $uriKey = null) Route::delete( $prefix.'/{repositoryId}/field/{field}', \Binaryk\LaravelRestify\Http\Controllers\FieldDestroyController::class - ); + )->name('field.destroy'); // Attach related repository id Route::post( $prefix.'/{repositoryId}/attach/{relatedRepository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryAttachController::class - ); + )->name('attach'); Route::post( $prefix.'/{repositoryId}/detach/{relatedRepository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDetachController::class - ); + )->name('detach'); Route::post( $prefix.'/{repositoryId}/sync/{relatedRepository}', \Binaryk\LaravelRestify\Http\Controllers\RepositorySyncController::class - ); + )->name('sync'); // Relatable Route::get( '/{parentRepository}/{parentRepositoryId}/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryIndexController::class - ); + )->name('relatable.index'); Route::post( '/{parentRepository}/{parentRepositoryId}/{repository}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryStoreController::class - ); + )->name('relatable.store'); Route::get( '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryShowController::class - ); + )->name('relatable.show'); Route::post( '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class - ); + )->name('relatable.update'); Route::put( '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryUpdateController::class - ); + )->name('relatable.update'); Route::delete( '/{parentRepository}/{parentRepositoryId}/{repository}/{repositoryId}', \Binaryk\LaravelRestify\Http\Controllers\RepositoryDestroyController::class - ); + )->name('relatable.destroy'); } public function once(): void { - Route::get('/search', GlobalSearchController::class); + Route::get('/search', GlobalSearchController::class)->name('search'); - Route::get('/profile', ProfileController::class); - Route::put('/profile', ProfileUpdateController::class); - Route::post('/profile', ProfileUpdateController::class); + Route::get('/profile', ProfileController::class)->name('profile'); + Route::put('/profile', ProfileUpdateController::class)->name('profile.updatePut'); + Route::post('/profile', ProfileUpdateController::class)->name('profile.updatePost'); // RestifyJS Route::get('/restifyjs/setup', RestifyJsSetupController::class)->withoutMiddleware( RestifySanctumAuthenticate::class, - ); + )->name('restifyjs.setup'); } public function withoutMiddleware(...$middleware): self diff --git a/src/Commands/PrepareSanctumCommand.php b/src/Commands/PrepareSanctumCommand.php new file mode 100644 index 00000000..ee4579aa --- /dev/null +++ b/src/Commands/PrepareSanctumCommand.php @@ -0,0 +1,135 @@ +info('Prepare Sanctum for Restify...'); + + $this->ensureSanctumIsInstalled(); + $this->ensureUserHasApiTokensTrait(); + + $this->replaceMiddleware(); + + return 0; + } + + protected function ensureSanctumIsInstalled() + { + $installedPackages = json_decode(File::get(base_path('composer.lock')), true); + + $sanctumInstalled = false; + foreach ($installedPackages['packages'] as $package) { + if ($package['name'] === 'laravel/sanctum') { + $sanctumInstalled = true; + break; + } + } + + if (! $sanctumInstalled) { + $this->info('Laravel Sanctum is not installed. Installing now...'); + $this->runProcess(['composer', 'require', 'laravel/sanctum']); + $this->runProcess(['php', 'artisan', 'vendor:publish', '--provider="Laravel\Sanctum\SanctumServiceProvider"']); + $this->runProcess(['php', 'artisan', 'migrate']); + $this->info('Laravel Sanctum has been installed.'); + } else { + $this->info('Laravel Sanctum is already installed.'); + } + } + + protected function replaceMiddleware(): int + { + $configPath = config_path('restify.php'); + + if (! File::exists($configPath)) { + $this->error('The config/restify.php file does not exist.'); + + return 1; + } + + $content = File::get($configPath); + + $pattern = '/\/\/\s*\'auth:sanctum\',/'; + $replacement = ' \'auth:sanctum\','; + + $updatedContent = preg_replace($pattern, $replacement, $content); + + if ($updatedContent === $content) { + // Check if 'auth:sanctum' is already present in the middleware list + if (strpos($content, '\'auth:sanctum\',') === false) { + $apiMiddlewarePattern = "/'api',/"; + $replacement = "'api',\n 'auth:sanctum',"; + $updatedContent = preg_replace($apiMiddlewarePattern, $replacement, $content); + File::put($configPath, $updatedContent); + $this->info('The auth:sanctum middleware has been added to the middleware list.'); + } else { + $this->info('The auth:sanctum middleware is already present in the middleware list.'); + } + + return 0; + } + + File::put($configPath, $updatedContent); + $this->info('The auth:sanctum comment has been replaced.'); + + return 1; + } + + protected function runProcess(array $command): void + { + $process = new Process($command); + $process->setTimeout(null); + $process->setTty(Process::isTtySupported()); + $process->run(function ($type, $buffer) { + $this->output->write($buffer); + }); + + if (! $process->isSuccessful()) { + throw new ProcessFailedException($process); + } + } + + protected function ensureUserHasApiTokensTrait() + { + $userModelPath = app_path('Models/User.php'); + + if (! File::exists($userModelPath)) { + $this->error('The User model does not exist.'); + + return; + } + + $content = File::get($userModelPath); + + // Check if HasApiTokens trait is already used + if (strpos($content, 'use HasApiTokens;') !== false) { + $this->info('The User model already uses the HasApiTokens trait.'); + + return; + } + + // Check if HasApiTokens is already imported + if (strpos($content, 'use Laravel\Sanctum\HasApiTokens;') === false) { + // Import HasApiTokens trait + $useStatements = "use Laravel\Sanctum\HasApiTokens;\nuse Illuminate\Notifications\Notifiable;"; + $content = str_replace('use Illuminate\Notifications\Notifiable;', $useStatements, $content); + } + + // Add HasApiTokens trait to the User class + $content = str_replace('use HasFactory, Notifiable;', 'use HasFactory, Notifiable, HasApiTokens;', $content); + + File::put($userModelPath, $content); + $this->info('The HasApiTokens trait has been added to the User model.'); + } +} diff --git a/src/Commands/RestifyAuthMacroCommand.php b/src/Commands/RestifyAuthMacroCommand.php new file mode 100644 index 00000000..bd944045 --- /dev/null +++ b/src/Commands/RestifyAuthMacroCommand.php @@ -0,0 +1,40 @@ +error('The routes/api.php file does not exist.'); + + return 1; + } + + $content = File::get($routesPath); + $restifyAuthRoute = 'Route::restifyAuth();'; + + if (strpos($content, $restifyAuthRoute) !== false) { + $this->info('The restifyAuth route is already in the routes/api.php file.'); + + return 0; + } + + $content .= "\n".$restifyAuthRoute."\n"; + + File::put($routesPath, $content); + $this->info('The restifyAuth route has been appended to the routes/api.php file.'); + + return 0; + } +} diff --git a/src/Commands/RestifyRouteListCommand.php b/src/Commands/RestifyRouteListCommand.php new file mode 100644 index 00000000..fc427814 --- /dev/null +++ b/src/Commands/RestifyRouteListCommand.php @@ -0,0 +1,48 @@ +whenStartsWith('/', fn ($replace) => $replace->replaceFirst('/', '')) + ->toString(); + + $routes = collect($this->router->getRoutes()) + ->filter(function (Route $route) use ($base) { + return strpos($route->uri(), $base) === 0; + }) + ->map(function ($route) { + return $this->getRouteInformation($route); + })->filter()->all(); + + if (($sort = $this->option('sort')) !== null) { + $routes = $this->sortRoutes($sort, $routes); + } else { + $routes = $this->sortRoutes('uri', $routes); + } + + if ($this->option('reverse')) { + $routes = array_reverse($routes); + } + + return $this->pluckColumns($routes); + } + + protected function configureOptions() + { + parent::configureOptions(); + + $this->addOption('command', null, InputOption::VALUE_OPTIONAL, 'Filter the routes by the given command.'); + } +} diff --git a/src/Commands/SetupAuthCommand.php b/src/Commands/SetupAuthCommand.php new file mode 100644 index 00000000..50e471ca --- /dev/null +++ b/src/Commands/SetupAuthCommand.php @@ -0,0 +1,20 @@ +info('Configure Sanctum and add auth routes'); + + $this->call(PrepareSanctumCommand::class); + $this->call(RestifyAuthMacroCommand::class); + } +} diff --git a/src/LaravelRestifyServiceProvider.php b/src/LaravelRestifyServiceProvider.php index f0606f43..dcf2cbac 100644 --- a/src/LaravelRestifyServiceProvider.php +++ b/src/LaravelRestifyServiceProvider.php @@ -8,9 +8,12 @@ use Binaryk\LaravelRestify\Commands\FilterCommand; use Binaryk\LaravelRestify\Commands\GetterCommand; use Binaryk\LaravelRestify\Commands\PolicyCommand; +use Binaryk\LaravelRestify\Commands\PrepareSanctumCommand; use Binaryk\LaravelRestify\Commands\PublishAuthCommand; use Binaryk\LaravelRestify\Commands\Refresh; use Binaryk\LaravelRestify\Commands\RepositoryCommand; +use Binaryk\LaravelRestify\Commands\RestifyRouteListCommand; +use Binaryk\LaravelRestify\Commands\SetupAuthCommand; use Binaryk\LaravelRestify\Commands\SetupCommand; use Binaryk\LaravelRestify\Commands\StoreCommand; use Binaryk\LaravelRestify\Commands\StubCommand; @@ -40,6 +43,9 @@ public function configurePackage(Package $package): void Refresh::class, StubCommand::class, PublishAuthCommand::class, + RestifyRouteListCommand::class, + PrepareSanctumCommand::class, + SetupAuthCommand::class, ]); }