Token Crud on console + Mail on create and destroy

This commit is contained in:
Jérémy Munsch 2022-07-26 10:38:32 +02:00
parent 28f95934ed
commit 7166d9a097
No known key found for this signature in database
GPG key ID: 18361AF3BFEC0FEC
14 changed files with 717 additions and 4 deletions

View file

@ -0,0 +1,137 @@
<?php
namespace App\Console\Commands\Api;
use App\Mail\Api\ApiTokenCreated;
use App\Mail\Api\ApiTokenRemoved;
use App\Models\Api\ApiToken;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
class TokenCrud extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'api:token
{action? : Can be list, add, remove, default is list}
{api_token? : Only if action is remove, pass the ID to remove}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Manage api tokens';
/**
* Execute the console command.
*
* @return inteager
*/
public function handle(): int
{
switch ($this->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;
});
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions\Api;
/**
* Unhandled error that occus within DAM.
*/
class DamException extends \RuntimeException
{
}

165
app/Lib/Helper.php Normal file
View file

@ -0,0 +1,165 @@
<?php
namespace App\Lib;
use App\Exceptions\Api\DamException;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
/**
* Helper Class
*/
class Helper
{
/**
* Package infos
*
* @var array
*/
private static $packageInfos;
/**
* Tells if the request is within api routes
*
* @param Request $request
* @return boolean
*/
public static function isRequestForApi(Request $request): bool
{
// * Small hack to ensure to know if route is From APIv1.
$routePrefix = (strpos(static::getRoutePrefix($request), 'api/') ? 'api/' : null) ??
(strpos($request->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
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail\Api;
use App\Models\Api\ApiToken;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ApiTokenCreated extends Mailable
{
use SerializesModels;
/**
*
* @var \App\Models\Api\ApiToken
*/
private $apiToken = null;
/**
* Create a new message instance.
*
* @param \App\Models\Api\ApiToken $apiToken
* @return void
*/
public function __construct(ApiToken $apiToken)
{
$this->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'));
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail\Api;
use App\Models\Api\ApiToken;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ApiTokenRemoved extends Mailable
{
use SerializesModels;
/**
*
* @var \App\Models\Api\ApiToken
*/
private $apiToken = null;
/**
* Create a new message instance.
*
* @param \App\Models\Api\ApiToken $apiToken
* @return void
*/
public function __construct(ApiToken $apiToken)
{
$this->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'));
}
}

139
app/Models/Api/ApiToken.php Normal file
View file

@ -0,0 +1,139 @@
<?php
namespace App\Models\Api;
use App\Lib\Helper;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Rennokki\QueryCache\Traits\QueryCacheable;
class ApiToken extends Model
{
use HasFactory;
use QueryCacheable;
// TODO: Ajouter aussi stats.
/**
* The attributes that should be cast.
*
* @var array
*/
protected $casts = [
'token_date' => '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&param2=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);
}
}

View file

@ -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",

69
composer.lock generated
View file

@ -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",

View file

@ -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'))
]
];

View file

@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Api;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ApiTokenFactory>
*/
class ApiTokenFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->name() . ' Token',
];
}
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('api_tokens', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,21 @@
<?php
namespace Database\Seeders;
use App\Models\Api\ApiToken;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class ApiTokenSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
DB::table('api_tokens')->truncate();
ApiToken::factory(10)->create();
}
}

View file

@ -0,0 +1,10 @@
@component('mail::message')
# !! {{ __('A new Api Token has been created') }} !!
{{ now() }}<br>
{{ __('Token name \':name\'', ['name' => $apiToken->name]) }}
{{ __('Thanks') }},<br>
{{ config('app.name') }}
@endcomponent

View file

@ -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() }}<br>
{{ __('Token name \':name\'', ['name' => $apiToken->name]) }}
{{ __('Thanks') }},<br>
{{ config('app.name') }}
@endcomponent