From 7166d9a097a0284a46f19a964b1ec2c4d419da24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Tue, 26 Jul 2022 10:38:32 +0200 Subject: [PATCH] Token Crud on console + Mail on create and destroy --- app/Console/Commands/Api/TokenCrud.php | 137 +++++++++++++++ app/Exceptions/Api/DamException.php | 10 ++ app/Lib/Helper.php | 165 ++++++++++++++++++ app/Mail/Api/ApiTokenCreated.php | 42 +++++ app/Mail/Api/ApiTokenRemoved.php | 42 +++++ app/Models/Api/ApiToken.php | 139 +++++++++++++++ composer.json | 3 +- composer.lock | 69 +++++++- config/mail.php | 4 +- database/factories/Api/ApiTokenFactory.php | 23 +++ ...2_07_25_145109_create_api_tokens_table.php | 42 +++++ database/seeders/ApiTokenSeeder.php | 21 +++ .../views/emails/apiTokens/created.blade.php | 10 ++ .../views/emails/apiTokens/removed.blade.php | 14 ++ 14 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 app/Console/Commands/Api/TokenCrud.php create mode 100644 app/Exceptions/Api/DamException.php create mode 100644 app/Lib/Helper.php create mode 100644 app/Mail/Api/ApiTokenCreated.php create mode 100644 app/Mail/Api/ApiTokenRemoved.php create mode 100644 app/Models/Api/ApiToken.php create mode 100644 database/factories/Api/ApiTokenFactory.php create mode 100644 database/migrations/2022_07_25_145109_create_api_tokens_table.php create mode 100644 database/seeders/ApiTokenSeeder.php create mode 100644 resources/views/emails/apiTokens/created.blade.php create mode 100644 resources/views/emails/apiTokens/removed.blade.php diff --git a/app/Console/Commands/Api/TokenCrud.php b/app/Console/Commands/Api/TokenCrud.php new file mode 100644 index 0000000..c999d3b --- /dev/null +++ b/app/Console/Commands/Api/TokenCrud.php @@ -0,0 +1,137 @@ +argument('action')) { + case null: + case 'list': + $this->table(['id', 'name', 'token_date'], ApiToken::all()->map(function (ApiToken $apiToken) { + return [ + 'id' => $apiToken->id, + 'name' => $apiToken->name, + 'token_date' => $apiToken->token_date, + ]; + })); + return 0; + case 'add': + return $this->createApiToken(); + case 'remove': + return $this->removeApiToken(); + default: + $this->error(\sprintf( + 'action \'%s\' is not availaible, use --help to see usage.', + $this->argument('action') + )); + return -1; + } //end switch + } + + /** + * Create api Token + * + * @return integer + */ + private function createApiToken(): int + { + return DB::transaction(function () { + $apiToken = new ApiToken(); + do { + if (!($apiToken->name = $this->ask('Give your token a name'))) { + $this->warn('Please provide a valid name.'); + } + } while (!$apiToken->name); + $publicToken = $apiToken->generatePublicToken(); + $privateToken = $apiToken->generatePrivateToken(); + if ( + !$this->confirm( + "Are you should you want to create the following '{$apiToken->name}'" + ) + ) { + $this->info('Nothing was created.'); + return 0; + } + $apiToken->saveOrFail(); + + Mail::send(new ApiTokenCreated($apiToken)); + + $this->alert(" + Your new api Token for '{$apiToken->id}' named '{$apiToken->name}' + public: {$publicToken} + private: {$privateToken} + + Please take note as it wont be available to display anymore ! + "); + + return 0; + }); + } + + /** + * Remove Api token + * + * @return integer + */ + private function removeApiToken(): int + { + return DB::transaction(function () { + if (!\is_numeric($apiTokenId = $this->argument('api_token'))) { + $this->error('remove command needs an id, use `list` to see all.'); + return -1; + } + /** @var \App\Models\Api\ApiToken|null */ + if (!($apiToken = ApiToken::where('id', $apiTokenId)->first())) { + $this->error("api_token '{$apiTokenId}' was not found"); + return -1; + } + if ( + !$this->confirm( + "Are you sur you want to remove apiToken '{$apiToken->id}' '{$apiToken->name}' ?" + ) + ) { + $this->info('Nothing was deleted.'); + return 0; + } + if (!$apiToken->deleteOrFail()) { + $this->error('removing apiToken failed'); + } + + Mail::send(new ApiTokenRemoved($apiToken)); + + $this->alert("apiToken '{$apiToken->id}' '{$apiToken->name}' has been removed !"); + + return 0; + }); + } +} diff --git a/app/Exceptions/Api/DamException.php b/app/Exceptions/Api/DamException.php new file mode 100644 index 0000000..f6e10e7 --- /dev/null +++ b/app/Exceptions/Api/DamException.php @@ -0,0 +1,10 @@ +path(), 'api/') === 0 ? 'api/' : null) ?? 'none'; + return strpos($routePrefix, 'api/') === 0; + } + + /** + * Try to get current route name + * + * @param Request $request + * @return string|null + */ + public static function getRouteName(Request $request) + { + return Route::currentRouteName() ?? ($request->route() ? $request->route()->getName() : null); + } + + /** + * Get the bearer token from request + * + * @param Request $request + * @return string|null + */ + public static function getBearerTokenFromRequest(Request $request): ?string + { + $header = $request->header('Authorization', ''); + + if (Str::contains($header, 'Bearer ')) { + $auths = \explode(',', $header); + foreach ($auths as $auth) { + if (Str::contains($auth, 'Bearer ')) { + return Str::substr($auth, 7); + } + } + } + return null; + } + + /** + * Try to get current route prefix + * + * @param Request $request + * @return string|null + */ + public static function getRoutePrefix(Request $request) + { + return (Route::getCurrentRoute() ? Route::getCurrentRoute()->getPrefix() : null) ?? + ($request->route() ? $request->route()->getPrefix() : null); + } + + /** + * Try to get route from request + * + * @param Request $request + * @return \Illuminate\Routing\Route|null + */ + public static function getRoute(Request $request) + { + return Route::getRoutes()->getByName(static::getRouteName($request)); + } + + /** + * Get composer.json infos + * + * @return array + */ + public static function getPackageInfo(): array + { + if (!static::$packageInfos) { + static::$packageInfos = \json_decode(\file_get_contents(__DIR__ . '/../../composer.json'), true); + } + return static::$packageInfos; + } + + /** + * Calculate the request signature + * + * @param string $secret + * @param string $uri + * @param string $method + * @param array $parameters + * @return string request signature + * @throws \App\Exceptions\Api\DamException If an error occurs. + */ + public static function signRequest(string $secret, string $uri, string $method, array $parameters): string + { + try { + $uri = collect(\parse_url($uri)); + $method = \strtolower($method); + + // * Remove signature from parameters to calculate the hashmac. + $signature = collect([ + 'uri' => sprintf( + '%s://%s%s', + $uri->get('scheme'), + $uri->get('host'), + $uri->get('path') + ), + 'method' => $method + ]); + + $parameters = collect(Arr::flatten($parameters)); + + // * Remove signature from parameters. + $parameters->pull('signature'); + + // * Natural sort all parameters. + $parameters = $parameters->sortKeys(\SORT_NATURAL) + ->map(function ($value, $key) { + return sprintf('%s=%s', \rawurlencode($key), \rawurlencode($value)); + }); + + $signature->push($parameters->implode('&')); + + $calculatedHash = base64_encode(hash_hmac( + 'sha256', + $signature->implode('\n'), + Crypt::decryptString($secret), + true + )); + + if (!$calculatedHash) { + throw new DamException('Failed to calculate requests hash'); + } + + return $calculatedHash; + } catch (DecryptException $e) { + throw new DamException('Failed to decrypt service credentials', 0, $e); + } catch (\Exception $e) { + throw new DamException('An error occured when authenticating service', 0, $e); + } //end try + } +} diff --git a/app/Mail/Api/ApiTokenCreated.php b/app/Mail/Api/ApiTokenCreated.php new file mode 100644 index 0000000..9044e9a --- /dev/null +++ b/app/Mail/Api/ApiTokenCreated.php @@ -0,0 +1,42 @@ +apiToken = $apiToken; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->markdown('emails.apiTokens.created', [ + 'apiToken' => $this->apiToken + ])->subject(trans('!!! :appname a new Api Token has been added !!!', ['appname' => config('app.name')])) + ->to(config('mail.contactsList.serviceweb')); + } +} diff --git a/app/Mail/Api/ApiTokenRemoved.php b/app/Mail/Api/ApiTokenRemoved.php new file mode 100644 index 0000000..3627d93 --- /dev/null +++ b/app/Mail/Api/ApiTokenRemoved.php @@ -0,0 +1,42 @@ +apiToken = $apiToken; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + return $this->markdown('emails.apiTokens.removed', [ + 'apiToken' => $this->apiToken + ])->subject(trans('!!! :appname an Api Token has been removed !!!', ['appname' => config('app.name')])) + ->to(config('mail.contactsList.serviceweb')); + } +} diff --git a/app/Models/Api/ApiToken.php b/app/Models/Api/ApiToken.php new file mode 100644 index 0000000..6b795af --- /dev/null +++ b/app/Models/Api/ApiToken.php @@ -0,0 +1,139 @@ + 'datetime' + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + static::creating(function (self $model) { + if (!$model->public_token) { + $model->generatePublicToken(); + } + if (!$model->private_token) { + $model->generatePrivateToken(); + } + }); + static::created(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::updated(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + static::deleted(function () { + // * Flush Eloquent cache. + self::flushQueryCache(); + }); + } + + // * METHODS + + /** + * Generates new public token + * + * @return string + */ + public function generatePublicToken(): string + { + $this->public_token = hash('ripemd160', Str::uuid()); + $this->token_date = now(); + return $this->public_token; + } + + /** + * Generates new private token + * It store the crypted private token in model + * and returns the plain private token + * + * @return string + */ + public function generatePrivateToken(): string + { + $pwd = hash('sha256', Str::uuid()); + $this->private_token = Crypt::encryptString($pwd); + $this->token_date = now(); + return $pwd; + } + + /** + * Authenticate the request for this service + * + * This will check hash-mac and that the request is wellformed + * + * The request should have the minimum following parameters + * - timestamp using format Y-m-d H:i:s, and must be UTC (Greenwich Mean Time) + * - bearerToken the public token generated by the crm + * - signature the hashmac signature of the request + * + * ! Signature must be calculated with every parameter urlencode once only + * ! All parameters to calculate the signature must be sorted (natural sort asc) + * + * ! Signature should look like + * * httpVerb\n + * * uri\n + * * queryString + * + * ! httpVerb as well as uri must be lower case + * ! uri should include scheme, host and path and nothing else, if path is empty a trailing slash should be added + * ! eg: https://ldc-crm.fr/customer/1 or https://ldc-crm.fr/ + * ! The query string shall be separated with '&', eg param1=value1¶m2=value2 + * ! Key names and values of the query string must be url encoded using RFC 3986 + * ! http://www.faqs.org/rfcs/rfc3986.html + * ! Signature must be hmac sha256 using the private key generatedby the crm + * + * * ! They should then be concat in a string separated by \n + * + * + * + * @param \Illuminate\Http\Request $request + * @return boolean + * @throws \App\Exceptions\Api\V2\CrmException If an error occurs. + */ + public function authenticateApi(Request $request): bool + { + // ! Hack to prevent Laravel to cast empty strings to null or the opposite. + // ! If cannot decode json user legacy all() method. + $data = \json_decode($request->getContent(), true); + $data = (\is_array($data) and \json_last_error() === \JSON_ERROR_NONE) ? + array_replace_recursive($request->all(), $data) : $request->all(); + + $calculatedHash = Helper::signRequest( + $this->private_token, + $request->getUri(), + $request->getMethod(), + $data + ); + + // ! Très important hash_equals https://fr.wikipedia.org/wiki/Attaque_temporelle + return \hash_equals($calculatedHash, $request->signature); + } +} diff --git a/composer.json b/composer.json index 98df431..2c9516c 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "laravelcollective/html": "^6.3", "ldc/web-status-checker-laravel": "^1.0", "mews/purifier": "^3.3", - "pion/laravel-chunk-upload": "^1.5" + "pion/laravel-chunk-upload": "^1.5", + "rennokki/laravel-eloquent-query-cache": "^3.3" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/composer.lock b/composer.lock index 30a5c70..8bb5911 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa7eb721113c779f78e7abc7a0318ee8", + "content-hash": "77f343483e87b0d4403a8b8209b33bd9", "packages": [ { "name": "adldap2/adldap2", @@ -4251,6 +4251,73 @@ ], "time": "2022-02-11T10:27:51+00:00" }, + { + "name": "rennokki/laravel-eloquent-query-cache", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/renoki-co/laravel-eloquent-query-cache.git", + "reference": "00e73a7050bedca315c71bf433d0c283f8a73fbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/renoki-co/laravel-eloquent-query-cache/zipball/00e73a7050bedca315c71bf433d0c283f8a73fbc", + "reference": "00e73a7050bedca315c71bf433d0c283f8a73fbc", + "shasum": "" + }, + "require": { + "illuminate/database": "^8.83|^9.0.1", + "illuminate/support": "^8.83|^9.0.1" + }, + "require-dev": { + "chelout/laravel-relationship-events": "^1.5", + "laravel/legacy-factories": "^1.3", + "mockery/mockery": "^1.5", + "orchestra/database": "^6.28|^7.0", + "orchestra/testbench": "^6.28|^7.0", + "orchestra/testbench-core": "^6.28|^7.0", + "phpunit/phpunit": "^9.5.13" + }, + "type": "library", + "autoload": { + "psr-4": { + "Rennokki\\QueryCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alex Renoki", + "email": "alex@renoki.org", + "homepage": "https://github.com/rennokki", + "role": "Developer" + } + ], + "description": "Adding cache on your Laravel Eloquent queries' results is now a breeze.", + "homepage": "https://github.com/renoki-co/laravel-eloquent-query-cache", + "keywords": [ + "caching", + "eloquent", + "laravel", + "query", + "remember", + "sql" + ], + "support": { + "issues": "https://github.com/renoki-co/laravel-eloquent-query-cache/issues", + "source": "https://github.com/renoki-co/laravel-eloquent-query-cache/tree/3.3.0" + }, + "funding": [ + { + "url": "https://github.com/rennokki", + "type": "github" + } + ], + "time": "2022-02-12T19:37:00+00:00" + }, { "name": "symfony/console", "version": "v6.1.2", diff --git a/config/mail.php b/config/mail.php index 29757ed..002b9c7 100644 --- a/config/mail.php +++ b/config/mail.php @@ -117,7 +117,7 @@ return [ // * Contacts List 'contactsList' => [ - 'invalidXml' => env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr'), - 'serviceweb' => env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr') + 'invalidXml' => preg_split('/(\s*,*\s*)*,+(\s*,*\s*)*/', env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr')), + 'serviceweb' => preg_split('/(\s*,*\s*)*,+(\s*,*\s*)*/', env('MAIL_LIST_SERVICEWEB', 'equipe.web@ldc.fr')) ] ]; diff --git a/database/factories/Api/ApiTokenFactory.php b/database/factories/Api/ApiTokenFactory.php new file mode 100644 index 0000000..5b4acf2 --- /dev/null +++ b/database/factories/Api/ApiTokenFactory.php @@ -0,0 +1,23 @@ + + */ +class ApiTokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'name' => $this->faker->name() . ' Token', + ]; + } +} diff --git a/database/migrations/2022_07_25_145109_create_api_tokens_table.php b/database/migrations/2022_07_25_145109_create_api_tokens_table.php new file mode 100644 index 0000000..18063dd --- /dev/null +++ b/database/migrations/2022_07_25_145109_create_api_tokens_table.php @@ -0,0 +1,42 @@ +id(); + + $table->string('name')->comment('Token name'); + + $table->string('public_token', 40) + ->comment('The public token encrypted'); + $table->string('private_token', 312) + ->comment('The private token encrypted'); + + $table->timestamp('token_date')->useCurrent() + ->comment('The token generation date'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('api_tokens'); + } +}; diff --git a/database/seeders/ApiTokenSeeder.php b/database/seeders/ApiTokenSeeder.php new file mode 100644 index 0000000..75b67de --- /dev/null +++ b/database/seeders/ApiTokenSeeder.php @@ -0,0 +1,21 @@ +truncate(); + ApiToken::factory(10)->create(); + } +} diff --git a/resources/views/emails/apiTokens/created.blade.php b/resources/views/emails/apiTokens/created.blade.php new file mode 100644 index 0000000..321bb77 --- /dev/null +++ b/resources/views/emails/apiTokens/created.blade.php @@ -0,0 +1,10 @@ +@component('mail::message') +# !! {{ __('A new Api Token has been created') }} !! + +{{ now() }}
+{{ __('Token name \':name\'', ['name' => $apiToken->name]) }} + + +{{ __('Thanks') }},
+{{ config('app.name') }} +@endcomponent diff --git a/resources/views/emails/apiTokens/removed.blade.php b/resources/views/emails/apiTokens/removed.blade.php new file mode 100644 index 0000000..f849cd4 --- /dev/null +++ b/resources/views/emails/apiTokens/removed.blade.php @@ -0,0 +1,14 @@ +@component('mail::message') +# !! {{ __('An Api Token has been removed') }} !! + +{{ __('The concerned service wont have acces to :appname anymore.', [ + 'appname' => config('app.name') +]) }} + +{{ now() }}
+{{ __('Token name \':name\'', ['name' => $apiToken->name]) }} + + +{{ __('Thanks') }},
+{{ config('app.name') }} +@endcomponent