commit f19181c63a90aa1b51ce2f6b7c896de329da3fbd Author: Michal Sieciechowicz Date: Mon Mar 1 10:04:25 2021 +0100 clone repo diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1492202 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57872d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a52d0ca --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Nomad NT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e057dfd --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +

+ +[![Total Downloads](https://poser.pugx.org/nomadnt/lumen-passport/downloads)](https://packagist.org/packages/nomadnt/lumen-passport) +[![Latest Stable Version](https://poser.pugx.org/nomadnt/lumen-passport/v/stable)](https://packagist.org/packages/nomadnt/lumen-passport) +[![License](https://poser.pugx.org/nomadnt/lumen-passport/license)](https://packagist.org/packages/nomadnt/lumen-passport) + + +# Lumen Passport + +Lumen porting of Laravel Passport. +The idea come from https://github.com/dusterio/lumen-passport but try to make it transparent with original laravel passport + +## Dependencies + +* PHP >= 7.3.0 +* Lumen >= 8.0 + +## Installation + +First of all let's install Lumen Framework if you haven't already. + +```sh +composer create-project --prefer-dist laravel/lumen lumen-app && cd lumen-app +``` + +Then install Lumen Passport (it will fetch Laravel Passport along): + +```sh +composer require nomadnt/lumen-passport +``` + +## Configuration + +Generate your APP_KEY and update .env with single command + +```sh +sed -i "s|\(APP_KEY=\)\(.*\)|\1$(openssl rand -base64 24)|" .env +``` + +Configure your database connection (ie to use SQLite) +This is how your .env file should looking after the changes + +```env +APP_NAME=Lumen +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC + +LOG_CHANNEL=stack +LOG_SLACK_WEBHOOK_URL= + +DB_CONNECTION=sqlite + +CACHE_DRIVER=file +QUEUE_CONNECTION=sync +``` + +Copy the Lumen configuration folder to your project + +```sh +cp -a vendor/laravel/lumen-framework/config config +``` + +Update `guards` and `provider` section of your config/auth.php to match Passport requirements + +```php + [ + 'api' => ['driver' => 'passport', 'provider' => 'users'] + ], + + ... + + 'providers' => [ + 'users' => ['driver' => 'eloquent', 'model' => \App\Models\User::class] + ] + + ... +]; +``` + +You need to change a little the `bootstrap/app.php` file doing the following: + +```php +withFacades(); + +// enable eloquent +$app->withEloquent(); + +... + +$app->configure('app'); + +// initialize auth configuration +$app->configure('auth'); + +... + +// enable auth and throttle middleware +$app->routeMiddleware([ + 'auth' => App\Http\Middleware\Authenticate::class, + 'throttle' => Nomadnt\LumenPassport\Middleware\ThrottleRequests::class +]); + +... + +// register required service providers + +// $app->register(App\Providers\AppServiceProvider::class); +$app->register(App\Providers\AuthServiceProvider::class); +$app->register(Laravel\Passport\PassportServiceProvider::class); +// $app->register(App\Providers\EventServiceProvider::class); + +... +``` + +Create database.sqlite + +```sh +touch database/database.sqlite +``` + +Lauch the migrations + +```sh +php artisan migrate +``` + +Install Laravel passport + +```sh +# Install encryption keys and other necessary stuff for Passport +php artisan passport:install +``` + +The previous command should give back to you an output similar to this: + +```sh +Encryption keys generated successfully. +Personal access client created successfully. +Client ID: 1 +Client secret: BxSueZnqimNTE0r98a0Egysq0qnonwkWDUl0KmE5 +Password grant client created successfully. +Client ID: 2 +Client secret: VFWuiJXTJhjb46Y04llOQqSd3kP3goqDLvVIkcIu +``` + +## Registering Routes + +Now is time to register the passport routes necessary to issue access tokens and revoke access tokens, clients, and personal access tokens. +To do this open you `app/Providers/AuthServiceProvider.php` and change the `boot` function to reflect the example below. + +```php +addDays(15)); + + // change the default refresh token expiration + Passport::refreshTokensExpireIn(Carbon::now()->addDays(30)); + } +} + +``` + +## User model + +Make sure your user model uses Passport's `HasApiTokens` trait, eg.: + +```php +register(App\Providers\AppServiceProvider::class); +$app->register(App\Providers\AuthServiceProvider::class); +$app->register(Laravel\Passport\PassportServiceProvider::class); +$app->register(App\Providers\EventServiceProvider::class); + +... +``` + +Then you need to listen for `AccessTokenCreated` event and register your required listeners + +```php + [ + 'App\Listeners\RevokeOtherTokens', + 'App\Listeners\PruneRevokedTokens', + ] + ]; +} +``` + +Create the `app/Listeners/RevokeOtherTokens.php` file and put the following content + +```php +where('user_id', $event->userId); + $query->where('id', '<>', $event->tokenId); + })->revoke(); + } +} +``` + +Create the `app/Listeners/PruneRevokedTokens.php` file and put the following content + +```php +where('user_id', $event->userId); + $query->where('id', '<>', $event->tokenId); + $query->where('revoked', true); + })->delete(); + } +} +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4824a36 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "nomadnt/lumen-passport", + "description": "Lumen porting of Laravel Passport", + "keywords": ["php","lumen","laravel passport"], + "homepage": "https://github.com/nomadnt/lumen-passport", + "license": "MIT", + "authors": [ + { + "name": "Filippo Sallemi", + "email": "fsallemi@nomadnt.com", + "homepage": "https://nomadnt.com" + } + ], + "type": "library", + "require": { + "php": "^7.3.0", + "laravel/passport": "^10.1.0" + }, + "autoload": { + "psr-4": { + "Nomadnt\\LumenPassport\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + } +} diff --git a/src/Middleware/ThrottleRequests.php b/src/Middleware/ThrottleRequests.php new file mode 100644 index 0000000..bc4b98b --- /dev/null +++ b/src/Middleware/ThrottleRequests.php @@ -0,0 +1,192 @@ +limiter = $limiter; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @param int|string $maxAttempts + * @param float|int $decayMinutes + * @return mixed + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1) + { + $key = $this->resolveRequestSignature($request); + + $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts); + + if ($this->limiter->tooManyAttempts($key, $maxAttempts, $decayMinutes)) { + throw $this->buildException($key, $maxAttempts); + } + + $this->limiter->hit($key, $decayMinutes); + + $response = $next($request); + + return $this->addHeaders( + $response, $maxAttempts, + $this->calculateRemainingAttempts($key, $maxAttempts) + ); + } + + /** + * Resolve the number of attempts if the user is authenticated or not. + * + * @param \Illuminate\Http\Request $request + * @param int|string $maxAttempts + * @return int + */ + protected function resolveMaxAttempts($request, $maxAttempts) + { + if (Str::contains($maxAttempts, '|')) { + $maxAttempts = explode('|', $maxAttempts, 2)[$request->user() ? 1 : 0]; + } + + return (int) $maxAttempts; + } + + /** + * Resolve request signature. + * + * @param \Illuminate\Http\Request $request + * @return string + * @throws \RuntimeException + */ + protected function resolveRequestSignature($request) + { + if ($user = $request->user()) { + return sha1($user->getAuthIdentifier()); + } + + if ($route = $request->route()) { + return sha1($request->getHost().'|'.$request->ip()); + //return sha1($request->method().'|'.$request->getHost().'|'.$request->ip()); + } + + throw new RuntimeException( + 'Unable to generate the request signature. Route unavailable.' + ); + } + + /** + * Create a 'too many attempts' exception. + * + * @param string $key + * @param int $maxAttempts + * @return \Symfony\Component\HttpKernel\Exception\HttpException + */ + protected function buildException($key, $maxAttempts) + { + $retryAfter = $this->getTimeUntilNextRetry($key); + + $headers = $this->getHeaders( + $maxAttempts, + $this->calculateRemainingAttempts($key, $maxAttempts, $retryAfter), + $retryAfter + ); + + return new HttpException( + 429, 'Too Many Attempts.', null, $headers + ); + } + + /** + * Get the number of seconds until the next retry. + * + * @param string $key + * @return int + */ + protected function getTimeUntilNextRetry($key) + { + return $this->limiter->availableIn($key); + } + + /** + * Add the limit header information to the given response. + * + * @param \Symfony\Component\HttpFoundation\Response $response + * @param int $maxAttempts + * @param int $remainingAttempts + * @param int|null $retryAfter + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function addHeaders(Response $response, $maxAttempts, $remainingAttempts, $retryAfter = null) + { + $response->headers->add( + $this->getHeaders($maxAttempts, $remainingAttempts, $retryAfter) + ); + + return $response; + } + + /** + * Get the limit headers information. + * + * @param int $maxAttempts + * @param int $remainingAttempts + * @param int|null $retryAfter + * @return array + */ + protected function getHeaders($maxAttempts, $remainingAttempts, $retryAfter = null) + { + $headers = [ + 'X-RateLimit-Limit' => $maxAttempts, + 'X-RateLimit-Remaining' => $remainingAttempts, + ]; + + if (! is_null($retryAfter)) { + $headers['Retry-After'] = $retryAfter; + $headers['X-RateLimit-Reset'] = $this->availableAt($retryAfter); + } + + return $headers; + } + + /** + * Calculate the number of remaining attempts. + * + * @param string $key + * @param int $maxAttempts + * @param int|null $retryAfter + * @return int + */ + protected function calculateRemainingAttempts($key, $maxAttempts, $retryAfter = null) + { + if (is_null($retryAfter)) { + return $this->limiter->retriesLeft($key, $maxAttempts); + } + + return 0; + } +} diff --git a/src/Passport.php b/src/Passport.php new file mode 100644 index 0000000..18a7853 --- /dev/null +++ b/src/Passport.php @@ -0,0 +1,33 @@ +all(); + }; + + $defaultOptions = [ + 'prefix' => 'oauth', + 'namespace' => '\Laravel\Passport\Http\Controllers', + ]; + + $options = array_merge($defaultOptions, $options); + + Route::group($options, function ($router) use ($callback) { + $callback(new RouteRegistrar($router)); + }); + } +} diff --git a/src/RouteRegistrar.php b/src/RouteRegistrar.php new file mode 100644 index 0000000..3d603e1 --- /dev/null +++ b/src/RouteRegistrar.php @@ -0,0 +1,128 @@ +router = $router; + } + + /** + * Register the routes needed for authorization. + * + * @return void + */ + public function forAuthorization() + { + $this->router->group(['middleware' => ['auth']], function ($router) { + $router->get('/authorize', [ + 'uses' => 'AuthorizationController@authorize', + ]); + $router->post('/authorize', [ + 'uses' => 'ApproveAuthorizationController@approve', + ]); + $router->delete('/authorize', [ + 'uses' => 'DenyAuthorizationController@deny', + ]); + }); + } + + /** + * Register the routes for retrieving and issuing access tokens. + * + * @return void + */ + public function forAccessTokens() + { + $this->router->post('/token', [ + 'uses' => 'AccessTokenController@issueToken', + 'middleware' => 'throttle', + ]); + + $this->router->group(['middleware' => ['auth']], function ($router) { + $router->get('/tokens', [ + 'uses' => 'AuthorizedAccessTokenController@forUser', + ]); + + $router->delete('/tokens/{token_id}', [ + 'uses' => 'AuthorizedAccessTokenController@destroy', + ]); + }); + } + + /** + * Register the routes needed for refreshing transient tokens. + * + * @return void + */ + public function forTransientTokens() + { + $this->router->post('/token/refresh', [ + 'middleware' => ['auth'], + 'uses' => 'TransientTokenController@refresh', + ]); + } + + /** + * Register the routes needed for managing clients. + * + * @return void + */ + public function forClients() + { + $this->router->group(['middleware' => ['auth']], function ($router) { + $router->get('/clients', [ + 'uses' => 'ClientController@forUser', + ]); + + $router->post('/clients', [ + 'uses' => 'ClientController@store', + ]); + + $router->put('/clients/{client_id}', [ + 'uses' => 'ClientController@update', + ]); + + $router->delete('/clients/{client_id}', [ + 'uses' => 'ClientController@destroy', + ]); + }); + } + + /** + * Register the routes needed for managing personal access tokens. + * + * @return void + */ + public function forPersonalAccessTokens() + { + $this->router->group(['middleware' => ['auth']], function ($router) { + $router->get('/scopes', [ + 'uses' => 'ScopeController@all', + ]); + + $router->get('/personal-access-tokens', [ + 'uses' => 'PersonalAccessTokenController@forUser', + ]); + + $router->post('/personal-access-tokens', [ + 'uses' => 'PersonalAccessTokenController@store', + ]); + + $router->delete('/personal-access-tokens/{token_id}', [ + 'uses' => 'PersonalAccessTokenController@destroy', + ]); + }); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..651156c --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,14 @@ +getConfigurationPath().($path ? '/'.$path : $path); + } +}