From cd3b9b818bffed096b7f59ec0441e7abe6c1b992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Jaussoin?= Date: Mon, 5 May 2025 13:43:44 +0000 Subject: [PATCH] Fix FLEXIAPI-286 Send an account_recovery_token using a push notification --- CHANGELOG.md | 1 + RELEASE.md | 3 +- flexiapi/.env.example | 1 + flexiapi/app/Account.php | 9 +- flexiapi/app/AccountRecoveryToken.php | 42 +++ .../Account/RecoveryController.php | 27 +- .../Admin/ProvisioningEmailController.php | 2 +- .../Api/Account/CreationTokenController.php | 2 +- .../Api/Account/RecoveryTokenController.php | 75 ++++++ flexiapi/app/Services/AccountService.php | 32 +-- flexiapi/composer.lock | 251 +++++++++--------- flexiapi/composer.phar | Bin 3063015 -> 3114082 bytes flexiapi/config/app.php | 1 + .../factories/AccountRecoveryTokenFactory.php | 53 ++++ ...8_create_account_recovery_tokens_table.php | 44 +++ flexiapi/lang/fr.json | 3 +- .../views/account/recovery/show.blade.php | 8 +- .../admin/account/activity/index.blade.php | 69 ++++- .../api/documentation_markdown.blade.php | 40 ++- .../views/mails/recover_by_code.blade.php | 2 +- .../recover_by_code_custom.blade.php.example | 2 +- .../resources/views/parts/recovery.blade.php | 2 +- flexiapi/routes/api.php | 1 + flexiapi/routes/web.php | 2 +- .../Feature/ApiAccountRecoveryTokenTest.php | 95 +++++++ flexiapi/tests/TestCase.php | 6 + 26 files changed, 604 insertions(+), 169 deletions(-) create mode 100644 flexiapi/app/AccountRecoveryToken.php create mode 100644 flexiapi/app/Http/Controllers/Api/Account/RecoveryTokenController.php create mode 100644 flexiapi/database/factories/AccountRecoveryTokenFactory.php create mode 100644 flexiapi/database/migrations/2025_04_30_094148_create_account_recovery_tokens_table.php create mode 100644 flexiapi/tests/Feature/ApiAccountRecoveryTokenTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6fd79..2c3a0dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ v1.7 - Fix FLEXIAPI-284 Add configurable admin API Keys - Fix FLEXIAPI-232 Add provisioning email + important redesign of the contacts page - Fix FLEXIAPI-287 Refactor the emails templates +- Fix FLEXIAPI-286 Send an account_recovery_token using a push notification and protect the account recovery using phone page with the account_recovery_token v1.6 ---- diff --git a/RELEASE.md b/RELEASE.md index d724f79..631facf 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,11 +11,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - **Spaces:** A new way to manage your SIP domains and hosts. A Space is defined by a unique SIP Domain and Host pair. - **New mandatory DotEnv variable** `APP_ROOT_HOST`, replaces `APP_URL` and `APP_SIP_DOMAIN` that are now configured using the new dedicated Artisan script. It defines the root hostname where all the Spaces will be configured. All the Spaces will be as subdomains of `APP_ROOT_HOST` except one that can be equal to `APP_ROOT_HOST`. Example: if `APP_ROOT_HOST=myhost.com` the Spaces hosts will be `myhost.com`, `alpha.myhost.com` , `beta.myhost.com`... + - **New DotEnv variable:** `APP_ACCOUNT_RECOVERY_TOKEN_EXPIRATION_MINUTES=0` Number of minutes before expiring the recovery tokens - **New Artisan script** `php artisan spaces:create-update {sip_domain} {host} {name} {--super}`, replaces `php artisan sip_domains:create-update {sip_domain} {--super}`. Can create a Space or update a Space Host base on its Space SIP Domain. ### Changed -- **Removing and moving DotEnv instance environnement variables to the Spaces** The following DotEnv variables were removed. You can now configure them directly in the designated spaces. +- **Removing and moving DotEnv instance environnement variables to the Spaces** The following DotEnv variables were removed. You can now configure them directly in the designated spaces after the migration. - INSTANCE_COPYRIGHT - INSTANCE_INTRO_REGISTRATION - INSTANCE_CUSTOM_THEME diff --git a/flexiapi/.env.example b/flexiapi/.env.example index 55b08ed..d2426b4 100644 --- a/flexiapi/.env.example +++ b/flexiapi/.env.example @@ -21,6 +21,7 @@ APP_DANGEROUS_ENDPOINTS=false # Enable some dangerous endpoints used for XMLRPC # Expiration time for tokens and code, in minutes, 0 means no expiration APP_API_ACCOUNT_CREATION_TOKEN_RETRY_MINUTES=60 # Number of minutes between two consecutive account_creation_token creation APP_ACCOUNT_CREATION_TOKEN_EXPIRATION_MINUTES=0 +APP_ACCOUNT_RECOVERY_TOKEN_EXPIRATION_MINUTES=0 APP_EMAIL_CHANGE_CODE_EXPIRATION_MINUTES=10 APP_PHONE_CHANGE_CODE_EXPIRATION_MINUTES=10 APP_RECOVERY_CODE_EXPIRATION_MINUTES=10 diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php index 67b41a2..80e3d8b 100644 --- a/flexiapi/app/Account.php +++ b/flexiapi/app/Account.php @@ -256,6 +256,11 @@ class Account extends Authenticatable return $this->hasOne(AccountCreationToken::class); } + public function accountRecoveryTokens() + { + return $this->hasMany(AccountRecoveryToken::class); + } + public function authTokens() { return $this->hasMany(AuthToken::class); @@ -385,10 +390,12 @@ class Account extends Authenticatable return $authToken; } - public function recover(?string $code = null): string + public function recover(?string $code = null, ?string $phone = null, ?string $email = null): string { $recoveryCode = new RecoveryCode; $recoveryCode->code = $code ?? generatePin(); + $recoveryCode->phone = $phone; + $recoveryCode->email = $email; $recoveryCode->account_id = $this->id; if (request()) { diff --git a/flexiapi/app/AccountRecoveryToken.php b/flexiapi/app/AccountRecoveryToken.php new file mode 100644 index 0000000..15eea05 --- /dev/null +++ b/flexiapi/app/AccountRecoveryToken.php @@ -0,0 +1,42 @@ +belongsTo(Account::class); + } + + public function consume() + { + $this->used = true; + $this->save(); + } + + public function consumed(): bool + { + return $this->used == true; + } + + public function toLog() + { + return [ + 'token' => $this->token, + 'pn_param' => $this->pn_param, + 'used' => $this->used, + 'account_id' => $this->account_id, + 'ip' => $this->ip, + 'user_agent' => $this->user_agent, + ]; + } +} diff --git a/flexiapi/app/Http/Controllers/Account/RecoveryController.php b/flexiapi/app/Http/Controllers/Account/RecoveryController.php index 35a0aa3..6c749ea 100644 --- a/flexiapi/app/Http/Controllers/Account/RecoveryController.php +++ b/flexiapi/app/Http/Controllers/Account/RecoveryController.php @@ -20,12 +20,16 @@ namespace App\Http\Controllers\Account; use App\Account; +use App\AccountRecoveryToken; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Services\AccountService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Str; +use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController; + class RecoveryController extends Controller { public function showEmail(Request $request) @@ -36,10 +40,16 @@ class RecoveryController extends Controller ]); } - public function showPhone(Request $request) + public function showPhone(Request $request, string $accountRecoveryToken) { + $accountRecoveryToken = AccountRecoveryToken::where('token', $accountRecoveryToken) + ->where('used', false) + ->firstOrFail(); + return view('account.recovery.show', [ 'method' => 'phone', + 'account_recovery_token' => $accountRecoveryToken->token, + 'phone' => $request->get('phone'), 'domain' => resolveDomain($request) ]); } @@ -49,7 +59,8 @@ class RecoveryController extends Controller $rules = [ 'email' => 'required_without:phone|email|exists:accounts,email', 'phone' => 'required_without:email|starts_with:+', - 'h-captcha-response' => captchaConfigured() ? 'required|HCaptcha' : '', + 'h-captcha-response' => captchaConfigured() ? 'required_if:email|HCaptcha' : '', + 'account_recovery_token' => 'required_if:phone', ]; $account = null; @@ -94,9 +105,17 @@ class RecoveryController extends Controller } if ($request->get('email')) { - $account = (new AccountService)->recoverByEmail($account); + $account = (new AccountService)->recoverByEmail($account, $request->get('email')); } elseif ($request->get('phone')) { - $account = (new AccountService)->recoverByPhone($account); + $accountRecoveryToken = AccountRecoveryToken::where('token', $request->get('account_recovery_token')) + ->where('used', false) + ->first(); + + if (!$accountRecoveryToken) { + abort(403, 'Wrong Account Recovery Token'); + } + + $account = (new AccountService)->recoverByPhone($account, $request->get('phone'), $accountRecoveryToken); } return view('account.recovery.confirm', [ diff --git a/flexiapi/app/Http/Controllers/Admin/ProvisioningEmailController.php b/flexiapi/app/Http/Controllers/Admin/ProvisioningEmailController.php index f7b2aa9..036a7a3 100644 --- a/flexiapi/app/Http/Controllers/Admin/ProvisioningEmailController.php +++ b/flexiapi/app/Http/Controllers/Admin/ProvisioningEmailController.php @@ -22,7 +22,7 @@ class ProvisioningEmailController extends Controller ]); } - public function send(Request $request, int $accountId) + public function send(int $accountId) { $account = Account::findOrFail($accountId); $account->provision(); diff --git a/flexiapi/app/Http/Controllers/Api/Account/CreationTokenController.php b/flexiapi/app/Http/Controllers/Api/Account/CreationTokenController.php index 93bfa0d..044836a 100644 --- a/flexiapi/app/Http/Controllers/Api/Account/CreationTokenController.php +++ b/flexiapi/app/Http/Controllers/Api/Account/CreationTokenController.php @@ -66,7 +66,7 @@ class CreationTokenController extends Controller $fp = new FlexisipPusherConnector($token->pn_provider, $token->pn_param, $token->pn_prid); if ($fp->sendToken($token->token)) { - Log::channel('events')->info('API: Token sent', ['token' => $token->token]); + Log::channel('events')->info('API: Account Creation Token sent', ['token' => $token->token]); $token->save(); return; diff --git a/flexiapi/app/Http/Controllers/Api/Account/RecoveryTokenController.php b/flexiapi/app/Http/Controllers/Api/Account/RecoveryTokenController.php new file mode 100644 index 0000000..bf153d0 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Account/RecoveryTokenController.php @@ -0,0 +1,75 @@ +. +*/ + +namespace App\Http\Controllers\Api\Account; + +use Illuminate\Http\Request; +use Illuminate\Support\Str; +use Illuminate\Support\Facades\Log; +use Carbon\Carbon; + +use App\AccountRecoveryToken; +use App\Rules\PnParam; +use App\Rules\PnPrid; +use App\Rules\PnProvider; +use App\Http\Controllers\Controller; +use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController; +use App\Libraries\FlexisipPusherConnector; + +class RecoveryTokenController extends Controller +{ + public function sendByPush(Request $request) + { + $request->validate([ + 'pn_provider' => ['required', new PnProvider], + 'pn_param' => [new PnParam], + 'pn_prid' => [new PnPrid], + ]); + + $last = AccountRecoveryToken::where('pn_provider', $request->get('pn_provider')) + ->where('pn_param', $request->get('pn_param')) + ->where('pn_prid', $request->get('pn_prid')) + ->where('created_at', '>=', Carbon::now()->subMinutes(config('app.account_recovery_token_retry_minutes'))->toDateTimeString()) + ->where('used', true) + ->latest() + ->first(); + + if ($last) { + Log::channel('events')->info('API: Token throttled', ['token' => $last->token]); + abort(429, 'Last token requested too recently'); + } + + $token = new AccountRecoveryToken; + $token->token = Str::random(WebAuthenticateController::$emailCodeSize); + $token->pn_provider = $request->get('pn_provider'); + $token->pn_param = $request->get('pn_param'); + $token->pn_prid = $request->get('pn_prid'); + $token->fillRequestInfo($request); + + $fp = new FlexisipPusherConnector($token->pn_provider, $token->pn_param, $token->pn_prid); + if ($fp->sendToken($token->token)) { + Log::channel('events')->info('API: AccountRecoveryToken sent', ['token' => $token->token]); + + $token->save(); + return; + } + + abort(503, "Token not sent"); + } +} diff --git a/flexiapi/app/Services/AccountService.php b/flexiapi/app/Services/AccountService.php index 86928f2..56d7787 100644 --- a/flexiapi/app/Services/AccountService.php +++ b/flexiapi/app/Services/AccountService.php @@ -21,6 +21,7 @@ namespace App\Services; use App\Account; use App\AccountCreationToken; +use App\AccountRecoveryToken; use App\EmailChangeCode; use App\Http\Requests\Account\Create\Request as CreateRequest; use App\Http\Requests\Account\Update\Request as UpdateRequest; @@ -220,7 +221,7 @@ class AccountService $account = $request->user(); - $phoneChangeCode = $account->phoneChangeCode ?? new PhoneChangeCode(); + $phoneChangeCode = new PhoneChangeCode(); $phoneChangeCode->account_id = $account->id; $phoneChangeCode->phone = $request->get('phone'); $phoneChangeCode->code = generatePin(); @@ -255,7 +256,7 @@ class AccountService $account = $request->user(); - $phoneChangeCode = $account->phoneChangeCode()->firstOrFail(); + $phoneChangeCode = $account->phoneChangeCodes()->firstOrFail(); if ($phoneChangeCode->expired()) { return abort(410, 'Expired code'); @@ -299,7 +300,7 @@ class AccountService $account = $request->user(); - $emailChangeCode = $account->emailChangeCode ?? new EmailChangeCode(); + $emailChangeCode = new EmailChangeCode(); $emailChangeCode->account_id = $account->id; $emailChangeCode->email = $request->get('email'); $emailChangeCode->code = generatePin(); @@ -327,7 +328,7 @@ class AccountService $account = $request->user(); - $emailChangeCode = $account->emailChangeCode()->firstOrFail(); + $emailChangeCode = $account->emailChangeCodes()->firstOrFail(); if ($emailChangeCode->expired()) { return abort(410, 'Expired code'); @@ -360,9 +361,11 @@ class AccountService * Account recovery */ - public function recoverByEmail(Account $account): Account + public function recoverByEmail(Account $account, string $email): Account { - $account = $this->recoverAccount($account); + $account->recover(email: $email); + $account->provision(); + $account->refresh(); Mail::to($account)->send(new RecoverByCode($account)); @@ -371,9 +374,11 @@ class AccountService return $account; } - public function recoverByPhone(Account $account): Account + public function recoverByPhone(Account $account, string $phone, AccountRecoveryToken $accountRecoveryToken): Account { - $account = $this->recoverAccount($account); + $account->recover(phone: $phone); + $account->provision(); + $account->refresh(); $message = 'Your ' . $account->space->name . ' validation code is ' . $account->recovery_code . '.'; @@ -386,14 +391,11 @@ class AccountService Log::channel('events')->info('Account Service: Sending recovery SMS', ['id' => $account->identifier]); - return $account; - } + $accountRecoveryToken->consume(); + $accountRecoveryToken->account_id = $account->id; + $accountRecoveryToken->save(); - private function recoverAccount(Account $account): Account - { - $account->recover(); - $account->provision(); - $account->refresh(); + Log::channel('events')->info('API: AccountRecoveryToken redeemed', ['account_recovery_token' => $accountRecoveryToken->toLog()]); return $account; } diff --git a/flexiapi/composer.lock b/flexiapi/composer.lock index f81e64f..20163d0 100644 --- a/flexiapi/composer.lock +++ b/flexiapi/composer.lock @@ -1324,16 +1324,16 @@ }, { "name": "giggsey/libphonenumber-for-php-lite", - "version": "9.0.3", + "version": "9.0.4", "source": { "type": "git", "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", - "reference": "e4f4c5834d35773bf9d5e72e3e764cfe4e301a8c" + "reference": "6b36e32fddce37738c4f6df66e49dd9a2475841c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/e4f4c5834d35773bf9d5e72e3e764cfe4e301a8c", - "reference": "e4f4c5834d35773bf9d5e72e3e764cfe4e301a8c", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/6b36e32fddce37738c4f6df66e49dd9a2475841c", + "reference": "6b36e32fddce37738c4f6df66e49dd9a2475841c", "shasum": "" }, "require": { @@ -1398,7 +1398,7 @@ "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", "source": "https://github.com/giggsey/libphonenumber-for-php-lite" }, - "time": "2025-04-11T07:36:14+00:00" + "time": "2025-04-28T07:26:55+00:00" }, { "name": "graham-campbell/result-type", @@ -2405,16 +2405,16 @@ }, { "name": "league/commonmark", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94" + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", "shasum": "" }, "require": { @@ -2451,7 +2451,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.7-dev" + "dev-main": "2.8-dev" } }, "autoload": { @@ -2508,7 +2508,7 @@ "type": "tidelift" } ], - "time": "2025-04-18T21:09:27+00:00" + "time": "2025-05-05T12:20:28+00:00" }, { "name": "league/config", @@ -2950,16 +2950,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.0", + "version": "1.13.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414" + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/024473a478be9df5fdaca2c793f2232fe788e414", - "reference": "024473a478be9df5fdaca2c793f2232fe788e414", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", + "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", "shasum": "" }, "require": { @@ -2998,7 +2998,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" }, "funding": [ { @@ -3006,7 +3006,7 @@ "type": "tidelift" } ], - "time": "2025-02-12T12:17:51+00:00" + "time": "2025-04-29T12:36:36+00:00" }, { "name": "namoshek/laravel-redis-sentinel", @@ -4096,16 +4096,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.45", + "version": "10.5.46", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8" + "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/bd68a781d8e30348bc297449f5234b3458267ae8", - "reference": "bd68a781d8e30348bc297449f5234b3458267ae8", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8080be387a5be380dda48c6f41cee4a13aadab3d", + "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d", "shasum": "" }, "require": { @@ -4115,7 +4115,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -4177,7 +4177,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.45" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.46" }, "funding": [ { @@ -4188,12 +4188,20 @@ "url": "https://github.com/sebastianbergmann", "type": "github" }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, { "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", "type": "tidelift" } ], - "time": "2025-02-06T16:08:12+00:00" + "time": "2025-05-02T06:46:24+00:00" }, { "name": "propaganistas/laravel-phone", @@ -6818,16 +6826,16 @@ }, { "name": "symfony/console", - "version": "v6.4.20", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36" + "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2e4af9c952617cc3f9559ff706aee420a8464c36", - "reference": "2e4af9c952617cc3f9559ff706aee420a8464c36", + "url": "https://api.github.com/repos/symfony/console/zipball/a3011c7b7adb58d89f6c0d822abb641d7a5f9719", + "reference": "a3011c7b7adb58d89f6c0d822abb641d7a5f9719", "shasum": "" }, "require": { @@ -6892,7 +6900,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.20" + "source": "https://github.com/symfony/console/tree/v6.4.21" }, "funding": [ { @@ -6908,7 +6916,7 @@ "type": "tidelift" } ], - "time": "2025-03-03T17:16:38+00:00" + "time": "2025-04-07T15:42:41+00:00" }, { "name": "symfony/css-selector", @@ -7339,16 +7347,16 @@ }, { "name": "symfony/http-foundation", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db" + "reference": "3f0c7ea41db479383b81d436b836d37168fd5b99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d0492d6217e5ab48f51fca76f64cf8e78919d0db", - "reference": "d0492d6217e5ab48f51fca76f64cf8e78919d0db", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/3f0c7ea41db479383b81d436b836d37168fd5b99", + "reference": "3f0c7ea41db479383b81d436b836d37168fd5b99", "shasum": "" }, "require": { @@ -7396,7 +7404,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v6.4.18" + "source": "https://github.com/symfony/http-foundation/tree/v6.4.21" }, "funding": [ { @@ -7412,20 +7420,20 @@ "type": "tidelift" } ], - "time": "2025-01-09T15:48:56+00:00" + "time": "2025-04-27T13:27:38+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.20", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37" + "reference": "983ca05eec6623920d24ec0f1005f487d3734a0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/6be6db31bc74693ce5516e1fd5e5ff1171005e37", - "reference": "6be6db31bc74693ce5516e1fd5e5ff1171005e37", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/983ca05eec6623920d24ec0f1005f487d3734a0c", + "reference": "983ca05eec6623920d24ec0f1005f487d3734a0c", "shasum": "" }, "require": { @@ -7510,7 +7518,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.20" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.21" }, "funding": [ { @@ -7526,20 +7534,20 @@ "type": "tidelift" } ], - "time": "2025-03-28T13:27:10+00:00" + "time": "2025-05-02T08:46:38+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11" + "reference": "ada2809ccd4ec27aba9fc344e3efdaec624c6438" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", - "reference": "e93a6ae2767d7f7578c2b7961d9d8e27580b2b11", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ada2809ccd4ec27aba9fc344e3efdaec624c6438", + "reference": "ada2809ccd4ec27aba9fc344e3efdaec624c6438", "shasum": "" }, "require": { @@ -7590,7 +7598,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.18" + "source": "https://github.com/symfony/mailer/tree/v6.4.21" }, "funding": [ { @@ -7606,20 +7614,20 @@ "type": "tidelift" } ], - "time": "2025-01-24T15:27:15+00:00" + "time": "2025-04-26T23:47:35+00:00" }, { "name": "symfony/mime", - "version": "v6.4.19", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3" + "reference": "fec8aa5231f3904754955fad33c2db50594d22d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", - "reference": "ac537b6c55ccc2c749f3c979edfa9ec14aaed4f3", + "url": "https://api.github.com/repos/symfony/mime/zipball/fec8aa5231f3904754955fad33c2db50594d22d1", + "reference": "fec8aa5231f3904754955fad33c2db50594d22d1", "shasum": "" }, "require": { @@ -7675,7 +7683,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.4.19" + "source": "https://github.com/symfony/mime/tree/v6.4.21" }, "funding": [ { @@ -7691,11 +7699,11 @@ "type": "tidelift" } ], - "time": "2025-02-17T21:23:52+00:00" + "time": "2025-04-27T13:27:38+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7754,7 +7762,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" }, "funding": [ { @@ -7774,7 +7782,7 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", @@ -7832,7 +7840,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" }, "funding": [ { @@ -7852,16 +7860,16 @@ }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773" + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773", - "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", "shasum": "" }, "require": { @@ -7915,7 +7923,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" }, "funding": [ { @@ -7931,11 +7939,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-09-10T14:38:51+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -7996,7 +8004,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" }, "funding": [ { @@ -8016,19 +8024,20 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -8076,7 +8085,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" }, "funding": [ { @@ -8092,20 +8101,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2024-12-23T08:48:59+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -8156,7 +8165,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" }, "funding": [ { @@ -8172,11 +8181,11 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -8232,7 +8241,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" }, "funding": [ { @@ -8252,7 +8261,7 @@ }, { "name": "symfony/polyfill-uuid", - "version": "v1.31.0", + "version": "v1.32.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", @@ -8311,7 +8320,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0" }, "funding": [ { @@ -8558,16 +8567,16 @@ }, { "name": "symfony/string", - "version": "v6.4.15", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f" + "reference": "73e2c6966a5aef1d4892873ed5322245295370c6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", - "reference": "73a5e66ea2e1677c98d4449177c5a9cf9d8b4c6f", + "url": "https://api.github.com/repos/symfony/string/zipball/73e2c6966a5aef1d4892873ed5322245295370c6", + "reference": "73e2c6966a5aef1d4892873ed5322245295370c6", "shasum": "" }, "require": { @@ -8624,7 +8633,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.4.15" + "source": "https://github.com/symfony/string/tree/v6.4.21" }, "funding": [ { @@ -8640,20 +8649,20 @@ "type": "tidelift" } ], - "time": "2024-11-13T13:31:12+00:00" + "time": "2025-04-18T15:23:29+00:00" }, { "name": "symfony/translation", - "version": "v6.4.19", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e" + "reference": "bb92ea5588396b319ba43283a5a3087a034cb29c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/3b9bf9f33997c064885a7bfc126c14b9daa0e00e", - "reference": "3b9bf9f33997c064885a7bfc126c14b9daa0e00e", + "url": "https://api.github.com/repos/symfony/translation/zipball/bb92ea5588396b319ba43283a5a3087a034cb29c", + "reference": "bb92ea5588396b319ba43283a5a3087a034cb29c", "shasum": "" }, "require": { @@ -8719,7 +8728,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v6.4.19" + "source": "https://github.com/symfony/translation/tree/v6.4.21" }, "funding": [ { @@ -8735,7 +8744,7 @@ "type": "tidelift" } ], - "time": "2025-02-13T10:18:43+00:00" + "time": "2025-04-07T19:02:30+00:00" }, { "name": "symfony/translation-contracts", @@ -8891,16 +8900,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.18", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837" + "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/4ad10cf8b020e77ba665305bb7804389884b4837", - "reference": "4ad10cf8b020e77ba665305bb7804389884b4837", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", + "reference": "22560f80c0c5cd58cc0bcaf73455ffd81eb380d5", "shasum": "" }, "require": { @@ -8956,7 +8965,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.18" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.21" }, "funding": [ { @@ -8972,7 +8981,7 @@ "type": "tidelift" } ], - "time": "2025-01-17T11:26:11+00:00" + "time": "2025-04-09T07:34:50+00:00" }, { "name": "theseer/tokenizer", @@ -9081,16 +9090,16 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.1", + "version": "v5.6.2", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2" + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/a59a13791077fe3d44f90e7133eb68e7d22eaff2", - "reference": "a59a13791077fe3d44f90e7133eb68e7d22eaff2", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", "shasum": "" }, "require": { @@ -9149,7 +9158,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.1" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" }, "funding": [ { @@ -9161,7 +9170,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:52:34+00:00" + "time": "2025-04-30T23:37:27+00:00" }, { "name": "voku/portable-ascii", @@ -9515,20 +9524,20 @@ }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -9536,8 +9545,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -9560,9 +9569,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "mockery/mockery", @@ -10197,16 +10206,16 @@ }, { "name": "symfony/var-exporter", - "version": "v6.4.20", + "version": "v6.4.21", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "998df255e9e6a15a36ae35e9c6cd818c17cf92a2" + "reference": "717e7544aa99752c54ecba5c0e17459c48317472" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/998df255e9e6a15a36ae35e9c6cd818c17cf92a2", - "reference": "998df255e9e6a15a36ae35e9c6cd818c17cf92a2", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/717e7544aa99752c54ecba5c0e17459c48317472", + "reference": "717e7544aa99752c54ecba5c0e17459c48317472", "shasum": "" }, "require": { @@ -10254,7 +10263,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.20" + "source": "https://github.com/symfony/var-exporter/tree/v6.4.21" }, "funding": [ { @@ -10270,7 +10279,7 @@ "type": "tidelift" } ], - "time": "2025-03-13T09:55:08+00:00" + "time": "2025-04-27T21:06:26+00:00" } ], "aliases": [], diff --git a/flexiapi/composer.phar b/flexiapi/composer.phar index 3b71ca645be88d71bc6e393515143364ef01946f..7a3bef9dffd8d0fe6e29b47fd521e51891cc0312 100755 GIT binary patch delta 59131 zcmcG$2V9#+(l8D-7!XZ#1PJtCY`_#%L~+4F5+FcANTRx6H9(+-B&rK`dXMcV$tJcF z+vz=a8tJifxg?k5F3#O0cezV(uI4VydEYC^rG2xzl90j9oA>*FfB!;O&+hE(%`Z7T<&6oHgx@Eu z=eCVd+Hv`-)Kv}a@idA=!^tPuzXI%=6-l*=oUDdwLi(+=tBAfBP;V_;Tjhjl4=q(grS z?Qk%KPUVLwltd=Ak&HACod2!q#^=~;*U*kvr{<7`pCv{`ef7rB7-8_uRLbDrQYj-< zTnPcXISv%;W_Bk$}knuj3a{fYIRMa(%v&)D) zRcSfg!A*2(aLIL!HN*Te>{V%WZtqT0lip{7WnDUDVpn=I8SQUrQBiA4HYO5nIg*~m zewUt+h?Vr=98e=|8@nr~iq&Lf5F~>cU8K{q(NR$!9B4hjrU`lMk@O7qql_E^uq;zY zN@Hd(DIdtRkTN~XM#`D2ep0@b)kDhaZ2Ejfb`L>(7=-a|?O$#uh()=XEH5XUG~5Cs zei{9X4~dMSA;sfyR(nUhM_|(MGMmg%abIhra=_*OjbCaAOBl(&b27-;ev?P1peetT)L)lxBIU>Vt>F=#T)u^a6@cB%Ph_nH zbS~!#D1z4uR0QzeqNu2c4>b<6b2)kJDL#kY$j>L3w(>hj`2b%@07qm|Q6*c`j|Bl! zP>qk1nuz5m1i51QZ75U|NIj_RMQ0jLlU0Mqex#79?Q4aU=(Hj#p6;S9GQ>SawEpv= z7V=y#pwDvxx~gm|qM}+#bB=`(ej%U;6PPB_LC4UJH!=qD{3$~_D)>=RFG;sPN@g5W zGFY5RNW5%WRFt~(u9IPmw-r;2PZ!g5`fag>VA8Cl7;jl=ARYHbMMd>Ze)I-=l;N?X zB^WHkY7Euo}bT_PsWCrezU6t8lV^43-3q|9DDM#_s;y9mNB0paD3{c@Q7ptOMH zm8Ou!HKjGA@!I&PsF#0wTPl01g~wi0N;U9cX&nK2yHrZbq%tW1{`Iv{QLk4Gybnv4 zx4)9Zy36Q#x~#07wEqQ0dQgh4!qyJAFDs`LEhwkLS`!l$)jaU0cS8L;%2U|G3i{ktL8lW{&{gn$g_giq zS4z3PFhkf<;Bud3^>yrcCFSa_%AgCYq<%zPMHj$am633>3q*70U+-^akF5ZMDT`;n zucG3~uGSOmj$rvrHJ!MWA;dX}`H1%a~A|DvM(; zmZlTn15&znv7O3kZn~X1Utld&CLjP3nT}woHAF>??5}xJRABD&Sgs_FeXxf5)qB9+@beR%B4z?h@v|E00h8raYW;E=DSQtY zzA&aJVD&*W56b+xp^MYWWK6SdT_rNIbSAOG)<*i%{_TT(|grm?Pp z(7B_Is+AYC>cC3tT4MIR_3`ZIbqwM37&J70^Um#rQ{1q=o^qdg9Nbm0rpX~a%m3_%RGlfPJRBFr5H z=B)NL5>`>mW6!KlWDOf~xd+$K1%b;u?{)qYVkFvl%LXdDCpQG?X{45EYa?BK_cT&t z`fVfi2%Q^g{eg{C0!grRHw)K%M;OGTaTV0Hlq;xCdlZy`;{#Dq_m6Bd5S`E~v)D6= zR6=hr_U376WrQAF=8i~FZYp7qwyY!?@^TAV{QFy}if(MBD`H3R`I+GJ zm#uXKxej8n(Zta#5{v2EV%Y%=jaLf5?Zw_EdyPGzD`XF7s5O67Q_J0*Ky?I{vz*Mk z*{eZ;a8F(vrMtRKLMXAe)sXUFu>4hy+Er=*~jcsm&#*l%OEC+@u@Bs2~I?F$p}nKmxX=hJWlxuX3I?W@ zyP>m|;Qj(|x7_@Onk*a4`N2+VA)o4`GXA`iPH%;tK5x_4QJDvyPX)`^t~CT92N1sb zV%00eap_m2`h=lHzu%C#$>`` zIxO=m?G_t*bXx&wc+5!k@>Qdp`zf7`VO;*^v6L%Wu8At;OVCTD(q6}EO+xMs!NGB< zJyWq7pbF5d)jUp0heoN^8P$aVjVJYlX+2kX?Q+5(E)Tu9c{{sjGXYL!<=s@j+PbOH z-q%fy=ZD=rL}m>=^!bV&BY8gEqvPI4&GaxX|MB_jx3L>~*KkKg(PRi*eza|4J3HN5 z&ON|T3E`4sEV_n0-b;1i(_V^B*+?2jc?O|q9dQ3_%C3(V81zT|JFnTma>(a zP%cP)>!6xtj~>WiJzFbD<1JgMr}^7fN^t!k-H06pT-Ve*I>RzgWUvnoQqlZ;6iH=O}O1d(K zarwT7PP+XoGjanU&S3k&0!~WBe!95=2 z8J7w52S(W3(Gl)#pjdclTt1;l&H+ITa39DD5(;Jd>zSz_wn46`gjV7b-F(HP?1z_R zusg;Yc`L!uU=+AC|3&j@XyiH=S(>YMPq;9B!(b&b6fU0?z8VKSu44rr4x4m!HqRPt z#AWZ$t+#|w^k5VO>T_otd->B@+=qgYxNHur5rD2xG@m>3xR(SQaamq|Xdv7;=sLS` zcSEV4z1-#C!9I^rh07_EsUb9QZ+PHAS3IkCXL0dv71NK)f4-Ku3uHnEe%j6BeH9cf zF2CrKu3L^JS#h&zxat{P6ak=@?HM#6s z?^f^{5OY7aMI4F3*dz~ye|zupK9h>eF-xCm9Z&v91gfuOK(X}NT#lxWeN z{#5o4zAWxHL0RMSkK3+V2lind3yrzT2_MOTJrU#;my<6IBbYV}P|Vdp6o=;kmv6O~ z-XCm8Wmg6=xXnR=ae1xpw-scef9$Vg_XT*|SAt{WvL-}D zKJ@QbK8BbjW6ovvS*u)Ti(Lp(uCl}076yZe>cDj1%U~B=UW=4(hLEm?ktgkL8_e4| z5cqvoVaQ4p!@dX$370Rb6NDiQ|$rOJkmY&C=+sdOL;lY^Nc~i`!|ak-dXb=-5G@pWZ<;07*OP=BQ&Q zjV7+&NmCs!g95Mn?1gPK^w^$`s#ADbaa*WBSY{U;t$$ZP2~>7~DLU|K!Y3?qUnV=b ztEjNe?{VwRK0h@-+IlKST<$o@b?@Jk%g*k~=XKbpDZm}UWrNFCo*ulN_1>S!dgkaB z=bkwl;CwYlcQGZ{h*fIu!s~(U++9p4aKu5Zmw)y9SS#4QyH^w8J-M3(hX3A8r~+9u@Vii@69mUCAEbL-&qv?4c1E{{kB6^u8$j)38$HreoGkHZ9(Uj8IMKol5@gf?_7F|rs*^6nM z`4HG2+cU+pAjy38)FDU(UQ8LxhM?`bl8eSbG@*tpv^Kvak@a2@Pq^KF$p9&Td&wXv z8!n|>OJQD&d=7C3U*BzyYWm8|Frn(q2*G0f!?ufGPmzmna31wC?$ zFN%tq&}=+FAm0c={tO5__V$MQ2%D9a>|QpGow<%q z65JbZNMNtIA(?>R5(Fy;0(!5I&PIUcqHF>pMs#~KgXki89#Ps?+(>O#>y31A zf3H4Zl( zpn2vP*hs#dl+s1eR1&g;2Pul#gLGnlagff3;1ESIa)^rj{zFvcUmc=k-U?(^K3Dur za0#ZelQ+{7-QUBO#5s21E~2L=Zl+nY_h5UJVExmb>@&AhH8^oIht0i(ni2Uebf{go zP+j`PEp#E}9i}9?57S9|_At!`2EdASX0LsT%(D0hP0r|#&>6nz2=yDkJwiK`9;G7L zag_4;@=_|k?#@K^n;>x7oph9KchYrt{7%Zoio1FVvTb)!lY8hc%I@#)qGtbPm==}g z(nmm{DbkI1uOb~r?xt)!cQ-vY%(`cQP$7a2%d4j*S<%r@hXnS{dkRS-J~Z3>>Yl z31nl(@2+6~bsx26x%X41hwrB}K6k&IkWmeb@83@?8za8#*~jCFw2B{~^Hui%U8&n2 zpg#SX2k45GfB|{M{6HN{Z~=SdffV-h2NKxE2RVfHjt8l=Iq)D&mE#d!D<~QRGn~(Q zAIw3g3VF%gH$c&FWP{6Z>rd<;x*lvz$gUIN=%*hT!7#YIJDNE{q_FK_9(&@UL^9Mb z9ttj~W7ObXaBPg!pE>3t<+kH?QoeG0oRsSxrjok)VQR8ZKTHi`m_*FanMcytfk(Kc zgX@t^q|5zHqCwz=9!|jg+1^XBk^!JZZmoV@+HDE^{-w>XqxIg{lGFI_G2@)mpR`B#A zUMi%cK_TIC(+gvXBzV(4pTO>&PhtD!DIa6=^pNCb(5Ls_|MY8Cd<{_bSQ2~Wi4638 zJTEEk>R@1%`vhHj-Y2#aO5Y4Vmp@4<@;*r^KK>-lWv>N{*FfDR18wxcsiZ^{nOcm;p?D5@W>y(AWqZ^ z4Qrk$B=odDLwlyd^6CH0J%3#w69JRuQV9=M18N2A(doG2|eLm=Io~K@B>UnAwPdrb9 zyzic;lc+dBk0&v6)1x0>L`3t@3+e2uCsq;?&zx9LyE~t_r;l)t0Y*>K5iUGQt@CeA z(iJXxfvUxoFVJb+1}6U%Y5WAS9k`?SCpoO}C-DS(!%wIN9*U2O`ssnIo+ZXo@Y6W< zs2qVC$j#&A}Xr=zF%!6k?Yg1fCqk&>hirW(&hEdi!@hyD=<_Pdxrwt zXFeB~QE+_V-4+k6L*f)Mk+kL@CJPOt z-|7#y>&Y}01=C)~25 zq#pS`iGtE=>T$X3?!bo9>as8!je=7Dgx8Btd~Btc-oI-({Kddu z?EL+^R^;!Eu0yY+#Oab{RU(-{EN`fhH^}7kD^ugf4x)d3uq+*Iv@DB9C+?3cLNDAC zSCnZMG&_9uDYMflRJb8w)jqzx28W{P zyH8eekYGi69Qypi=vZ{>LT(IN-x!~OmOmArj<#-#iAP0E@hi~jE%AJG#kaXRsBvq& zBt~a;+2?m`jejE={i`7%1?@F(3g;)b#jhSkim~ECbj$BjR?I*5YW&U75zo6`j}H{` zhCDvI**YR9gW1AU=rvo%%)@qnIqF)!l8=u4DJBkm`}cTGv>T;<9B+i;^hfboXzpWD zweREjJy3Q0v-sVJ`}g=Bbmo(IL6#fHsV;3Otu6`JU0#EI+`p;BjjDKf04ZIhS!sG(6OH<*Px5{rKO?+J7Wrx{nin?%iQd6+RMw= zBJIzUSIvL-^W=i)7ETEy#LS?5qp;^4wArm5o4u?I6626aD5sT>9>bLc5PEY;CPQho zstv)Pphs^cccA?_OwOe@yparlDe#vHf86;S-bh=Uf=<1cCXVC4zkqKB{q9IyDSGq0 zG=93(Y4-a;r~G#3kjmjJ1>zpspU9x_^I|WvA08ia!NCi6WR`lju ziJ9n36(<%o_i`alT|Wb@_t(S&C#`&)^1OSzl{0X?U~c7&{yMeTppMJ z(u;0@1ILxX1`;GGEcntg77 zDl63ld%EpY0)4AqFhv^#mWcqM;0uBTz{q$Sv275T-8R9HW7=**n%gp0##ajHgrKXs zvI@}4w`S_AFu!y(Y+cSRA;AWL2pdNR?Y=EDPxhm_tOqkGUSI{K)hx@K(*oyUV#5=F zrx|9zFPMV-uwcYtv)SF10^fvNU>=4uBf%8NQn0ORa+@IF!R$>5K(?;xb;2O)8$gwV zvibq)*9#LwYD|smC%dBK{>c-W%85DyWa;m++20UpxQRhx>HkzBtUV-9n%M_u@q)B=6 zJ8sLIjP3*Szz?~JGGhUkoTEDyCvFQnDR4__gMoS`hkpdp?bDD6(a9Z=e>BOXC%P|X^^^(+R)MD0W;f0Na7!|CMIOYY2k{LU=# z@6lc*CoY8;W373*&13PGm7@6Fu^?lr(hgy)1Ep-q5L38;xos`ty5P z4QNQeJT=KhyMRp;i%^;(GY`$hWyj8E-;n*v$8*#&?!Y!g01Sh<7nH$z@e$*JR}x-5 z`0xS_*nH)n)gumng!*H6P#Dk^0S*rYQ?MZI6o}X_2m)Yz;+1Fhcpb1z0v?V4|63#2 zh!<;&50)zWP@R*7JdfwZrIiE?ClHHC0)u!Q+UDQilk*!1I(13TiZEYPaqQCo0Ea=p3kX5+kH9dXs16XE-na&_FR^ei@fbQ$@9DK%Xu&weXh=l zn+FNO?k=&y3J=fC}Dj`nGEB#)mY-!3Q)z`18}gJ4rJ zoy+1am4ae?6hZ3T9=E+1Dy$y&kYgDCGEW3NhSk~mFUBS+&t9C&E=ryAIMFZA3vVEMt~kUU72@7 zY+?+0=FMD3oV2#KPA-zqSN<~h4+UYVpZKUa8$BP*iO+uWv*2px33muJA*NjMo}^nD@(gx5i{P+bse~ zjX)%85K9_FQh`JyshKaY=e@UVqg)~yvengFMG~{sA`*$~hs1UD^)|Dmwq8_gv((rm zqFQN9y+}4xW3D%gY*KSwZLQc`Q&&@Kw#w=x^UdpcN&QH#E{M7G;+uK!mj{3O^B3P- zFj9ZsME^Ms%u8#vVy!=PSdci2kFZSHY93>wWEM!R;lEm6aUOxGgm8A_P7kg z3U9l)vwuuq>u}d-hvmvqPw%XCY_#93G;yp$?LLRJQ}1c9Is0{O-DZi-U=fYY%33w8 zYN<#z+hsO3%{Yx_QH|81Fqxb~J{wRs&}VHP8S0*Nw|D5&!+imRuU<6E@%N6@iN|WR zLPx+eVfDfxpw;B>bJ~RUQ_@zqs$WvmWgGTso3!%b5r6H}K%GhK_t=L=>&C2)|pbeb-6+dzvX&@Y_yPr4KplVkPzrsf$*r*?A0ZE3a}XPd?c zlqQ3B#62U^jeD9Equ$v`$*{>iTst?UteKSbO-r-^Ij5qcepXQ@Q`EOg`((|c+5z9F zb<{VmnvvNg3gMJfVz-W3rY!XXLjAB%W~?*RR*0KNMq11UleTGS2-L{sH`mShYC5_* zI9)RayHOI*4b%&J1Ew0iy{V$xYBY^Y-Kx5}0eywrt25WM%^A#2ahF!t7Z|d1$`q|L zCcmQAqpa_|Xh8l&<-Y>HD z3P<%uPv2OrMQ0hc)T#%RBepK9VZx^xF`H&|B3t{IaY|@0P8ifZ9PO0dAW`|Hoi6R% zR8yd}Su@)2>33+mWNM4nDWC0a7mLT*bbS-8iduWc)QH(;^hx?9(oUtQy+UO1PPMj8 zjns72O`8oveH@`;TqK>4DBRi!V?Z-0mO5RIUWLmERCUO@)iXME>wwGGIxya$u=ECG zI@OTL;dhF}!yT?Baf`;_9qa1ou-TNlX-juKM>(Kw8=W?eG`AXR^Z`p-vq3V}+cV~n z>doy2lc-J|=&$HAX(koJ+9rjfWpub-HZ|PuM<3dGVDMMu$DmWc$xF@~s8LOc19EYT zuBWBFuf{qqbl8<0(_>BpI-Z-CQ>q?oQHZ;3ty2aiN2^v$>Bkjy9@ThDXUmAl)~b+q z%+!xsT~p{!vOH$4-`zH3b=6PWn(IgVd+KI5eY2V=?WlI5U)tu^Dnu|cZiPaoQ7XC=qWW5$w7cFv)$AJ!c#W;oUCL&$ydt1!_NeQv6-s;CjL*lh$;V9I z7EgPx)6=b;6??L+cfx6d&tpO~Dg zbJp}I#>7HZM_1=)t=TZi>8>A=cPV63^<9eL4n>o8R5c;;Ojk%IYt%F2@(QC%r|qo? zm<&D5)2iMX`9!VQIzHZEnDlnm>3T*?W{ufDZ1c)gLZw;8agWwjC}!p?E|YG;)+cIL z>$|&Eis|VAht_B9aH+?7$4%pHQ-N8vug2(~^69lBLylJ1>ot3OG?pIUut;0iHs%;` zjoBQdoc=EP_;7!-P~P0G6m@sFWlf^~ig9DJZo=LoQ%HT2I#Zi?bXrx{BW|;}m*D~ppw#+!xE@8b@>2@31rTSr0 zv!$h}XV%g&&@*djH8o4+#y-_lt!747-#6`2xW_ELoeE{Uzh%tR+AZ%>>N(!oikaGe zvv%0!5znaG%)^0ZW6M~#zS-QZsGDmu)D9SJeVyHU{ZOmWHEHUdwF}h)CY?;~^b5VV z37M*#_FpRjk%So;dZ}l-0Ad4yOm=tZOWOt`fk;X!_r<`-`6VB)SE^pK_}&- z0ghYf3fL+XzJ5`KeX2qxosbTh`n4LZNinMxpXJwR zopX_26Qb8D3sM(5xAHliqthdEDU`Kseccdjc8jK@6Mglg&C~ixuh>7@D{+cB{$|WNIk2SNDaL` za({n?%;zy^^gf;3WFA(@CfX_#hJgWd(}+r2t1?PE6lPA>Y~O^}HtsbzXC_8G%}t7K zuc&QeW~j+0R5qDxW469wuURi`uD4HC7=-Sz5sPZXKHFmRy6b0JJ+*Fihq6}d>alie zx~7{siUEUDF6nNYc4-~TSzX)M*l?XtEA8~Fr&upGn`Lz}d7s_aIno`dZ8r$bGu~EVduvV8P>az#0?OlUs@H2O zYNS?CQ*(z+u7P;XSl?|J5cP0m-9l}RO(yD@R<&uQnm(&W++}U5@3m-WYJ{_1vq_?p zPf2Q=P16#!XQ;xfYUymVSlSeG4wbvFRciADhGwi{+ko5LGvVRbbdz;fvUAG8G1iz`&23$MLlfM7cZ+3WM(!4RtqNyv&v@sQO5qRaoVCqztEp$WZla~R#_G2;=_W_S1uJCF^67R@JU(@ttZ|lIAUpS?l(D&Mu!%Mwe z>innA7d)y%O;?mbav_DE8C{Cf9*-?9R^qI<%j^{l+ue2_NeDxR$?1S}BqW${@`!+U z2!XE!*4NtVq~bbrZB3n|X2>qJ*4xB3vs8wnSM!hjFQ73yKN>f%Gd91ol%KRLSz0BL znZ(kD8c~BxhQ7FtzXNS35qYcCSb)`~BC&(q%dRcuu|G8gc9xl7Yakb*1TDnt4~eh%^)`Elri0)87Z zIQabK(rPKL&ql0+-zZqxwF>ThKvvOBP)G=h9H{ZytwJxH;lO(iGo(oj(n>1@@>BQl zGf-n0KNh`E2E%<_z)weei}?cNY2_z!Yv6zq{=>5&s%sF{;@QYXYezx0jkEkt(6D{8 z{9EQZWBj@0sBswDt{UQVk^XIdA$r5j|HE>a7nJ4zbf@>hIDhr<)#u1Rp>0$A{1ub3 z>e|p8(w}$^&jlX6mcOd3R$NnGC#r{O95Rb!Vw=@0sk6()L$yP-W_wMYNG>&-?R9Xl z0$RIWu~bsRS%(+GMW^t`AwpfZ^H;9^@vzwlPcs}E;NvuGobY6?TqYKv=UVu!#TGbD z9KqMW$qzCmqG~ZLNs(a34#8G&wFohHfjIH>#KNPr8Xcd2={m8VpP5@*T~jRqK*82J z{7Zs=)x$4zY=WO%IO%ZNJwmVF?8LcTP^4;cZ8d=gSUH^y&%JzZqNL6Q$+;R?aFxxb zNUNuD4{5ao=aSL!&vWw7w#WD}$aOtGemTquO4aZWEu=lKx`oe2g70!NuCFdE=K2?1 zz6K-CzdotSsH&1yfhmW>HZ;DG-=Cs|+wX!VyBki)9D!N1?=YW9f#Ei~Ae&lkvHRe- zo!OZS=eq%ZCY(ZqD&0a%P%g*kapJsnafclpe+lx;dIi5S#prN(opySfX7;0Fm+&(S za56jSHsJgUA5^)?%MSQx4yC=#FG1h!;3u9-zuuJdtCvG}WUk|9CXlQ+T-Wy^#RoZ= zaTskC@Uri%oU9de4769v=f&XP=yVB8lKz97SOOR7MBsvbmgCCfP5i8AiUXZ~Atx4H z|28ZS_!YCPY6N|81Xdz`h#7&?4itSOzwcaRK6I2{mwyh$4*2YLA*?3Eb?_Oo@Xpmc zTFtMVuk`XOe*w1<<`jilP$=39vrB7+hU#QCxwS?j7g@{_aji^buC;=-G>gTMrWMtJ ztB~7k(Mf$_JxXjU6rk9~!p&d`#y1vb5SIyPvVOcH13b0plg7e}(8<=qOt2VYOh?v_ zcO3-5*Bc8j{QtnnJTP)JHlMhW(n?Ng@cIeKqL)(VLWug2&O}6|v%a;^khWxokh!aH zWj--+ONOF`j|}zWYz-JVJQ3%b;V(K1>o2{wwGjS_=C5rP9DERc^q?RQeSEl(gTDO- zlZ@_rl1W9Wo0yV04n22UcpH$sa0n;-a58T53)G<7aKz%Bus9u7IFy6S1ph}ImNu)@ddQ`yb;{siaJ~&pfdPg{GKgv}%#})GRCIK7_Ts^Cv7E#$j=rvQuX?DRd@i-&#s!x)?&; zr|jr9YI@XzT8&Pl4WgR1Iw$-Pv1%P|hifU?h)#+=+6kqpn2hMP?CDptvXz}3od!&j z)#LQ|nms<3IZ#TPGF1wbn($)|*drVGxM9$w(S}=1;A0{o@(Dz@Ng3+i9k52WXREae zO$Tm-`!^0}WK(`?r_rQSP~85&jMLugft?53`=L~EHG167;f?H+t%uzXp~gD`N0pIH zSq62Bx(|Bz?8ElyaEt!6>?~7%2kApb3AdbnH9I57T3``d>2Svk4-RjoMSy3`;j3o` zHBm@tf-5tj*0a@Jqw7(0XjFqnWh?McxNwuJLPl~AY4L1#sC6x-)z!Is)f#pBoPFgj5D_ol8?dsTP*5%i%ufnvKvr03m4R7A$$;s>Zr!K*?`JT0 z$a0V=)dg$Mb|)~%D##)bmqPLqtKcHv)QAHv)4?T{pq_@uS@13!Q1excU_%J!Ssn#& zYUm9+8{G9Io(12hS!E03p>AaZx`WSD%&l7BW5J~W4pr!$-iUb;Vq*cAo(a1ERMYIl zw{If4MYxQRpMn0`3Gnj4zQLs*!~yyUF2V;n7_(bY6iL^D!wOlS4d^{3Q_${&>rE6s z0=&7?pjDU#gDwfPP3a0C7;z z{I6f?mq*TP5WA5&zK9Os0arVh&`Ujh1Q_^3^hG6;T{tq* z;Bqxk!XxP>{&54UmNMCk4Tr=0S49yhVCH=tGnbZc3U10vCuElwV+l z+vR2}=$&8S^tgw^J*lmnTkx~coqAZj1o1^x;&j9Cp^>oFJbNUk9WH$S&`=Sc1$u%> zf@^Coyi6>1bY>ZoA^|e7k@CZxQj(!0H*+d6$|28$dtm{Gy&qj3RSU}l?xQX&(TENU zzIp?SUdHg}e)LfP`z3u|8iJk}ukBbIjc!1hYNn1CTuNcr9v%@@HZgVCoKSnD%iVy+ zTbSIm2rEP!?FMAMnkfre;E;pefS$aWS*;A>3A$0$xlBBs^0502nfH+2#RR`*V2b2m zvV%axD8Zcj1!kw;gKq_czxBg);IO)c-Sh?&Kg_J`ilA6eR^o!QraFP|G-7X#kpeXi zYSR|*g?B#Gac@BT`j`Sdo#${&3;vA8g&}=~?Lq)wz;%0Id62u4V2RI-!ql&as24f{ zIn)IZRiw#sN+QUDPXm^a#Lo8e8_;A7Bh2M&fwv{_{Z=OUl1A{(Dtf4FWe(EMuFg|y zX(%UvS3E$8g6(*z3rYpm0um13G&_Fj0^g=xaQ|?vA1o*gfN!#%eo#=3o*G~zh`*cR ztRQjZ+Uw6SB{ythI?$orObOciOmZUnuYU`qF`J+hs`^sUf#jO90(9e-0$cv#eQXKY zUXjn-psrY0JoLs~36UKH9$U&A1o(D04p~A(L)5a+>+I|8aQiOuLhA*MFuA@#`!v2-PO5@;$psAp+%Zz<8~{he+hL_5asUwcN{Jcv`=knY_I5paJhLgKvnCmz-e5qc{JYky%~33#&TZs)g&`UZ)vudqWL*VFMTVeJE@k z$^oWy*91iT`&JerelAmkmi;a{3k~Kn$8eg4IhX!F93N6sw0nrjLRVbJNYN+JOzLW7 z_%lvqu{1P<_jYA){hGXl1#d~9!DUQQ9?(La5uPn8xFxDD@FEBjd($$|)k91?x@j5H zbPlYcoz+=b8<+oBtTW4*e=o%vX>7uX(Fsv%4*FXRvkKYIWaOi5>wzj+ETckCa*~px zH=`4Km>l$z82I0+mLzWQ-JYrq#QmaApMm$FL@h}%=;ddbwE4ZU%oEFXON6v|qXC0+ zsH`}kgc>6?OT&mhYD!|;O7BB_*)I8zuR{;oIJjc7WGFpkW{}B|U zOD7B+3gSNk&${b9J^lRw(G)9c>=Q58W*O`nU)NC4~jKFdv zqG>Xn*_seCH*jMg_daD}QczX~b58`^Z!#7UqRV7F5$$hhE^60iMYOj>j4nlO*^FzU z>k6U)WoS=2vo0>^pb_RZ1}hsn&SgaC$ORyavUISX3rwBLUNmA$4l{--?kLGXZL67- ztOfzrck<1*&Ea22(U+Wuezu)S;)d&Rn&OATku}J|V^*Tia+0{jjiF<_6b>rWR%N44 zd0?#$eJ$XkH-DX4gdY?B$Do88^hDvH1c~)=e5B7~5vxWT;qTuNfO5}cY|VgNYjG8j;aPHt!N!-geM3Sd9pM&OakIf!XV z%Ggl0V2;8RkQaE*jV{7iU@S<`l9XwM00@4=ho-D$p>~4eta>~qwshylTQ=nMm;;fP zY@r7CM5kWJ%w2PSn}icFk@g6E$7gcl78@Ta;%3D1$NE-%gQt) zGIBY2jbN7qB_1z6%)wC9zbT3ItC&I5JDQMz*8PP^S=Jzc&#Psjw!Y*vq|?K-H_6HRoA+w>+6%h)!Ic5QA=94etE1)y!MyrL?#V zG+D}U(XC_LoHTqD2uwgT?EgK!8L*e=jpN*``TI+m@@TX>y(|ss*F~>DyZ@aSJAbB( zxh6Us@5yrsDwt7Nu z0K;3e=lhV}|I{<`yLq9IfD=58Yxbb+70gZ&@SrykHy*`RGMDAx9RYbW`E0F6*H$tu z>xs6IjZQESj7X40Dk`TG-gvfC#~JeC9JEiuL6ZYtJ`U_=M{00WpvGtZK=i zgvoI!lZgg|jJD|q(L&bnY^-FDbS}}s0wsdeiI~R5^Wh{pgCB`-VJ`j+D9g`>k|a^xhx#U6y{c9~#c-|ES0}i0UIwpJRmMmyBH2U*l zE_Qe)fm?7`~1!{D6J=0rxJ}n4k;Fl1FJQt?tpv?`;RD9^m4d|B*j2)f$5I%=- z&swG;E10_qn@^HAM-Wi1mTttyY=dwXZXF4}Zh!6_KYW1!HvX`uE`_c8vvEASFD+lk zWTrt@ER2~PU|`HyXj@!*4EjdOO+fD-&r0OLP6^I70Kq1-Z5?w1EK-0fn9o?xJQ$s^ zm_RsLK+Ib3qbE0jYd@c>5~H2}DUC<(HUjIRh@B<~?%N0sbao>%QN2W*OU``G66oI* z3?E3dD42C6OS%S6V$ap#jEX5-s;Y|;!{Lr-iyFe2>zbH`A3sP$jsm#jpv_8V`wwzQ z52C_jV!>Cc2zPC&b8>h74*vspi#lTN`p?1GBCrra5sWo7Gp40djJ$So7CY}WLy}}6 zb;E063`=*<+J)r%S)CtmVOBpnqS5*y4*JhzLK3>UjS(%|v}OL+ZOmLWI+mQCi%`CGbB1-x` z_IVZ=UW~BHa6p8YMC9Tu2_wO4upI9C0rrQgm=#MM?%C=2NUq7DI-IGKd?g)h3p%Z3 zRLK$8OC0EAQ+&p}S;y2bQ!ZHr5y_*4Ed8QEBJ;kEE$9t>de(ed7jtuT-x748Gm1zI zi?dER4;4JOu<6grM+Iv+TjoVZ=8wx4EMR&sO5e4YoP0UdeXU|`>nc1~ssX;mWE>Cz&k|E%rBW8#sw|xlKTgYD-izR0u1HSLfPs{0rH9%zIy3g z&`T^~fq*r`rvdaj3t3)^P(iehz}R%4=ZBdau%v<)61OCRn{#=Xd?=2ns@1$=grdtE?+zrjR&e@QSB4(4VCq^aYUqzuC`JFMKj+#}UYC z{?X3_)c;RLK{rh?RmqSj3w@Ia&gJ?84BN3p1@Hmm+4H|}ybw-HlRXop*Gu7q3eH#H zZWLAyv}%GWTLv2O#XE47dt)FWoyJqwO)$;C8N^Ch_>I?>=Ayq%FsVscMj;WQd$i4+F~3ppnIwJ%I*Xn^5W~w6M++SxV{=5kVs_Ljo?K-xQW@AicPP*)2Ffznc;3MeB>jY$v|@jphVyTlaSgB zn~wdLL0#(sW}VWEZ{GJ%lNPkw5uM?70?ogBXBrydn_d7LwuSV5!i0S-5?{*XuAqp} z!2_`0IDLTOWrg%wP_i(_TJ+@s#s*&)0|kmqb}Gn?%`iu3-$AAyHX`VYgG^=uz9>=x z*G_%tWGqvdMx9Fpk>}@d{UN4YgS&G|$VK1(7kpAg=+Z+>13Gbt$rf+gf&+z88ZObD z34wua4lz9uRb>1v=(mTMne5;JKFM6zZ8SX%pWNJbGhx|n`yj*|x|O*Ishc=ykKe`|j7F^)(aGqJdrLFX$=jK?vo;Y+1R|%`WAtz+Te<}u zzk}HZabnJ$%xWAbek3SBt~;1GWWJMOp#CzbN5|pSFC>^Qi${N+D$BUx75Loh-|u9e zLekHRv!cByEx9tIDwLN3Te4OFS8hr|#siPzhxHe}76mWIp%4GTEu4S zzKTO2m^EnQy};4Ooa!x0s@KvD%>q=>eas5h=}JJn`@w#`e=qm}lAAgUV*TQ}>+WM3 zN!>ia{gwpm3?2H94>B7fpg-DEYKkPH1bJUw9gp65h|xwst$CYCWUuljps{0Mi9+Ku z=zUK03iRADrjFLZH8S|{@Nwp1YzJ;R&Nz`{M`bz6dzi7J*RM+8qWnkTQ2VM!z@Xm# zFl3|lUX>7!G9Q840DpX#sSX{yrac1S2d{|$m!ORs_vTo$kC@iVb&tqOHB1@64He~y9&2>sij%e4=H%5Sp@C+QWac|rFVVt9>^^4(z_1y z>N#A$^kc7r@5#c=MF)Il&<*!(;9@Vl{RIa@dtPEPD>?YvwmUiC(HGbzRuXE0__{*E>Z#BIkyg0ksbz2%DIqI8W&*Zmf)ojI9^;p9O^|w&9g|x zR|6dWi1Bb1DVV7{oAe0C;Gj?=2%oTrB^9nAqoNOAj?Khb`?z|-G7%;fHCBgnP=r7E zeKtubtr%{~yvj@B7>Q8vJOFd#lpVgJOr~ui`4vn?I{aiqK`%L-J;e(HV|3_)%#`c| z%pD~d9<$c|FuF1_H0)z=|4Kplks=Vil#z$-xv!9y6iEa6dRtNcnzOVk=516=hUnaPXjNV@LlZw?RVKkj2xL6yhu>jzXciiQbPh_K7@-wyEZXtX=kk)FC5aNegDknr-Z^i zha0|?3?HC{iiU(62@37gdQu{7__n^NHE~F%cMz~G`1TL zcaljH7*8ghN!^;XDPt#bL(`^-orm4lcE)3D6W0@W8plpjKVr9WzjN-r|6?E00&Y8D zMo9nv-^aPMh}RdSnvGtXqN_$?8)G zd6S7AoEs})<~ny!fW=6%`xk+tq6KBE!jR|U<5n;!n48T~I53Q($q}8`ywC3++!t-` zs2OSuv?1ARv^m;LZ^W0MHfz_`Hbckc<>7EIzj&-#DZFErZ`oC!a>rkQnnj<{W&@SR99T9xgmL(T@id3=M~h7HWTWGQtBo- z41KQAMQ2i~%v)n$w4>(4zM5RXtPKIr%-|Yo8dzhuo;x@SQ^k5K0L3LA7=?R5)@30( zr{`M{TGCBuN2cRWLOU2ox`gR5oLw6^1cB5hI+=T!6EK5&4~~TH7^44LLLX|3hMI=f zN~%%ctzR_;iwH4;=__b}z>-rq95*pL6*OYAV@&~fyU2(rMhPabIi2s-y!f3c+_*0PO)` z;n1zYJ#v8Jc&*~K&2c(#3Vj!uptt2l z7j&?DHgSS3QaFL59f3B&OT|WX1K4r81_$Yl+a{6VABR>ZLr!8dSm%wUAi1eAzQ%O}Fd)VUWN3zLVnpd#CNGDapqwW_%cgW)56F4A^ROI;&~WV7Q99-~e2IeF{Q z@oR%8C5KI8vChG$=E0z_yzsx&sb>Pb$T{IYS2uB;#Vjr`P@e~{m)D!P_-eE%+Qhu) z@W>LxCB$Y1ugBJ0emYdTNUXiH+S{~FRzh_%ERZ9=`-d zZ`b!7ffVZ9PpKS4nAY>BeqerK0W|uW#}QXJbbiO<=7`c$S&gI?&ZG1r9~4jjuCOrg zU~FU@#}NbPzwo4ay;Dp7_KWE1&P(P}fzn8*gu_bb3;xCYh3$}1UDrMZYXs}A+`;!> zga-Q3Q|7-}4&2kPnboAZ{^DssG$>X-V{TbUr=-N0*UU}lA9%+6(xL(ro3)HTB<>*w zE}G5a4=(}q~9tTFd*Z=90eH&H;lk| zXMI83x@y2t8oC88!5O7{d7!P}o|5=s+>;{vfhh%dl+a-pG}L4qs*zuv0LvndM#O^` z&6VA{Nm7UqGjnKU3fFb?$Cy#cgFWpY9JEH-A3F3PyVsvda6uOM1LDHZ&653TU!}8+ z3J}+m<7`3#IMgDPDVaL0(}1y2-I+n^j=bK4?)v!L-BTsv)br+mJXSV5m6#%sQa~n| z(NH%!q<@uqt%8W9OUoEUxsB-R8DJ2EpRrwn2mi$2Kf`ww6ixZ!?_MxpwF&Dy2Bn4OGVjR=;r?3dM|$!U2kCs*S3q1Czn=>rc-d- zpL)w&)hxrF*$uG+C`SV{CTI;@FzO7AL`?ivkhdJK}Xg@yQ5IA4>Tl2&M@!KDlmQ#Gq;zDVY#dOPJ z;#@~v`RNmNdE&8mFfUy0H}Akw|8<;d)9=0F`ghG>vJ2*QRtG`I0m`}$1;(9o%M*rS zf{V~GSV&GsW8+O8&#u@w?(>5-Q+w5xz17IvD1I&su}Y)6v&i5q{pcG8t6+JYO+#2m_!x5EEn~A{^_* z4Lg+jaRTcx@li5Gp$Axa5EVS##C5ZDP12t|Slu-SF@6O8dunoIG;vgHd)~L=s(>H_ zQ)}aiF&t*Vv0lQR8WezU26_|tj?=W1a{-rX&)Aq`{Ldata%M|w>RiLxsqQ236xkOVdOylag?dM)nn^cKVGl<;(8fIj}3zxqF}&FMQ#C zBhv4q4<0y%jGkK%5Z2#sAV*DPN8k2HPq%?fqjn(!6B0uOjmSRJQT)0%VW)XKOZol2_pUAX3LJ z06TloLl}9aJP5Z%V|$HmWQXY*AXx0bx+2J3H4xq&H2V8`x+3&P&n-O|KzP^QplY)p ziDgg-A3fdS9pSCWs+IK5Ga$rl;X1sY9jIp@2TuR)&i;YOz;2{~+S=ROO=$Aq9<7@r zxEZM3*xpNs+hX8qt)PKm`!M&6CbmQd@EPED?nVwELT_XzvfAw0-PlLT!2+0`Jw3Rl z&(jq~-EKx%?@k^prm=U|UTRg`eMduCl5rEJ;rC+_y9oW^&dB!2z}}z-9bire40}+< z&YrE?BU_QytB1PpMQgkEMEZLIMtB#pbM4#;K)9PdVf29sML$G1fWpInM*5BZ-Yo+l z0SpNsHt#edTaeWZxxE0VD&2>1MaI{(u9Ksbx z=YG&o0RtMmoFkK1QXEJ`?>us$TF>I*cS`e0wN%8ifX4%qI&@@l)4%)Hc&+_Jx4i#S zeW`eCgKzmtwWp~M=)>yW{BGWA;SJ_36Bq9*s1!e{tywDm{FUN0;!CFsaF5_v{mL~B zGz2hNuyIPXF^0q(G0M2h$#lh6Ucn*j-baHaV)A*>__@OYr6L`=O? zSD1$aj7t6}UNq_!i|^c2?-4gHt6uU4JP{C}F{ClLwrReHq!Tf;Nu#s_0<@I?sh48H zaELZQQhZw9N$azAC&-GrDH6$9Bq0IUdTF#kMuyFR;)!%u{K%rSO+C``3lUPiN`c$x z=%~QKbvYD7ZguH$I0z|5?x-pr#Z1qT2#WfIBnm}iiCK3J=u*CpB300BG}6XK4KI&7 z8`X)kM~X_4YGgKs6frA-Pp5B&NguJSAQj5Zs|$vgBANG|03Cs|rX`Vi+zP!2q6QgkzkX`Wd%SGvmRG0;>ovV%Fs#)fur_Lbra zMn{s%P!R@`kO86GqGwS5{3LFTJcYu^*ah(iZA7I%Um`jadNs`j3iqUG)wy~z)Bxh1MgUZh00Vh)k)YeX2-9j2g>K2Q*lCw3;t5|odTb}5cXOhy6pm90kIM}J_Fc?1#w>uhXNHSt zY&~>~ly!rtL0tWDCY+N#X;v_!`uaynn8Msp>reWQ%cqmPPpT3=CT|b+3?!)YtL#RT zi7<>A2aY6;(4ja=#-iuXO|TS9Yn8NCC>AS|0?FUA9B9i6I4N_IICd%GIUoW_qo8@c zh}2}^t6rY+PCY$MS@4!J;gTHfDHUIRrFey2!g`N`Qf&zv9LbbiqIi>zB+D_4XF@0GE_h zMUBAj+U}VRY??c2wv(Kr!k})Qtuu2h-7M3S%gWutfKU|LTuT==35Ap_i`eGY!uwRi z!e!Q8O4pD6s@Yqb%!m=%voaI9q@r5M3ARiEJ~wxQ4Ml!y0CE)3Di)4h+ptqI3)un> zs6_Be9%m||XervD@5fqGtIr`-s8;0y)AMT)tsAN#NK;w18h2OXqEWZxgBm%vTmf?2 z3pG;%uCXOCHnjxNE4EN@CeSPJ>L%PP8kmAbHHkgSa-yqOA!8}LAh>vx({k>vw;G|v8-6!)#6>0;N3+Nkd(ujaLAFZQt&IvPEpS+_R5toHT%&)B|P3o38J(#0iTX1H9nLyALwQR<4>6b`bG}Fh)wGqvb*4NA+ zyW_1UxVN3)Xi0y-p!J&4mZn{mza&LMa*t!iXjCFQ4Hy`sU9)g*JVXO+FO+lWm0M%tb9BTx-$jUYR`JZfh!E0w8FO zh{C#>3UPd@EWZd>9ZLa73v4)LfP6{b`~6Dt+BiGk26F>ZXecnfUu&?gG1yW|igrY_E z2+bTBWu`iuK)AoFEF%Ev@C2|Cm*U+ONt+*iKnpdM*rni^yijdg&8;pelXas-6(tgsK{0=({@Vdou_Tc%ZN!$Xv!3W~eGQd24J zYA#tEcVWdIQC4_z0O!agAM=v)tTBp1Dl^MhbDzl!__Ps2d5|<-QJJ8&T5_V)Q6mMl zO!#vQVB5UZphjT9&ipO~mG~$Lv+t|s!ciw09i&Ms1stQ64aXRY{LKRb&GA!c6DTZW zb$by|L2>psrIms@8_Iw`Km)wx+MU$^@dC$UbN6!#T%JzAa_wL`b~te~)y!oH&aR;( z(S-#}+~T*6xxKtjt+p(l2nSxu0I}g%TdH{QH>KXCU=BS>jT2`cEv(Y_9`!x9pf2gc z0L`ThT+ZAEb1mu7Q8|?Zu~Kh~Y)EwrXYY)^h-z9qlBP@i@Uf~2(X@P7p?L0PGq2FY zP71jNLW4j3=%SJklyqmhTpMKWmDwzDUJ4{a#=6(5Jf-<)TCxvu-#Z1>Ny(hmra&7i z^vqsq+m|}3T9vwK-ZG)6sxqXjYWD9|R;@;YZUNO=8V& zh6sd;=PH+r<46Iw+-18IBdda?9SkL~g=tY;rkXgdK7ddP1sJ2_2)tjSnmF^og3{W| z+lMtSKTU@?cdn#T1izc-6;o?#7A76D3UXD4C1w#u0BVu&WNvl7O-akMmK?8%!c7y` zfD>(gHPk468CbqVwA|ufAy!r86&FF2r$_=@mKF=vm6eEl+Y6Qyrij?06*=na6B85Q z+&z-;vJOf-QMIh>LqslDGOD3eP-}1XHrOfDE zQnW+Xv2Al+sd(hY$^!8hC+b&-<9kBoWhye$S_{0n zIfDH?wlFVlI=p1HbnQ;fh89;NDh$U_XZgM?%h?H;a)91K6TGlJR3(hP-i3=vG=n|t zY+K&yd{}=G))Bu0H`=W%D6Z0nE*WA81?E`sB!~$=F0C#e9;Pi=Rg;3KX!({=Vw&k3 zQk~(%>{4v5I2Wil#81W+<*Ovq+7hf9`=|N3;z$~BHPuZfnBawsu#WI86!Mm(j>EcFA|G$6g8@Y`M^Bz-YU zWk|*Tk1Z=%B7ru=d&|mkCFh;>-f(w9I@F<$%HSD!Q7s~=Lk6xXLP#e?;TTRT;64JZ z`6Dh>JiNhImE@jOx_&udmhBk67={(@YJzo$4Nvowp+VatAvxisJ^s)p#7m72BqY~E zOvU68k5FYGKzxyMqW+r@>?*W;=th(+L{PAyzBH>;>a7pEOKN5xyC8FB*~ z2OuuEs*WN=E0!dyEu8-;-i@pr~zz_V3M{++r1-LxxNCG}IrCb?${Xhd;T z+D_hI32-Mi4f}P*%77u@E+%U;Y^k-y)OYF|*od;*F|#fsihd7?91Z&2`qGh)=MQd~#GSDI*vp4`AvHIou<046UOSdKy-8gjOS z6^t8OammWSaDU|19%HR>1F=xu@u((q00IwjXp@`xqYz{1A_iPoVZ^Go@j*g7lnT{y@aH?=Lfb>HI+hCl7AaBB*4uZw(vV5aeS6=onr)rRha;68^C? z<}ojVrA%`j&Q`+eIcJ|K%9pIT0wEnK6{T zr5^T2;n4(?a)=c%${)j}g5)9e$;=GMDh@8>C$oPjF%jb685~36<1{jyPuge{DtTaz zR;f`O_mb^ODWjIMWpo~!jp2G_Xkn<4&Y`5VT4&s#j4_9j?mEOUTi9!)8>40*nt&b*n?myq^s@^@GmXRWcM>)YGw}8e_;8@K3m3>-GHl6@#7e zSyJ?Puw?(mb(hFn!$fQI7L8gefP?cgjV7?e&sfBnFo1!uoZ#zKNcG{Y=n-yPv#xUYqtBueqQ*)I25G0y_Xn;w)%WfTTDITR76<>PBFo7{Gwf?{0 ztW#nVM@@){-YFrp#%(axh&jxMwnO$f-gm^+8wxHx9qc4y54_tcoTJ~}Nz9t?uSm;k z_bkieO}E_N@hfqq=d8}ExP`OWOz296#iMfLJSHyvx^&GxoOI8mWr7vugm-`>t4rk+YuGujG4}3l zJd#0;wL8!iBu}GL)Xo;!&%%pv3@bjP?r1kPciatNwVRj&-tPFk9i_vZ+OiGz-VBP= zWq{1J=YY0tJYD&;n@IUY3G(3}0skA#u2R5Zu_Mu6R>=eAV zXArz{qFyK0U>5CE-z)_$m8gk(iI;y}x-tn`b;6NJ08E90cQ}X{*?6iOf>epn4azl> zG4ar8+^tMeVh6w)<`5B6;o$zI;9x@|8H}|rL-T@&;qG%%JW-RshT~xJw3X40Guh(A zs5I?g3A+-1wX@FfUS>Fp9h0#RB&9upa8bI+Dg{Da?xbJq@c>)DBw2}qqY(_{61B8V z3z#FpW_uQ$2<#pK9aN$(K|ZjSb|6$eL5WmV+YOv0R<8p|?M;^*N)Z&bA%#L|m5Mds zp-`XrB(g%0u0!$)T6Zg3szFk@6d3VY2Nd#QBUEJWogYbPPih^46ep?c0uoe|=Qxy1 zNg}5n2>e%c=0cWg_sB37EkcpQI^oo^<_)TtjO zL#C=I8V~jmXgc&0ri1GH~42$LUkAW4k~Iz_K&jL1VD#7FaW|DCCSt zivgXX2Tke{t+3yulyDVjZJE0OzRy{JzPk!I7X|^r1zaWE%@yp7x6<8BY1edWIIGRGM<>2pR7hvsNQ%NgPiN)P4O1G>sJHod}Kx(HBCa2JI zXMDC_87Wg8*?Ntkl?=QF4LIr(FO(D4* zYm=pwrH$yEhedyA_GD~=(L*;OaX?>fD#}1-T;DF?rZY(d?jo4TG*WbxpqZYt1e3yC zB_yq&MBKe5>~3Uad?>BQNF{M9R+n*}yY^=-x_%jlOmY#iXAWJx1Yg)bK&`nn3)CW*Q(;l{n4zAu}{ zT(V6(Rk(3*=gey%eplo7L;AljQbk0j&ve_BCaXbUCBj#hQ41^-TwCBE?x> z6${OLn?ptw2#%HB*5(!zxf28jT`r^=D-;j$H{R8^W&zbM zmIJ^v%Nk0%BEwqXj(wsC*UIV?hT~Lzx5pIQ>E?Cd{xm=HvA~c#(6OT2E$MZIeVrXq zQ%Gp-w}cgrsH45*2IP}z=xe~i{Dzw;&8W?D!G1c)LXsW30Nmb0M}Uhy6g`GM;NU>_>PSr}g) zc*dxlP-I3nJ*#AnKoF_}+RcXU+#VA?kc4hFc0gbi;j!Q<@zB>7;d-Qj;70NDfG_Zg zPrYm6$A_O9{M6#74nOtyX~2&kKU8Kdej4!;JpZY8LuD7X{pnAA3%_x={=R2Fd496e zd+H6}x85^PZTVird^oZFdykzSJ^fg8%c{p8U3&KKpa1N)Ufnmg@8}=B@%n9x{_&cT V@sGE!eEYVAANarrws!95{2$Uu$Aka? delta 22235 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 diff --git a/flexiapi/config/app.php b/flexiapi/config/app.php index c4da9ff..4a79527 100644 --- a/flexiapi/config/app.php +++ b/flexiapi/config/app.php @@ -31,6 +31,7 @@ return [ */ 'api_key_expiration_minutes' => env('APP_API_KEY_EXPIRATION_MINUTES', 60), 'account_creation_token_expiration_minutes' => env('APP_ACCOUNT_CREATION_TOKEN_EXPIRATION_MINUTES', 0), + 'account_recovery_token_expiration_minutes' => env('APP_ACCOUNT_RECOVERY_TOKEN_EXPIRATION_MINUTES', 0), 'email_change_code_expiration_minutes' => env('APP_EMAIL_CHANGE_CODE_EXPIRATION_MINUTES', 10), 'phone_change_code_expiration_minutes' => env('APP_PHONE_CHANGE_CODE_EXPIRATION_MINUTES', 10), 'recovery_code_expiration_minutes' => env('APP_RECOVERY_CODE_EXPIRATION_MINUTES', 10), diff --git a/flexiapi/database/factories/AccountRecoveryTokenFactory.php b/flexiapi/database/factories/AccountRecoveryTokenFactory.php new file mode 100644 index 0000000..dcc0dee --- /dev/null +++ b/flexiapi/database/factories/AccountRecoveryTokenFactory.php @@ -0,0 +1,53 @@ +. +*/ + +namespace Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Str; + +use App\AccountRecoveryToken; +use App\Http\Controllers\Account\AuthenticateController as WebAuthenticateController; +use Illuminate\Support\Carbon; + +class AccountRecoveryTokenFactory extends Factory +{ + protected $model = AccountRecoveryToken::class; + + public function definition() + { + return [ + 'pn_provider' => $this->faker->uuid(), + 'pn_param' => $this->faker->uuid(), + 'pn_prid' => $this->faker->uuid(), + 'token' => Str::random(WebAuthenticateController::$emailCodeSize), + 'used' => false, + 'ip' => $this->faker->ipv4(), + 'user_agent' => $this->faker->userAgent(), + 'created_at' => Carbon::now() + ]; + } + + public function expired() + { + return $this->state(fn (array $attributes) => [ + 'created_at' => Carbon::now()->subMinutes(1000) + ]); + } +} diff --git a/flexiapi/database/migrations/2025_04_30_094148_create_account_recovery_tokens_table.php b/flexiapi/database/migrations/2025_04_30_094148_create_account_recovery_tokens_table.php new file mode 100644 index 0000000..ec1df9b --- /dev/null +++ b/flexiapi/database/migrations/2025_04_30_094148_create_account_recovery_tokens_table.php @@ -0,0 +1,44 @@ +string('phone')->nullable(); + $table->string('email')->nullable(); + }); + + Schema::create('account_recovery_tokens', function (Blueprint $table) { + $table->id(); + $table->string('token'); + $table->string('pn_provider'); + $table->string('pn_param'); + $table->string('pn_prid'); + $table->boolean('used')->default(false); + $table->string('ip')->nullable(); + $table->string('user_agent')->nullable(); + $table->integer('account_id')->unsigned()->nullable(); + $table->foreign('account_id')->references('id') + ->on('accounts')->onDelete('cascade'); + $table->timestamps(); + + $table->index('token'); + $table->index(['pn_provider', 'pn_param', 'pn_prid']); + }); + } + + public function down(): void + { + Schema::table('recovery_codes', function (Blueprint $table) { + $table->dropColumn('phone'); + $table->dropColumn('email'); + }); + + Schema::dropIfExists('account_recovery_tokens'); + } +}; diff --git a/flexiapi/lang/fr.json b/flexiapi/lang/fr.json index b4fd6e8..4895735 100644 --- a/flexiapi/lang/fr.json +++ b/flexiapi/lang/fr.json @@ -141,7 +141,7 @@ "Realm": "Royaume", "Record calls": "Enregistrements des appels", "Recover your account using your email": "Récupérer votre compte avec votre email", - "Recover your account using your phone number": "Récupérer votre compte avec votre numéro de téléphone", + "Use the mobile app to recover your account using your phone number": "Utilisez l'application mobile pour récupérer votre compte avec votre numéro de téléphone", "Register": "Inscription", "Registrar": "Registrar", "Registration introduction": "Présentation lors de l'inscription", @@ -198,6 +198,7 @@ "Username": "Identifiant", "Value": "Valeur", "Verify": "Vérifier", + "Via": "Via", "Week": "Semaine", "Welcome on :app_name": "Bienvenue sur :app_name", "Wrong username or password": "Mauvais identifiant ou mot de passe", diff --git a/flexiapi/resources/views/account/recovery/show.blade.php b/flexiapi/resources/views/account/recovery/show.blade.php index 9092b2b..26a2343 100644 --- a/flexiapi/resources/views/account/recovery/show.blade.php +++ b/flexiapi/resources/views/account/recovery/show.blade.php @@ -39,14 +39,18 @@

@endif
- + @include('parts.errors', ['name' => 'phone']) @include('parts.errors', ['name' => 'identifier'])
@endif - @include('parts.captcha') + @if (!empty($account_recovery_token)) + + @else + @include('parts.captcha') + @endif
diff --git a/flexiapi/resources/views/admin/account/activity/index.blade.php b/flexiapi/resources/views/admin/account/activity/index.blade.php index 6908d72..e4fe0ba 100644 --- a/flexiapi/resources/views/admin/account/activity/index.blade.php +++ b/flexiapi/resources/views/admin/account/activity/index.blade.php @@ -74,9 +74,9 @@
@endif - @if ($account->recoveryCodes->isNotEmpty()) -
-

Recovery Codes

+ @if ($account->accountRecoveryTokens) +
+

Account Recovery Tokens

@@ -84,11 +84,55 @@ + + @foreach ($account->accountRecoveryTokens as $key => $accountRecoveryToken) + + + + + @endforeach + +
{{ __('Used on') }}
+ {{ $accountRecoveryToken->created_at }} + consumed())class="crossed"@endif> + {{ __('Token') }}: {{ $accountRecoveryToken->token }} + + + {{ $accountRecoveryToken->created_at != $accountRecoveryToken->updated_at ? $accountRecoveryToken->updated_at : '-' }} + + IP: {{ $accountRecoveryToken->ip ?? '-' }} | + {{ \Illuminate\Support\Str::limit($accountRecoveryToken->user_agent, 20, $end='...') }} + +
+
+ @endif + + @if ($account->recoveryCodes->isNotEmpty()) +
+

Recovery Codes

+ + + + + + + + @foreach ($account->recoveryCodes as $key => $recoveryCode) +
{{ __('Created') }}{{ __('Via') }} phone/envelope{{ __('Used on') }}
{{ $recoveryCode->created_at }} + consumed())class="crossed"@endif> + {{ __('Code') }}: {{ $recoveryCode->code ?? '-' }} + + + @if ($recoveryCode->phone) + phone {{ $recoveryCode->phone }} + @elseif($recoveryCode->email) + envelope {{ $recoveryCode->email }} + @endif {{ $recoveryCode->created_at != $recoveryCode->updated_at ? $recoveryCode->updated_at : '-' }} @@ -120,10 +164,12 @@
{{ $phoneChangeCode->created_at }} + consumed())class="crossed"@endif> + {{ __('Code') }}: {{ $phoneChangeCode->code ?? '-' }} + {{ $phoneChangeCode->phone }} - {{ __('Code') }}: {{ $phoneChangeCode->code ?? '-' }} {{ $phoneChangeCode->created_at != $phoneChangeCode->updated_at ? $phoneChangeCode->updated_at : '-' }} @@ -155,10 +201,12 @@
{{ $emailChangeCode->created_at }} + consumed())class="crossed"@endif> + {{ __('Code') }}: {{ $emailChangeCode->code ?? '-' }} + {{ $emailChangeCode->email }} - {{ __('Code') }}: {{ $emailChangeCode->code ?? '-' }} {{ $emailChangeCode->created_at != $emailChangeCode->updated_at ? $emailChangeCode->updated_at : '-' }} @@ -189,6 +237,9 @@
{{ $provisioningToken->created_at }} + offed())class="crossed"@endif> + {{ $provisioningToken->token }} + {{ $provisioningToken->consumed() ? $provisioningToken->updated_at : '-' }} @@ -205,7 +256,7 @@ @endif @if ($account->resetPasswordEmailTokens->isNotEmpty()) -
+

{{ __('Reset password emails') }}

@@ -220,12 +271,12 @@
{{ $resetPasswordEmailToken->created_at }} + offed())class="crossed"@endif> + {{ $resetPasswordEmailToken->token }} + {{ $resetPasswordEmailToken->email }} - - {{ $resetPasswordEmailToken->token }} - {{ $resetPasswordEmailToken->consumed() ? $resetPasswordEmailToken->updated_at : '-' }} diff --git a/flexiapi/resources/views/api/documentation_markdown.blade.php b/flexiapi/resources/views/api/documentation_markdown.blade.php index 2189105..1e01cfb 100644 --- a/flexiapi/resources/views/api/documentation_markdown.blade.php +++ b/flexiapi/resources/views/api/documentation_markdown.blade.php @@ -121,15 +121,6 @@ You can find more documentation on the related [IETF RFC-7616](https://tools.iet Returns `pong` -## Account Creation Request Tokens - -An `account_creation_request_token` is a unique token that can be validated and then used to generate a valid `account_creation_token`. - -### `POST /account_creation_request_tokens` -Public - -Create and return an `account_creation_request_token` that should then be validated to be used. - ## Spaces Manage the list of allowed `spaces`. The admin accounts declared with a `domain` that is a `super` `sip_domain` will become Super Admin. @@ -249,6 +240,15 @@ JSON parameters: Delete the a space email server configuration. +## Account Creation Request Tokens + +An `account_creation_request_token` is a unique token that can be validated and then used to generate a valid `account_creation_token`. + +### `POST /account_creation_request_tokens` +Public + +Create and return an `account_creation_request_token` that should then be validated to be used. + ## Account Creation Tokens An `account_creation_token` is a unique token that allow the creation or the validation of a unique account. @@ -294,6 +294,28 @@ JSON parameters: Create and return an `account_creation_token`. +## Account Recovery Tokens + +An `account_recovery_token` is a unique token that allow the recovery of an account. + +It can be used on the following page that also accepts a `phone` optional parameter to prefil the recovery form: + + {{ route('account.recovery.show.phone', ['account_recovery_token' => '_the_token_']) }} + {{ route('account.recovery.show.phone', ['account_recovery_token' => '_the_token_', 'phone' => '+3312341234']) }} + +### `POST /account_recovery_tokens/send-by-push` +Public + +Create and send an `account_recovery_token` using a push notification to the device. +Return `403` if a token was already sent, or if the tokens limit is reached for this device. +Return `503` if the token was not successfully sent. + +JSON parameters: + +* `pn_provider` **required**, the push notification provider, must be in apns.dev, apns or fcm +* `pn_param` the push notification parameter, can be null or contain only alphanumeric and underscore characters +* `pn_prid` the push notification unique id, can be null or contain only alphanumeric, dashes and colon characters + ## Auth Tokens ### `POST /accounts/auth_token` diff --git a/flexiapi/resources/views/mails/recover_by_code.blade.php b/flexiapi/resources/views/mails/recover_by_code.blade.php index eedd5f7..0384d49 100644 --- a/flexiapi/resources/views/mails/recover_by_code.blade.php +++ b/flexiapi/resources/views/mails/recover_by_code.blade.php @@ -9,7 +9,7 @@ You are trying to authenticate to {{ $account->space->name }} using your email a Please enter the code bellow to finish the authentication process. -## {{ $account->recovery_code } +## {{ $account->recovery_code }} @if (config('app.recovery_code_expiration_minutes') > 0) The code is only available {{ config('app.recovery_code_expiration_minutes') }} minutes. diff --git a/flexiapi/resources/views/mails/recover_by_code_custom.blade.php.example b/flexiapi/resources/views/mails/recover_by_code_custom.blade.php.example index eedd5f7..0384d49 100644 --- a/flexiapi/resources/views/mails/recover_by_code_custom.blade.php.example +++ b/flexiapi/resources/views/mails/recover_by_code_custom.blade.php.example @@ -9,7 +9,7 @@ You are trying to authenticate to {{ $account->space->name }} using your email a Please enter the code bellow to finish the authentication process. -## {{ $account->recovery_code } +## {{ $account->recovery_code }} @if (config('app.recovery_code_expiration_minutes') > 0) The code is only available {{ config('app.recovery_code_expiration_minutes') }} minutes. diff --git a/flexiapi/resources/views/parts/recovery.blade.php b/flexiapi/resources/views/parts/recovery.blade.php index 5d1c283..311bb87 100644 --- a/flexiapi/resources/views/parts/recovery.blade.php +++ b/flexiapi/resources/views/parts/recovery.blade.php @@ -1,7 +1,7 @@

envelope{{ __('Recover your account using your email') }}
@if (space()->phone_registration) - phone{{ __('Recover your account using your phone number') }}
+ phone{{ __('Use the mobile app to recover your account using your phone number') }}
@endif qr-code{{ __('Login using a QRCode') }}

\ No newline at end of file diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php index 3677aba..3981c4f 100644 --- a/flexiapi/routes/api.php +++ b/flexiapi/routes/api.php @@ -44,6 +44,7 @@ Route::post('account_creation_request_tokens', 'Api\Account\CreationRequestToken Route::post('account_creation_tokens/send-by-push', 'Api\Account\CreationTokenController@sendByPush'); Route::post('account_creation_tokens/using-account-creation-request-token', 'Api\Account\CreationTokenController@usingAccountRequestToken'); Route::post('accounts/with-account-creation-token', 'Api\Account\AccountController@store'); +Route::post('account_recovery_tokens/send-by-push', 'Api\Account\RecoveryTokenController@sendByPush'); Route::get('accounts/{sip}/info', 'Api\Account\AccountController@info'); diff --git a/flexiapi/routes/web.php b/flexiapi/routes/web.php index 2d9c18b..c990d5d 100644 --- a/flexiapi/routes/web.php +++ b/flexiapi/routes/web.php @@ -100,7 +100,7 @@ Route::middleware(['web_panel_enabled', 'space.check'])->group(function () { }); Route::prefix('recovery')->controller(RecoveryController::class)->group(function () { - Route::get('phone', 'showPhone')->name('account.recovery.show.phone'); + Route::get('phone/{account_recovery_token}', 'showPhone')->name('account.recovery.show.phone'); Route::get('email', 'showEmail')->name('account.recovery.show.email'); Route::post('/', 'send')->name('account.recovery.send'); Route::post('confirm', 'confirm')->name('account.recovery.confirm'); diff --git a/flexiapi/tests/Feature/ApiAccountRecoveryTokenTest.php b/flexiapi/tests/Feature/ApiAccountRecoveryTokenTest.php new file mode 100644 index 0000000..48473ca --- /dev/null +++ b/flexiapi/tests/Feature/ApiAccountRecoveryTokenTest.php @@ -0,0 +1,95 @@ +. +*/ + +namespace Tests\Feature; + +use App\Account; +use App\Space; +use App\AccountRecoveryToken; +use Tests\TestCase; +use Carbon\Carbon; +use App\Http\Middleware\IsWebPanelEnabled; + +class ApiAccountRecoveryTokenTest extends TestCase +{ + protected $tokenRoute = '/api/account_recovery_tokens/send-by-push'; + protected $tokenRequestRoute = '/api/account_recovery_request_tokens'; + protected $method = 'POST'; + + protected $pnProvider = 'fcm'; + protected $pnParam = 'param'; + protected $pnPrid = 'id'; + + public function testMandatoryParameters() + { + $this->json($this->method, $this->tokenRoute)->assertStatus(422); + $this->json($this->method, $this->tokenRoute, [ + 'pn_provider' => null, + 'pn_param' => null, + 'pn_prid' => null, + ])->assertStatus(422); + } + + public function testThrottling() + { + AccountRecoveryToken::factory()->create([ + 'pn_provider' => $this->pnProvider, + 'pn_param' => $this->pnParam, + 'pn_prid' => $this->pnPrid, + ]); + + $this->json($this->method, $this->tokenRoute, [ + 'pn_provider' => $this->pnProvider, + 'pn_param' => $this->pnParam, + 'pn_prid' => $this->pnPrid, + ])->assertStatus(503); + + // Redeem all the tokens + AccountRecoveryToken::where('used', false)->update(['used' => true]); + + $this->json($this->method, $this->tokenRoute, [ + 'pn_provider' => $this->pnProvider, + 'pn_param' => $this->pnParam, + 'pn_prid' => $this->pnPrid, + ])->assertStatus(429); + } + + public function testTokenRecoveryPage() + { + $token = AccountRecoveryToken::factory()->create(); + $space = Space::factory()->create(); + $phone = '+3312345'; + + $this->get($this->setSpaceOnRoute($space, route('account.recovery.show.phone', ['account_recovery_token' => 'bad_token']))) + ->assertStatus(404); + + $this->get($this->setSpaceOnRoute($space, route('account.recovery.show.phone', ['account_recovery_token' => $token->token]))) + ->assertDontSee($phone) + ->assertStatus(200); + + $this->get($this->setSpaceOnRoute($space, route('account.recovery.show.phone', ['account_recovery_token' => $token->token, 'phone' => $phone]))) + ->assertSee($phone) + ->assertStatus(200); + + $token->consume(); + + $this->get($this->setSpaceOnRoute($space, route('account.recovery.show.phone', ['account_recovery_token' => $token->token]))) + ->assertStatus(404); + } +} diff --git a/flexiapi/tests/TestCase.php b/flexiapi/tests/TestCase.php index 91223ae..bb1d122 100644 --- a/flexiapi/tests/TestCase.php +++ b/flexiapi/tests/TestCase.php @@ -20,6 +20,7 @@ namespace Tests; use App\PhoneCountry; +use App\Space; use App\Http\Middleware\SpaceCheck; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -45,4 +46,9 @@ abstract class TestCase extends BaseTestCase PhoneCountry::factory()->france()->activated()->create(); PhoneCountry::factory()->netherlands()->create(); } + + protected function setSpaceOnRoute(Space $space, string $route) + { + return str_replace('localhost', $space->domain, $route); + } }