From 05e8cca347ec5a96a1c77f1741d738f7c0441ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F=20=28envoyr=29?= Date: Sun, 27 Apr 2025 04:02:46 +0200 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 269 +++++++++++++ README.stub | 214 ++++++++++ composer.json | 52 +++ config/anikeen-id.php | 8 + generator/generate-docs.php | 85 ++++ oauth-public.key | 14 + phpunit.xml | 29 ++ src/Id/AnikeenId.php | 371 ++++++++++++++++++ src/Id/ApiOperations/Delete.php | 11 + src/Id/ApiOperations/Get.php | 11 + src/Id/ApiOperations/Post.php | 11 + src/Id/ApiOperations/Put.php | 11 + src/Id/ApiOperations/Validation.php | 19 + src/Id/ApiTokenCookieFactory.php | 54 +++ src/Id/Auth/TokenGuard.php | 199 ++++++++++ src/Id/Auth/UserProvider.php | 65 +++ src/Id/Contracts/AppTokenRepository.php | 13 + src/Id/Enums/Scope.php | 16 + src/Id/Exceptions/MissingScopeException.php | 32 ++ src/Id/Exceptions/RateLimitException.php | 14 + .../RequestFreshAccessTokenException.php | 24 ++ ...RequestRequiresAuthenticationException.php | 13 + .../RequestRequiresClientIdException.php | 13 + ...uestRequiresMissingParametersException.php | 22 ++ .../Exceptions/RequestRequiresParameter.php | 13 + .../RequestRequiresRedirectUriException.php | 13 + src/Id/Facades/AnikeenId.php | 21 + src/Id/Helpers/JwtParser.php | 35 ++ src/Id/Helpers/Paginator.php | 76 ++++ .../Middleware/CheckClientCredentials.php | 27 ++ .../CheckClientCredentialsForAnyScope.php | 29 ++ src/Id/Http/Middleware/CheckCredentials.php | 40 ++ src/Id/Http/Middleware/CheckForAnyScope.php | 32 ++ src/Id/Http/Middleware/CheckScopes.php | 32 ++ .../Http/Middleware/CreateFreshApiToken.php | 87 ++++ src/Id/Providers/AnikeenIdServiceProvider.php | 80 ++++ src/Id/Providers/AnikeenIdSsoUserProvider.php | 117 ++++++ src/Id/Repository/AppTokenRepository.php | 57 +++ src/Id/Result.php | 203 ++++++++++ src/Id/Socialite/AnikeenIdExtendSocialite.php | 13 + src/Id/Socialite/Provider.php | 88 +++++ src/Id/Traits/HasAnikeenTokens.php | 43 ++ src/Id/Traits/OauthTrait.php | 36 ++ src/Id/Traits/SshKeysTrait.php | 42 ++ src/Id/Traits/UsersTrait.php | 39 ++ src/Support/Query.php | 26 ++ 47 files changed, 2723 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 README.stub create mode 100644 composer.json create mode 100644 config/anikeen-id.php create mode 100644 generator/generate-docs.php create mode 100644 oauth-public.key create mode 100644 phpunit.xml create mode 100644 src/Id/AnikeenId.php create mode 100644 src/Id/ApiOperations/Delete.php create mode 100644 src/Id/ApiOperations/Get.php create mode 100644 src/Id/ApiOperations/Post.php create mode 100644 src/Id/ApiOperations/Put.php create mode 100644 src/Id/ApiOperations/Validation.php create mode 100644 src/Id/ApiTokenCookieFactory.php create mode 100644 src/Id/Auth/TokenGuard.php create mode 100644 src/Id/Auth/UserProvider.php create mode 100644 src/Id/Contracts/AppTokenRepository.php create mode 100644 src/Id/Enums/Scope.php create mode 100644 src/Id/Exceptions/MissingScopeException.php create mode 100644 src/Id/Exceptions/RateLimitException.php create mode 100644 src/Id/Exceptions/RequestFreshAccessTokenException.php create mode 100644 src/Id/Exceptions/RequestRequiresAuthenticationException.php create mode 100644 src/Id/Exceptions/RequestRequiresClientIdException.php create mode 100644 src/Id/Exceptions/RequestRequiresMissingParametersException.php create mode 100644 src/Id/Exceptions/RequestRequiresParameter.php create mode 100644 src/Id/Exceptions/RequestRequiresRedirectUriException.php create mode 100644 src/Id/Facades/AnikeenId.php create mode 100644 src/Id/Helpers/JwtParser.php create mode 100644 src/Id/Helpers/Paginator.php create mode 100644 src/Id/Http/Middleware/CheckClientCredentials.php create mode 100644 src/Id/Http/Middleware/CheckClientCredentialsForAnyScope.php create mode 100644 src/Id/Http/Middleware/CheckCredentials.php create mode 100644 src/Id/Http/Middleware/CheckForAnyScope.php create mode 100644 src/Id/Http/Middleware/CheckScopes.php create mode 100644 src/Id/Http/Middleware/CreateFreshApiToken.php create mode 100644 src/Id/Providers/AnikeenIdServiceProvider.php create mode 100644 src/Id/Providers/AnikeenIdSsoUserProvider.php create mode 100644 src/Id/Repository/AppTokenRepository.php create mode 100644 src/Id/Result.php create mode 100644 src/Id/Socialite/AnikeenIdExtendSocialite.php create mode 100644 src/Id/Socialite/Provider.php create mode 100644 src/Id/Traits/HasAnikeenTokens.php create mode 100644 src/Id/Traits/OauthTrait.php create mode 100644 src/Id/Traits/SshKeysTrait.php create mode 100644 src/Id/Traits/UsersTrait.php create mode 100644 src/Support/Query.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..417fe5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor +.phpunit.result.cache +.idea +composer.lock \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5195a2c --- /dev/null +++ b/README.md @@ -0,0 +1,269 @@ +# Anikeen ID + +[![Latest Stable Version](https://img.shields.io/packagist/v/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) +[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) +[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) + +PHP Anikeen ID API Client for Laravel 10+ + +## Table of contents + +1. [Installation](#installation) +2. [Event Listener](#event-listener) +3. [Configuration](#configuration) +4. [Examples](#examples) +5. [Documentation](#documentation) +6. [Development](#Development) + +## Installation + +``` +composer require anikeen/id +``` + +## Event Listener + +- Add `SocialiteProviders\Manager\SocialiteWasCalled` event to your `listen[]` array in `app/Providers/EventServiceProvider`. +- Add your listeners (i.e. the ones from the providers) to the `SocialiteProviders\Manager\SocialiteWasCalled[]` that you just created. +- The listener that you add for this provider is `'Anikeen\\Id\\Socialite\\AnikeenIdExtendSocialite@handle',`. +- Note: You do not need to add anything for the built-in socialite providers unless you override them with your own providers. + +``` +/** + * The event handler mappings for the application. + * + * @var array + */ +protected $listen = [ + \SocialiteProviders\Manager\SocialiteWasCalled::class => [ + // add your listeners (aka providers) here + 'Anikeen\\Id\\Socialite\\AnikeenIdExtendSocialite@handle', + ], +]; +``` + +## Configuration + +Copy configuration to config folder: + +``` +$ php artisan vendor:publish --provider="Anikeen\Id\Providers\AnikeenIdServiceProvider" +``` + +Add environmental variables to your `.env` + +``` +ANIKEEN_ID_KEY= +ANIKEEN_ID_SECRET= +ANIKEEN_ID_REDIRECT_URI=http://localhost +``` + +You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. + +**Add to `config/services.php`:** + +```php +'anikeen-id' => [ + 'client_id' => env('ANIKEEN_ID_KEY'), + 'client_secret' => env('ANIKEEN_ID_SECRET'), + 'redirect' => env('ANIKEEN_ID_REDIRECT_URI') +], +``` + +## Implementing Auth + +This method should typically be called in the `boot` method of your `AuthServiceProvider` class: + +```php +use Anikeen\Id\AnikeenId; +use Anikeen\Id\Providers\AnikeenIdSsoUserProvider; +use Illuminate\Http\Request; + +/** + * Register any authentication / authorization services. + * + * @return void + */ +public function boot() +{ + Auth::provider('sso-users', function ($app, array $config) { + return new AnikeenIdSsoUserProvider( + $app->make(AnikeenId::class), + $app->make(Request::class), + $config['model'], + $config['fields'] ?? [], + $config['access_token_field'] ?? null + ); + }); +} +``` + +reference the guard in the `guards` configuration of your `auth.php` configuration file: + +```php +'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'api' => [ + 'driver' => 'anikeen-id', + 'provider' => 'sso-users', + ], +], +``` + +reference the provider in the `providers` configuration of your `auth.php` configuration file: + +```php +'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], + + 'sso-users' => [ + 'driver' => 'sso-users', + 'model' => App\Models\User::class, + 'fields' => ['first_name', 'last_name', 'email'], + 'access_token_field' => 'sso_access_token', + ], +], +``` + +## Examples + +#### Basic + +```php +$anikeenId = new Anikeen\IdAnikeenId(); + +$anikeenId->setClientId('abc123'); + +// Get SSH Key by User ID +$result = $anikeenId->getSshKeysByUserId(38); + +// Check, if the query was successfull +if ( ! $result->success()) { + die('Ooops: ' . $result->error()); +} + +// Shift result to get single key data +$sshKey = $result->shift(); + +echo $sshKey->name; +``` + +#### Setters + +```php +$anikeenId = new Anikeen\Id\AnikeenId(); + +$anikeenId->setClientId('abc123'); +$anikeenId->setClientSecret('abc456'); +$anikeenId->setToken('abcdef123456'); + +$anikeenId = $anikeenId->withClientId('abc123'); +$anikeenId = $anikeenId->withClientSecret('abc123'); +$anikeenId = $anikeenId->withToken('abcdef123456'); +``` + +#### OAuth Tokens + +```php +$anikeenId = new Anikeen\Id\AnikeenId(); + +$anikeenId->setClientId('abc123'); +$anikeenId->setToken('abcdef123456'); + +$result = $anikeenId->getAuthedUser(); + +$user = $userResult->shift(); +``` + +```php +$anikeenId->setToken('uvwxyz456789'); + +$result = $anikeenId->getAuthedUser(); +``` + +```php +$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser(); +``` + +#### Facade + +```php +use Anikeen\Id\Facades\AnikeenId; + +AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); +``` + +## Documentation + +### Oauth + +```php +public function retrievingToken(string $grantType, array $attributes) +``` + +### SshKeys + +```php +public function getSshKeysByUserId(int $id) +public function createSshKey(string $publicKey, string $name = NULL) +public function deleteSshKey(int $id) +``` + +### Users + +```php +public function getAuthedUser() +public function createUser(array $parameters) +public function isEmailExisting(string $email) +``` + +### Delete + +```php + +``` + +### Get + +```php + +``` + +### Post + +```php + +``` + +### Put + +```php + +``` + +[**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Enums/Scope.php) + +## Development + +#### Run Tests + +```shell +composer test +``` + +```shell +BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test +``` + +#### Generate Documentation + +```shell +composer docs +``` diff --git a/README.stub b/README.stub new file mode 100644 index 0000000..757784b --- /dev/null +++ b/README.stub @@ -0,0 +1,214 @@ +# Anikeen ID + +[![Latest Stable Version](https://img.shields.io/packagist/v/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) +[![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) +[![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) + +PHP Anikeen ID API Client for Laravel 11+ + +## Table of contents + +1. [Installation](#installation) +2. [Event Listener](#event-listener) +3. [Configuration](#configuration) +4. [Examples](#examples) +5. [Documentation](#documentation) +6. [Development](#Development) + +## Installation + +``` +composer require anikeen/id +``` + +## Event Listener + +In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` + +``` +Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { + $event->extendSocialite('anikeen-id', \Anikeen\Id\Socialite\Provider::class); +}); +``` + +## Configuration + +Copy configuration to config folder: + +``` +$ php artisan vendor:publish --provider="Anikeen\Id\Providers\AnikeenIdServiceProvider" +``` + +Add environmental variables to your `.env` + +``` +ANIKEEN_ID_KEY= +ANIKEEN_ID_SECRET= +ANIKEEN_ID_REDIRECT_URI=http://localhost +``` + +You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. + +**Add to `config/services.php`:** + +```php +'anikeen-id' => [ + 'client_id' => env('ANIKEEN_ID_KEY'), + 'client_secret' => env('ANIKEEN_ID_SECRET'), + 'redirect' => env('ANIKEEN_ID_REDIRECT_URI') +], +``` + +## Implementing Auth + +This method should typically be called in the `boot` method of your `AuthServiceProvider` class: + +```php +use Anikeen\Id\AnikeenId; +use Anikeen\Id\Providers\AnikeenIdSsoUserProvider; +use Illuminate\Http\Request; + +/** + * Register any authentication / authorization services. + * + * @return void + */ +public function boot() +{ + Auth::provider('sso-users', function ($app, array $config) { + return new AnikeenIdSsoUserProvider( + $app->make(AnikeenId::class), + $app->make(Request::class), + $config['model'], + $config['fields'] ?? [], + $config['access_token_field'] ?? null + ); + }); +} +``` + +reference the guard in the `guards` configuration of your `auth.php` configuration file: + +```php +'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + + 'api' => [ + 'driver' => 'anikeen-id', + 'provider' => 'sso-users', + ], +], +``` + +reference the provider in the `providers` configuration of your `auth.php` configuration file: + +```php +'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\User::class, + ], + + 'sso-users' => [ + 'driver' => 'sso-users', + 'model' => App\Models\User::class, + 'fields' => ['first_name', 'last_name', 'email'], + 'access_token_field' => 'sso_access_token', + ], +], +``` + +## Examples + +#### Basic + +```php +$anikeenId = new Anikeen\IdAnikeenId(); + +$anikeenId->setClientId('abc123'); + +// Get SSH Key by User ID +$result = $anikeenId->getSshKeysByUserId(38); + +// Check, if the query was successfull +if ( ! $result->success()) { + die('Ooops: ' . $result->error()); +} + +// Shift result to get single key data +$sshKey = $result->shift(); + +echo $sshKey->name; +``` + +#### Setters + +```php +$anikeenId = new Anikeen\Id\AnikeenId(); + +$anikeenId->setClientId('abc123'); +$anikeenId->setClientSecret('abc456'); +$anikeenId->setToken('abcdef123456'); + +$anikeenId = $anikeenId->withClientId('abc123'); +$anikeenId = $anikeenId->withClientSecret('abc123'); +$anikeenId = $anikeenId->withToken('abcdef123456'); +``` + +#### OAuth Tokens + +```php +$anikeenId = new Anikeen\Id\AnikeenId(); + +$anikeenId->setClientId('abc123'); +$anikeenId->setToken('abcdef123456'); + +$result = $anikeenId->getAuthedUser(); + +$user = $userResult->shift(); +``` + +```php +$anikeenId->setToken('uvwxyz456789'); + +$result = $anikeenId->getAuthedUser(); +``` + +```php +$result = $anikeenId->withToken('uvwxyz456789')->getAuthedUser(); +``` + +#### Facade + +```php +use Anikeen\Id\Facades\AnikeenId; + +AnikeenId::withClientId('abc123')->withToken('abcdef123456')->getAuthedUser(); +``` + +## Documentation + + + +[**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Enums/Scope.php) + +## Development + +#### Run Tests + +```shell +composer test +``` + +```shell +BASE_URL=xxxx CLIENT_ID=xxxx CLIENT_KEY=yyyy CLIENT_ACCESS_TOKEN=zzzz composer test +``` + +#### Generate Documentation + +```shell +composer docs +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f99d117 --- /dev/null +++ b/composer.json @@ -0,0 +1,52 @@ +{ + "name": "anikeen/id", + "description": "PHP AnikeenId API Client for Laravel 10+", + "license": "MIT", + "authors": [ + { + "name": "René Preuß", + "email": "rene@anikeen.com" + }, + { + "name": "Maurice Preuß", + "email": "maurice@anikeen.com" + } + ], + "require": { + "php": "^8.0", + "ext-json": "*", + "illuminate/support": "^11.0|^12.0", + "illuminate/console": "^11.0|^12.0", + "guzzlehttp/guzzle": "^6.3|^7.0", + "socialiteproviders/manager": "^3.4|^4.0.1", + "firebase/php-jwt": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "Anikeen\\Id\\": "src/Id", + "Anikeen\\Support\\": "src/Support" + } + }, + "autoload-dev": { + "psr-4": { + "Anikeen\\Id\\Tests\\": "tests/Id" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "docs": "php generator/generate-docs.php" + }, + "extra": { + "laravel": { + "providers": [ + "Anikeen\\Id\\Providers\\AnikeenIdServiceProvider" + ], + "aliases": { + "AnikeenId": "Anikeen\\Id\\Facades\\AnikeenId" + } + } + } +} \ No newline at end of file diff --git a/config/anikeen-id.php b/config/anikeen-id.php new file mode 100644 index 0000000..0335805 --- /dev/null +++ b/config/anikeen-id.php @@ -0,0 +1,8 @@ + env('ANIKEEN_ID_KEY'), + 'client_secret' => env('ANIKEEN_ID_SECRET'), + 'redirect_url' => env('ANIKEEN_ID_REDIRECT_URI'), + 'base_url' => env('ANIKEEN_ID_BASE_URL'), +]; \ No newline at end of file diff --git a/generator/generate-docs.php b/generator/generate-docs.php new file mode 100644 index 0000000..68d1fbe --- /dev/null +++ b/generator/generate-docs.php @@ -0,0 +1,85 @@ +map(function ($trait) { + + $title = str_replace('Trait', '', Arr::last(explode('\\', $trait))); + + $methods = []; + + $reflection = new ReflectionClass($trait); + + collect($reflection->getMethods()) + ->reject(function (ReflectionMethod $method) { + return $method->isAbstract(); + }) + ->reject(function (ReflectionMethod $method) { + return $method->isPrivate() || $method->isProtected(); + }) + ->reject(function (ReflectionMethod $method) { + return $method->isConstructor(); + }) + ->each(function (ReflectionMethod $method) use (&$methods, $title, $trait) { + + $declaration = collect($method->getModifiers())->map(function (int $modifier) { + return $modifier == ReflectionMethod::IS_PUBLIC ? 'public ' : ''; + })->join(' '); + + $declaration .= 'function '; + $declaration .= $method->getName(); + $declaration .= '('; + + $declaration .= collect($method->getParameters())->map(function (ReflectionParameter $parameter) { + + $parameterString = Arr::last(explode('\\', $parameter->getType()->getName())); + $parameterString .= ' '; + $parameterString .= '$'; + $parameterString .= $parameter->getName(); + + if ($parameter->isDefaultValueAvailable()) { + $parameterString .= ' = '; + $parameterString .= str_replace(PHP_EOL, '', var_export($parameter->getDefaultValue(), true)); + } + + return $parameterString; + + })->join(', '); + + $declaration .= ')'; + + $methods[] = $declaration; + }); + + return [$title, $methods]; + }) + ->map(function ($args) { + + list($title, $methods) = $args; + + $markdown = '### ' . $title; + $markdown .= PHP_EOL . PHP_EOL; + $markdown .= '```php'; + $markdown .= PHP_EOL; + + $markdown .= collect($methods)->each(function ($method) { + return $method; + })->implode(PHP_EOL); + + $markdown .= PHP_EOL; + $markdown .= '```'; + + return $markdown; + })->join(PHP_EOL . PHP_EOL); + +$markdown = str_replace("array (\n)", '[]', $markdown); + +$content = file_get_contents(__DIR__ . '/../README.stub'); + +$content = str_replace('', $markdown, $content); + +file_put_contents(__DIR__ . '/../README.md', $content); \ No newline at end of file diff --git a/oauth-public.key b/oauth-public.key new file mode 100644 index 0000000..0e87400 --- /dev/null +++ b/oauth-public.key @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtpfqwoXAEOYbKIU0eBso +J0Amh40GsVKiZxPZ+GVftr96SQWI+wXGfFHAL9vNlucJqI0l2lsOEwI9UKr99w3M +borB9YmGhd15wX1Es5SYTEA6vo1qsLVz2gqDCW5AkmwI42WFWvBr9nR9+8qoNGgZ +neE2HLEq0BvtDBeebmWfurOJzAvRrXBj1qjYro50G5vFlTzga46P6I6y9/JevDFX +0IZVtPcUvvVfaOVu+oy2v5ET0k1KRXDq8ShR9oJbh9N/SOvzjS0tv7R3XfU6kIgF +SWw6nc9NOtpFfgscYLaVETNSF/HSW+UCYg0/aWKrcI0j/K+StfRArmeQE+rGysvt +4YPfH/4TztfqtirZKmCBSXnjyKpzWbrBE7ahxBqXhvF8vwegxFjfLs/aqAAXYgyN +X9zz91GobEdNBKct9whNl+OgGEIsfacEniPkQsRws/MDVhlrUyPu2R54sgrtboNf +wpuuz0hbBqjHjdPAAojRRdqp6EUXEn+8K0CTXqpogC30pGam4RsMgqP9/3kYjgQC +QQOoP/4hM6piKExf2JPQoc9AbVN9qbtKXZANfrEMbXjNd8a0yn65w68+lS959SkG +86NQxNCcmL07p/AtZMokZajuFsZEv1ezPt2IjCfUsjuB2+04EyCvBFv8q2GJQN39 +suV26MnzxHOzo95+ViVwrAECAwEAAQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fcdfeaa --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,29 @@ + + + + + tests + + + + + src + + + + + + + + + \ No newline at end of file diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php new file mode 100644 index 0000000..9bc7870 --- /dev/null +++ b/src/Id/AnikeenId.php @@ -0,0 +1,371 @@ +setClientId($clientId); + } + if ($clientSecret = config('anikeen_id.client_secret')) { + $this->setClientSecret($clientSecret); + } + if ($redirectUri = config('anikeen_id.redirect_url')) { + $this->setRedirectUri($redirectUri); + } + if ($redirectUri = config('anikeen_id.base_url')) { + self::setBaseUrl($redirectUri); + } + $this->client = new Client([ + 'base_uri' => self::$baseUrl, + ]); + } + + /** + * @param string $baseUrl + * + * @internal only for internal and debug purposes. + */ + public static function setBaseUrl(string $baseUrl): void + { + self::$baseUrl = $baseUrl; + } + + /** + * Get or set the name for API token cookies. + * + * @param string|null $cookie + * @return string|static + */ + public static function cookie(string $cookie = null) + { + if (is_null($cookie)) { + return static::$cookie; + } + + static::$cookie = $cookie; + + return new static; + } + + /** + * Set the current user for the application with the given scopes. + */ + public static function actingAs(Authenticatable|Traits\HasAnikeenTokens $user, array $scopes = [], string $guard = 'api'): Authenticatable + { + $user->withAnikeenAccessToken((object)[ + 'scopes' => $scopes + ]); + + if (isset($user->wasRecentlyCreated) && $user->wasRecentlyCreated) { + $user->wasRecentlyCreated = false; + } + + app('auth')->guard($guard)->setUser($user); + + app('auth')->shouldUse($guard); + + return $user; + } + + /** + * Fluid client id setter. + */ + public function withClientId(string $clientId): self + { + $this->setClientId($clientId); + + return $this; + } + + /** + * Get client secret. + * + * @throws RequestRequiresClientIdException + */ + public function getClientSecret(): string + { + if (!$this->clientSecret) { + throw new RequestRequiresClientIdException; + } + + return $this->clientSecret; + } + + /** + * Set client secret. + */ + public function setClientSecret(string $clientSecret): void + { + $this->clientSecret = $clientSecret; + } + + /** + * Fluid client secret setter. + */ + public function withClientSecret(string $clientSecret): self + { + $this->setClientSecret($clientSecret); + + return $this; + } + + /** + * Get redirect url. + * + * @throws RequestRequiresRedirectUriException + */ + public function getRedirectUri(): string + { + if (!$this->redirectUri) { + throw new RequestRequiresRedirectUriException; + } + + return $this->redirectUri; + } + + /** + * Set redirect url. + */ + public function setRedirectUri(string $redirectUri): void + { + $this->redirectUri = $redirectUri; + } + + /** + * Fluid redirect url setter. + */ + public function withRedirectUri(string $redirectUri): self + { + $this->setRedirectUri($redirectUri); + + return $this; + } + + /** + * Get OAuth token. + * + * @throws RequestRequiresAuthenticationException + */ + public function getToken(): ?string + { + if (!$this->token) { + throw new RequestRequiresAuthenticationException; + } + + return $this->token; + } + + /** + * Set OAuth token. + */ + public function setToken(string $token): void + { + $this->token = $token; + } + + /** + * Fluid OAuth token setter. + */ + public function withToken(string $token): self + { + $this->setToken($token); + + return $this; + } + + /** + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function get(string $path = '', array $parameters = [], Paginator $paginator = null): Result + { + return $this->query('GET', $path, $parameters, $paginator); + } + + /** + * Build query & execute. + * + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function query(string $method = 'GET', string $path = '', array $parameters = [], Paginator $paginator = null, mixed $jsonBody = null): Result + { + /** @noinspection DuplicatedCode */ + if ($paginator !== null) { + $parameters[$paginator->action] = $paginator->cursor(); + } + try { + $response = $this->client->request($method, $path, [ + 'headers' => $this->buildHeaders((bool)$jsonBody), + 'query' => Query::build($parameters), + 'json' => $jsonBody ?: null, + ]); + $result = new Result($response, null, $paginator); + } catch (RequestException $exception) { + $result = new Result($exception->getResponse(), $exception, $paginator); + } + $result->anikeenId = $this; + + return $result; + } + + /** + * Build headers for request. + * + * @throws RequestRequiresClientIdException + */ + private function buildHeaders(bool $json = false): array + { + $headers = [ + 'Client-ID' => $this->getClientId(), + 'Accept' => 'application/json', + ]; + if ($this->token) { + $headers['Authorization'] = 'Bearer ' . $this->token; + } + if ($json) { + $headers['Content-Type'] = 'application/json'; + } + + return $headers; + } + + /** + * Get client id. + * + * @throws RequestRequiresClientIdException + */ + public function getClientId(): string + { + if (!$this->clientId) { + throw new RequestRequiresClientIdException; + } + + return $this->clientId; + } + + /** + * Set client id. + */ + public function setClientId(string $clientId): void + { + $this->clientId = $clientId; + } + + /** + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function post(string $path = '', array $parameters = [], Paginator $paginator = null): Result + { + return $this->query('POST', $path, $parameters, $paginator); + } + + /** + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function delete(string $path = '', array $parameters = [], Paginator $paginator = null): Result + { + return $this->query('DELETE', $path, $parameters, $paginator); + } + + /** + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function put(string $path = '', array $parameters = [], Paginator $paginator = null): Result + { + return $this->query('PUT', $path, $parameters, $paginator); + } + + /** + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function json(string $method, string $path = '', array $body = null): Result + { + if ($body) { + $body = json_encode(['data' => $body]); + } + + return $this->query($method, $path, [], null, $body); + } +} diff --git a/src/Id/ApiOperations/Delete.php b/src/Id/ApiOperations/Delete.php new file mode 100644 index 0000000..1c46638 --- /dev/null +++ b/src/Id/ApiOperations/Delete.php @@ -0,0 +1,11 @@ +config->get('session'); + + $expiration = Carbon::now()->addMinutes($config['lifetime']); + + return new Cookie( + AnikeenId::cookie(), + $this->createToken($userId, $csrfToken, $expiration), + $expiration, + $config['path'], + $config['domain'], + $config['secure'], + true, + false, + $config['same_site'] ?? null + ); + } + + /** + * Create a new JWT token for the given user ID and CSRF token. + */ + protected function createToken(mixed $userId, string $csrfToken, Carbon $expiration): string + { + return JWT::encode([ + 'sub' => $userId, + 'csrf' => $csrfToken, + 'expiry' => $expiration->getTimestamp(), + ], $this->encrypter->getKey(), 'HS256'); + } +} \ No newline at end of file diff --git a/src/Id/Auth/TokenGuard.php b/src/Id/Auth/TokenGuard.php new file mode 100644 index 0000000..bb55b25 --- /dev/null +++ b/src/Id/Auth/TokenGuard.php @@ -0,0 +1,199 @@ +provider = $provider; + $this->encrypter = $encrypter; + $this->jwtParser = $jwtParser; + } + + /** + * Get the user for the incoming request. + * + * @throws BindingResolutionException + * @throws Throwable + */ + public function user(Request $request): ?Authenticatable + { + if ($request->bearerToken()) { + return $this->authenticateViaBearerToken($request); + } elseif ($request->cookie(AnikeenId::cookie())) { + return $this->authenticateViaCookie($request); + } + + return null; + } + + /** + * Authenticate the incoming request via the Bearer token. + * + * @throws BindingResolutionException + * @throws Throwable + */ + protected function authenticateViaBearerToken(Request $request): ?Authenticatable + { + if (!$token = $this->validateRequestViaBearerToken($request)) { + return null; + } + + // If the access token is valid we will retrieve the user according to the user ID + // associated with the token. We will use the provider implementation which may + // be used to retrieve users from Eloquent. Next, we'll be ready to continue. + /** @var Authenticatable|HasAnikeenTokens $user */ + $user = $this->provider->retrieveById( + $request->attributes->get('oauth_user_id') ?: null + ); + + return $user?->withAnikeenAccessToken($token); + + } + + /** + * Authenticate and get the incoming request via the Bearer token. + * + * @throws BindingResolutionException + * @throws Throwable + */ + protected function validateRequestViaBearerToken(Request $request): ?stdClass + { + try { + $decoded = $this->jwtParser->decode($request); + + $request->attributes->set('oauth_access_token_id', $decoded->jti); + $request->attributes->set('oauth_client_id', $decoded->aud); + $request->attributes->set('oauth_client_trusted', $decoded->client->trusted); + $request->attributes->set('oauth_user_id', $decoded->sub); + $request->attributes->set('oauth_scopes', $decoded->scopes); + + return $decoded; + } catch (AuthenticationException $e) { + $request->headers->set('Authorization', '', true); + + Container::getInstance()->make( + ExceptionHandler::class + )->report($e); + + return null; + } + } + + /** + * Authenticate the incoming request via the token cookie. + */ + protected function authenticateViaCookie(Request $request): mixed + { + if (!$token = $this->getTokenViaCookie($request)) { + return null; + } + + // If this user exists, we will return this user and attach a "transient" token to + // the user model. The transient token assumes it has all scopes since the user + // is physically logged into the application via the application's interface. + /** @var Authenticatable|HasAnikeenTokens $user */ + if ($user = $this->provider->retrieveById($token['sub'])) { + return $user->withAnikeenAccessToken((object)['scopes' => ['*']]); + } + + return null; + } + + /** + * Get the token cookie via the incoming request. + */ + protected function getTokenViaCookie(Request $request): ?array + { + // If we need to retrieve the token from the cookie, it'll be encrypted so we must + // first decrypt the cookie and then attempt to find the token value within the + // database. If we can't decrypt the value we'll bail out with a null return. + try { + $token = $this->decodeJwtTokenCookie($request); + } catch (Exception $e) { + return null; + } + + // We will compare the CSRF token in the decoded API token against the CSRF header + // sent with the request. If they don't match then this request isn't sent from + // a valid source and we won't authenticate the request for further handling. + if (!AnikeenId::$ignoreCsrfToken && (!$this->validCsrf($token, $request) || + time() >= $token['expiry'])) { + return null; + } + + return $token; + } + + /** + * Decode and decrypt the JWT token cookie. + */ + protected function decodeJwtTokenCookie(Request $request): array + { + return (array)JWT::decode( + CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)), + new Key( + $this->encrypter->getKey(), + 'HS256' + ) + ); + } + + /** + * Determine if the CSRF / header are valid and match. + */ + protected function validCsrf(array $token, Request $request): bool + { + return isset($token['csrf']) && hash_equals( + $token['csrf'], $this->getTokenFromRequest($request) + ); + } + + /** + * Get the CSRF token from the request. + */ + protected function getTokenFromRequest(Request $request): string + { + $token = $request->header('X-CSRF-TOKEN'); + + if (!$token && $header = $request->header('X-XSRF-TOKEN')) { + $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized())); + } + + return $token; + } + + /** + * Determine if the cookie contents should be serialized. + */ + public static function serialized(): bool + { + return EncryptCookies::serialized('XSRF-TOKEN'); + } +} \ No newline at end of file diff --git a/src/Id/Auth/UserProvider.php b/src/Id/Auth/UserProvider.php new file mode 100644 index 0000000..e2ccb00 --- /dev/null +++ b/src/Id/Auth/UserProvider.php @@ -0,0 +1,65 @@ +provider->retrieveById($identifier); + } + + /** + * {@inheritdoc} + */ + public function retrieveByToken($identifier, $token): ?Authenticatable + { + return $this->provider->retrieveByToken($identifier, $token); + } + + /** + * {@inheritdoc} + */ + public function updateRememberToken(Authenticatable $user, $token): void + { + $this->provider->updateRememberToken($user, $token); + } + + /** + * {@inheritdoc} + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + return $this->provider->retrieveByCredentials($credentials); + } + + /** + * {@inheritdoc} + */ + public function validateCredentials(Authenticatable $user, array $credentials): bool + { + return $this->provider->validateCredentials($user, $credentials); + } + + /** + * Get the name of the user provider. + */ + public function getProviderName(): string + { + return $this->providerName; + } +} diff --git a/src/Id/Contracts/AppTokenRepository.php b/src/Id/Contracts/AppTokenRepository.php new file mode 100644 index 0000000..fff3882 --- /dev/null +++ b/src/Id/Contracts/AppTokenRepository.php @@ -0,0 +1,13 @@ +scopes = Arr::wrap($scopes); + } + + /** + * Get the scopes that the user did not have. + */ + public function scopes(): array + { + return $this->scopes; + } +} \ No newline at end of file diff --git a/src/Id/Exceptions/RateLimitException.php b/src/Id/Exceptions/RateLimitException.php new file mode 100644 index 0000000..61a374e --- /dev/null +++ b/src/Id/Exceptions/RateLimitException.php @@ -0,0 +1,14 @@ +getStatusCode())); + $instance->response = $response; + + return $instance; + } + + public function getResponse(): ResponseInterface + { + return $this->response; + } +} \ No newline at end of file diff --git a/src/Id/Exceptions/RequestRequiresAuthenticationException.php b/src/Id/Exceptions/RequestRequiresAuthenticationException.php new file mode 100644 index 0000000..9d61140 --- /dev/null +++ b/src/Id/Exceptions/RequestRequiresAuthenticationException.php @@ -0,0 +1,13 @@ +bearerToken(), + new Key($this->getOauthPublicKey(), 'RS256') + ); + } catch (Throwable $exception) { + throw (new AuthenticationException()); + } + } + + private function getOauthPublicKey(): bool|string + { + return file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key'); + } +} diff --git a/src/Id/Helpers/Paginator.php b/src/Id/Helpers/Paginator.php new file mode 100644 index 0000000..6151594 --- /dev/null +++ b/src/Id/Helpers/Paginator.php @@ -0,0 +1,76 @@ +pagination = $pagination; + } + + /** + * Create Paginator from Result object. + */ + public static function from(Result $result): self + { + return new self($result->pagination); + } + + /** + * Return the current active cursor. + */ + public function cursor(): string + { + return $this->pagination->cursor; + } + + /** + * Set the Paginator to fetch the next set of results. + */ + public function first(): self + { + $this->action = 'first'; + + return $this; + } + + /** + * Set the Paginator to fetch the first set of results. + */ + public function next(): self + { + $this->action = 'after'; + + return $this; + } + + /** + * Set the Paginator to fetch the last set of results. + */ + public function back(): self + { + $this->action = 'before'; + + return $this; + } +} \ No newline at end of file diff --git a/src/Id/Http/Middleware/CheckClientCredentials.php b/src/Id/Http/Middleware/CheckClientCredentials.php new file mode 100644 index 0000000..4e94241 --- /dev/null +++ b/src/Id/Http/Middleware/CheckClientCredentials.php @@ -0,0 +1,27 @@ +scopes)) { + return; + } + + foreach ($scopes as $scope) { + if (!in_array($scope, $token->scopes)) { + throw new MissingScopeException($scopes); + } + } + } +} \ No newline at end of file diff --git a/src/Id/Http/Middleware/CheckClientCredentialsForAnyScope.php b/src/Id/Http/Middleware/CheckClientCredentialsForAnyScope.php new file mode 100644 index 0000000..8e8106e --- /dev/null +++ b/src/Id/Http/Middleware/CheckClientCredentialsForAnyScope.php @@ -0,0 +1,29 @@ +scopes)) { + return; + } + + foreach ($scopes as $scope) { + if (in_array($scope, $token->scopes)) { + return; + } + } + + throw new MissingScopeException($scopes); + } +} \ No newline at end of file diff --git a/src/Id/Http/Middleware/CheckCredentials.php b/src/Id/Http/Middleware/CheckCredentials.php new file mode 100644 index 0000000..701094d --- /dev/null +++ b/src/Id/Http/Middleware/CheckCredentials.php @@ -0,0 +1,40 @@ +getJwtParser()->decode($request); + + $request->attributes->set('oauth_access_token_id', $decoded->jti); + $request->attributes->set('oauth_client_id', $decoded->aud); + //$request->attributes->set('oauth_client_trusted', $decoded->client->trusted); + $request->attributes->set('oauth_user_id', $decoded->sub); + $request->attributes->set('oauth_scopes', $decoded->scopes); + + $this->validateScopes($decoded, $scopes); + + return $next($request); + } + + private function getJwtParser(): JwtParser + { + return app(JwtParser::class); + } + + abstract protected function validateScopes(stdClass $token, array $scopes); +} \ No newline at end of file diff --git a/src/Id/Http/Middleware/CheckForAnyScope.php b/src/Id/Http/Middleware/CheckForAnyScope.php new file mode 100644 index 0000000..d77824e --- /dev/null +++ b/src/Id/Http/Middleware/CheckForAnyScope.php @@ -0,0 +1,32 @@ +user() || !$request->user()->anikeenToken()) { + throw new AuthenticationException; + } + + foreach ($scopes as $scope) { + if ($request->user()->anikeenTokenCan($scope)) { + return $next($request); + } + } + + throw new MissingScopeException($scopes); + } +} \ No newline at end of file diff --git a/src/Id/Http/Middleware/CheckScopes.php b/src/Id/Http/Middleware/CheckScopes.php new file mode 100644 index 0000000..2a40197 --- /dev/null +++ b/src/Id/Http/Middleware/CheckScopes.php @@ -0,0 +1,32 @@ +user() || !$request->user()->anikeenToken()) { + throw new AuthenticationException; + } + + foreach ($scopes as $scope) { + if (!$request->user()->anikeenTokenCan($scope)) { + throw new MissingScopeException($scope); + } + } + + return $next($request); + } +} diff --git a/src/Id/Http/Middleware/CreateFreshApiToken.php b/src/Id/Http/Middleware/CreateFreshApiToken.php new file mode 100644 index 0000000..4491c0b --- /dev/null +++ b/src/Id/Http/Middleware/CreateFreshApiToken.php @@ -0,0 +1,87 @@ +guard = $guard; + + $response = $next($request); + + if ($this->shouldReceiveFreshToken($request, $response)) { + $response->withCookie($this->cookieFactory->make( + $request->user($this->guard)->getAuthIdentifier(), $request->session()->token() + )); + } + + return $response; + } + + /** + * Determine if the given request should receive a fresh token. + */ + protected function shouldReceiveFreshToken(Request $request, Response $response): bool + { + return $this->requestShouldReceiveFreshToken($request) && + $this->responseShouldReceiveFreshToken($response); + } + + /** + * Determine if the request should receive a fresh token. + */ + protected function requestShouldReceiveFreshToken(Request $request): bool + { + return $request->isMethod('GET') && $request->user($this->guard); + } + + /** + * Determine if the response should receive a fresh token. + */ + protected function responseShouldReceiveFreshToken(Response $response): bool + { + return !$this->alreadyContainsToken($response); + } + + /** + * Determine if the given response already contains an API token. + * This avoids us overwriting a just "refreshed" token. + */ + protected function alreadyContainsToken(Response $response): bool + { + foreach ($response->headers->getCookies() as $cookie) { + if ($cookie->getName() === AnikeenId::cookie()) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/Id/Providers/AnikeenIdServiceProvider.php b/src/Id/Providers/AnikeenIdServiceProvider.php new file mode 100644 index 0000000..f383b86 --- /dev/null +++ b/src/Id/Providers/AnikeenIdServiceProvider.php @@ -0,0 +1,80 @@ +publishes([ + dirname(__DIR__, 3) . '/config/anikeen-id.php' => config_path('anikeen-id.php'), + ], 'config'); + } + + /** + * Register the application services. + */ + public function register(): void + { + $this->mergeConfigFrom(dirname(__DIR__, 3) . '/config/anikeen-id.php', 'anikeen-id'); + $this->app->singleton(Contracts\AppTokenRepository::class, Repository\AppTokenRepository::class); + $this->app->singleton(AnikeenId::class, function () { + return new AnikeenId; + }); + + $this->registerGuard(); + } + + /** + * Register the token guard. + */ + protected function registerGuard(): void + { + Auth::resolved(function ($auth) { + $auth->extend('anikeen-id', function ($app, $name, array $config) { + return tap($this->makeGuard($config), function ($guard) { + $this->app->refresh('request', $guard, 'setRequest'); + }); + }); + }); + } + + /** + * Make an instance of the token guard. + */ + protected function makeGuard(array $config): RequestGuard + { + return new RequestGuard(function ($request) use ($config) { + return (new TokenGuard( + new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']), + $this->app->make('encrypter'), + $this->app->make(JwtParser::class) + ))->user($request); + }, $this->app['request']); + } + + /** + * Get the services provided by the provider. + */ + public function provides(): array + { + return [ + AnikeenId::class, + ]; + } +} diff --git a/src/Id/Providers/AnikeenIdSsoUserProvider.php b/src/Id/Providers/AnikeenIdSsoUserProvider.php new file mode 100644 index 0000000..1ddd66f --- /dev/null +++ b/src/Id/Providers/AnikeenIdSsoUserProvider.php @@ -0,0 +1,117 @@ +request = $request; + $this->model = $model; + $this->fields = $fields; + $this->accessTokenField = $accessTokenField; + $this->anikeenId = $anikeenId; + } + + public function retrieveById(mixed $identifier): Builder|Model|null + { + $model = $this->createModel(); + $token = $this->request->bearerToken(); + + $user = $this->newModelQuery($model) + ->where($model->getAuthIdentifierName(), $identifier) + ->first(); + + // Return user when found + if ($user) { + // Update access token when updated + if ($this->accessTokenField) { + $user[$this->accessTokenField] = $token; + + if ($user->isDirty()) { + $user->save(); + } + } + + return $user; + } + + // Create new user + $this->anikeenId->setToken($token); + $result = $this->anikeenId->getAuthedUser(); + + if (!$result->success()) { + return null; + } + + $attributes = Arr::only((array)$result->data(), $this->fields); + $attributes[$model->getAuthIdentifierName()] = $result->data->id; + + if ($this->accessTokenField) { + $attributes[$this->accessTokenField] = $token; + } + + return $this->newModelQuery($model)->create($attributes); + } + + /** + * Create a new instance of the model. + */ + public function createModel(): Model + { + $class = '\\' . ltrim($this->model, '\\'); + + return new $class; + } + + /** + * Get a new query builder for the model instance. + */ + protected function newModelQuery(?Model $model = null): Builder + { + return is_null($model) + ? $this->createModel()->newQuery() + : $model->newQuery(); + } + + public function retrieveByToken($identifier, $token) + { + return null; + } + + public function updateRememberToken(Authenticatable $user, $token) + { + // void + } + + public function retrieveByCredentials(array $credentials) + { + return null; + } + + public function validateCredentials(Authenticatable $user, array $credentials): bool + { + return false; + } +} diff --git a/src/Id/Repository/AppTokenRepository.php b/src/Id/Repository/AppTokenRepository.php new file mode 100644 index 0000000..158bc72 --- /dev/null +++ b/src/Id/Repository/AppTokenRepository.php @@ -0,0 +1,57 @@ +client = app(AnikeenId::class); + } + + /** + * {@inheritDoc} + */ + public function getAccessToken(): string + { + $accessToken = Cache::get(self::ACCESS_TOKEN_CACHE_KEY); + + if ($accessToken) { + return $accessToken; + } + + return $this->requestFreshAccessToken('*'); + } + + /** + * @throws RequestFreshAccessTokenException + */ + private function requestFreshAccessToken(string $scope): mixed + { + $result = $this->getClient()->retrievingToken('client_credentials', [ + 'scope' => $scope, + ]); + + if (!$result->success()) { + throw RequestFreshAccessTokenException::fromResponse($result->response()); + } + + Cache::put(self::ACCESS_TOKEN_CACHE_KEY, $accessToken = $result->data()->access_token, now()->addWeek()); + + return $accessToken; + } + + private function getClient(): AnikeenId + { + return $this->client; + } +} \ No newline at end of file diff --git a/src/Id/Result.php b/src/Id/Result.php new file mode 100644 index 0000000..d3755fc --- /dev/null +++ b/src/Id/Result.php @@ -0,0 +1,203 @@ +success = $exception === null; + $this->status = $response ? $response->getStatusCode() : 500; + $jsonResponse = $response ? @json_decode($response->getBody()->getContents(), false) : null; + if ($jsonResponse !== null) { + $this->setProperty($jsonResponse, 'data'); + $this->setProperty($jsonResponse, 'total'); + $this->setProperty($jsonResponse, 'pagination'); + $this->paginator = Paginator::from($this); + } + } + + /** + * Sets a class attribute by given JSON Response Body. + */ + private function setProperty(stdClass $jsonResponse, string $responseProperty, string $attribute = null): void + { + $classAttribute = $attribute ?? $responseProperty; + if (property_exists($jsonResponse, $responseProperty)) { + $this->{$classAttribute} = $jsonResponse->{$responseProperty}; + } elseif ($responseProperty === 'data') { + $this->{$classAttribute} = $jsonResponse; + } + } + + /** + * Returns whether the query was successfully. + */ + public function success(): bool + { + return $this->success; + } + + /** + * Returns the last HTTP or API error. + */ + public function error(): string + { + // TODO Switch Exception response parsing to this->data + if ($this->exception === null || !$this->exception->hasResponse()) { + return 'Anikeen ID API Unavailable'; + } + $exception = (string)$this->exception->getResponse()->getBody(); + $exception = @json_decode($exception); + if (property_exists($exception, 'message') && !empty($exception->message)) { + return $exception->message; + } + + return $this->exception->getMessage(); + } + + /** + * Shifts the current result (Use for single user/video etc. query). + */ + public function shift(): mixed + { + if (!empty($this->data)) { + $data = $this->data; + + return array_shift($data); + } + + return null; + } + + /** + * Return the current count of items in dataset. + */ + public function count(): int + { + return count($this->data); + } + + /** + * Set the Paginator to fetch the next set of results. + */ + public function next(): ?Paginator + { + return $this->paginator?->next(); + } + + /** + * Set the Paginator to fetch the last set of results. + */ + public function back(): ?Paginator + { + return $this->paginator?->back(); + } + + /** + * Get rate limit information. + */ + public function rateLimit(string $key = null): array|int|string|null + { + if (!$this->response) { + return null; + } + $rateLimit = [ + 'limit' => (int)$this->response->getHeaderLine('X-RateLimit-Limit'), + 'remaining' => (int)$this->response->getHeaderLine('X-RateLimit-Remaining'), + 'reset' => (int)$this->response->getHeaderLine('Retry-After'), + ]; + if ($key === null) { + return $rateLimit; + } + + return $rateLimit[$key]; + } + + /** + * Insert users in data response. + */ + public function insertUsers(string $identifierAttribute = 'user_id', string $insertTo = 'user'): self + { + $data = $this->data; + $userIds = collect($data)->map(function ($item) use ($identifierAttribute) { + return $item->{$identifierAttribute}; + })->toArray(); + if (count($userIds) === 0) { + return $this; + } + $users = collect($this->anikeenId->getUsersByIds($userIds)->data); + $dataWithUsers = collect($data)->map(function ($item) use ($users, $identifierAttribute, $insertTo) { + $item->$insertTo = $users->where('id', $item->{$identifierAttribute})->first(); + + return $item; + }); + $this->data = $dataWithUsers->toArray(); + + return $this; + } + + /** + * Set the Paginator to fetch the first set of results. + */ + public function first(): ?Paginator + { + return $this->paginator?->first(); + } + + public function response(): ?ResponseInterface + { + return $this->response; + } + + public function dump(): void + { + dump($this->data()); + } + + /** + * Get the response data, also available as public attribute. + */ + public function data(): array + { + return $this->data; + } +} \ No newline at end of file diff --git a/src/Id/Socialite/AnikeenIdExtendSocialite.php b/src/Id/Socialite/AnikeenIdExtendSocialite.php new file mode 100644 index 0000000..b26352b --- /dev/null +++ b/src/Id/Socialite/AnikeenIdExtendSocialite.php @@ -0,0 +1,13 @@ +extendSocialite('anikeen-id', Provider::class); + } +} \ No newline at end of file diff --git a/src/Id/Socialite/Provider.php b/src/Id/Socialite/Provider.php new file mode 100644 index 0000000..0166a10 --- /dev/null +++ b/src/Id/Socialite/Provider.php @@ -0,0 +1,88 @@ +buildAuthUrlFromBase( + 'https://id.anikeen.com/oauth/authorize', $state + ); + } + + /** + * {@inheritdoc} + */ + protected function getTokenUrl(): string + { + return 'https://id.anikeen.com/oauth/token'; + } + + /** + * {@inheritdoc} + * + * @throws GuzzleException + */ + protected function getUserByToken($token) + { + $response = $this->getHttpClient()->get( + 'https://id.anikeen.com/api/v1/user', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ], + ]); + + return json_decode($response->getBody()->getContents(), true)['data']; + } + + /** + * {@inheritdoc} + */ + protected function mapUserToObject(array $user): \Laravel\Socialite\Two\User|User + { + return (new User())->setRaw($user)->map([ + 'id' => $user['id'], + 'nickname' => $user['username'], + 'name' => $user['name'], + 'email' => Arr::get($user, 'email'), + 'avatar' => $user['avatar'], + ]); + } + + /** + * {@inheritdoc} + */ + protected function getTokenFields($code): array + { + return array_merge(parent::getTokenFields($code), [ + 'grant_type' => 'authorization_code', + ]); + } +} diff --git a/src/Id/Traits/HasAnikeenTokens.php b/src/Id/Traits/HasAnikeenTokens.php new file mode 100644 index 0000000..7ebd96a --- /dev/null +++ b/src/Id/Traits/HasAnikeenTokens.php @@ -0,0 +1,43 @@ +accessToken; + } + + /** + * Determine if the current API token has a given scope. + */ + public function anikeenTokenCan(string $scope): bool + { + $scopes = $this->accessToken ? $this->accessToken->scopes : []; + + return in_array('*', $scopes) || in_array($scope, $this->accessToken->scopes); + } + + /** + * Set the current access token for the user. + */ + public function withAnikeenAccessToken(stdClass $accessToken): self + { + $this->accessToken = $accessToken; + + return $this; + } +} \ No newline at end of file diff --git a/src/Id/Traits/OauthTrait.php b/src/Id/Traits/OauthTrait.php new file mode 100644 index 0000000..174154a --- /dev/null +++ b/src/Id/Traits/OauthTrait.php @@ -0,0 +1,36 @@ +client->request('POST', '/oauth/token', [ + 'form_params' => $attributes + [ + 'grant_type' => $grantType, + 'client_id' => $this->getClientId(), + 'client_secret' => $this->getClientSecret(), + ], + ]); + + $result = new Result($response, null); + } catch (RequestException $exception) { + $result = new Result($exception->getResponse(), $exception); + } + + $result->anikeenId = $this; + + return $result; + } +} \ No newline at end of file diff --git a/src/Id/Traits/SshKeysTrait.php b/src/Id/Traits/SshKeysTrait.php new file mode 100644 index 0000000..48d20ec --- /dev/null +++ b/src/Id/Traits/SshKeysTrait.php @@ -0,0 +1,42 @@ +get("v1/users/$id/ssh-keys/json", [], null); + } + + /** + * Creates ssh key for the currently authed user + */ + public function createSshKey(string $publicKey, string $name = null): Result + { + return $this->post('v1/ssh-keys', [ + 'public_key' => $publicKey, + 'name' => $name, + ]); + } + + /** + * Deletes a given ssh key for the currently authed user + */ + public function deleteSshKey(int $id): Result + { + return $this->delete("v1/ssh-keys/$id", []); + } +} \ No newline at end of file diff --git a/src/Id/Traits/UsersTrait.php b/src/Id/Traits/UsersTrait.php new file mode 100644 index 0000000..de8ddfc --- /dev/null +++ b/src/Id/Traits/UsersTrait.php @@ -0,0 +1,39 @@ +get('v1/user'); + } + + /** + * Creates a new user on behalf of the current user. + */ + public function createUser(array $parameters): Result + { + return $this->post('v1/users', $parameters); + } + + /** + * Checks if the given email exists. + */ + public function isEmailExisting(string $email): Result + { + return $this->post('v1/users/check', [ + 'email' => $email, + ]); + } +} diff --git a/src/Support/Query.php b/src/Support/Query.php new file mode 100644 index 0000000..e6d83d0 --- /dev/null +++ b/src/Support/Query.php @@ -0,0 +1,26 @@ + $value) { + $value = (array)$value; + array_walk_recursive($value, function ($value) use (&$parts, $name) { + $parts[] = urlencode($name) . '=' . urlencode($value); + }); + } + + return implode('&', $parts); + } +} \ No newline at end of file