From de058741f67063d89b5ac45e49b0f91b87be82a3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 5 May 2025 11:17:44 +0300 Subject: [PATCH] CFM-333_Email_Verifier_Enroller_Plugin (#309) * Email Verificiation.Charset and code length configuration. Block user on failed attempt. * Add block on failure configuration * Fix redirects and state saves --- app/config/routes.php | 7 +- .../resources/locales/en_US/core_enroller.po | 46 ++++++++ .../Controller/EmailVerifiersController.php | 48 +++++++- .../src/Lib/Enum/VerificationDefaultsEnum.php | 38 +++++++ .../src/Model/Table/EmailVerifiersTable.php | 78 ++++++++++--- .../Table/PetitionVerificationsTable.php | 33 ++++-- .../CoreEnroller/src/config/plugin.json | 6 +- .../templates/EmailVerifiers/fields.inc | 59 +++++++--- .../element/emailVerifiers/verify.php | 66 ++++++++++- app/resources/locales/en_US/information.po | 11 +- app/src/Controller/AppController.php | 2 +- app/src/Lib/Enum/ApplicationStateEnum.php | 1 + app/src/Lib/Random/RandomString.php | 24 ++-- app/src/Lib/Traits/ApplicationStatesTrait.php | 32 ++++-- app/src/Lib/Traits/AutoViewVarsTrait.php | 4 +- app/src/Lib/Traits/ValidationTrait.php | 17 +++ .../Model/Table/ApplicationStatesTable.php | 34 ++++++ app/src/Model/Table/VerificationsTable.php | 70 +++++++++--- app/templates/EnrollmentFlows/start.php | 5 +- app/templates/Standard/dispatch.php | 10 +- app/templates/element/notify/blockUser.php | 106 ++++++++++++++++++ app/templates/layout/default.php | 4 +- app/webroot/js/comanage/comanage.js | 6 +- 23 files changed, 616 insertions(+), 91 deletions(-) create mode 100644 app/plugins/CoreEnroller/src/Lib/Enum/VerificationDefaultsEnum.php create mode 100644 app/templates/element/notify/blockUser.php diff --git a/app/config/routes.php b/app/config/routes.php index 2bf695e7b..bd049807f 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -96,7 +96,12 @@ ['_namePrefix' => 'apiAjaxV2:'], function (RouteBuilder $builder) { // Register scoped middleware for in scopes. - $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware(['httponly' => true])); + $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([ + 'httponly' => true, + 'secure' => true, + 'cookieName' => 'csrfToken', + 'accessibleHeaders' => ['X-CSRF-Token'], + ])); // BodyParserMiddleware will automatically parse JSON bodies, but we only // want that for API transactions, so we only apply it to the /api scope. $builder->registerMiddleware('bodyparser', new BodyParserMiddleware()); diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index f333a118d..d53ad5dfb 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -55,6 +55,15 @@ msgstr "{0,plural,=1{Petition Identifier} other{Petition Identifiers}}" msgid "controller.PetitionVerifications" msgstr "{0,plural,=1{Petition Verification} other{Petition Verifications}}" +msgid "enumeration.VerificationDefaultsEnum.234679CDFGHJKLMNPQRTVWXZ" +msgstr "DefaultCharset" + +msgid "enumeration.VerificationDefaultsEnum.8" +msgstr "DefaultCodeLength" + +msgid "enumeration.VerificationDefaultsEnum.60" +msgstr "DefaultVerificationValidity" + msgid "enumeration.VerificationModeEnum.0" msgstr "None" @@ -70,6 +79,24 @@ msgstr "Requested address is not a valid candidate" msgid "error.EmailVerifiers.minimum" msgstr "The required number of verified Email Addresses has not been met" +msgid "error.EmailVerifiers.code_length.content" +msgstr "The code length must be a numeric value." + +msgid "error.EmailVerifiers.code_length.comparison_max" +msgstr "Code length must not be less than 4 characters." + +msgid "error.EmailVerifiers.code_length.step_four" +msgstr "Code length must be increased by 4 characters, e.g. 8, 12, 16, 20." + +msgid "error.EmailVerifiers.code_length.comparison_less" +msgstr "Code length must not exceed 20 characters." + +msgid "error.EmailVerifiers.verification_code_charset.content" +msgstr "Letters and numbers only." + +msgid "error.EmailVerifiers.verification_code_charset.is_upper_case" +msgstr "Charset must consist of UPPER case characters only." + msgid "error.EmailVerifiers.verified" msgstr "Requested address is already verified" @@ -157,6 +184,25 @@ msgstr "Request Validity" msgid "field.EmailVerifiers.request_validity.desc" msgstr "Duration, in minutes, of the verification request before it expires" +msgid "field.EmailVerifiers.verification_code_charset" +msgstr "Verification Code Character Set" + +msgid "field.EmailVerifiers.verification_code_charset.desc" +msgstr "Set of characters for generating the verification code. Numbers and uppercase letters only." + +msgid "field.EmailVerifiers.verification_code_length" +msgstr "Verification Code Length" + +msgid "field.EmailVerifiers.verification_code_length.desc" +msgstr "Set the verification code length. Default length is 8." + +msgid "field.EmailVerifiers.enable_blockonfailure" +msgstr "Enable Attempt Blocker" + +msgid "field.EmailVerifiers.enable_blockonfailure.desc" +msgstr "Enables blocking after consecutive failed attempts. Blocking duration increases exponentially based on the number of failed attempts." + + msgid "field.EnrollmentAttributes.address_required_fields" msgstr "Required Address Fields" diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php index cbfd00a6b..e05a16748 100644 --- a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php +++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php @@ -30,7 +30,9 @@ namespace CoreEnroller\Controller; use App\Controller\StandardEnrollerController; +use App\Lib\Enum\ApplicationStateEnum; use App\Lib\Enum\PetitionStatusEnum; +use App\Lib\Traits\ApplicationStatesTrait; use App\Lib\Util\StringUtilities; use Cake\Http\Exception\BadRequestException; use Cake\ORM\TableRegistry; @@ -38,6 +40,8 @@ use \App\Lib\Enum\HttpStatusCodesEnum; class EmailVerifiersController extends StandardEnrollerController { + use ApplicationStatesTrait; + public $paginate = [ 'order' => [ 'EmailVerifiers.id' => 'asc' @@ -124,6 +128,10 @@ public function resend($id) { */ public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + $username = $session->read('Auth.external.user'); + $op = $this->requestParam('op'); if(!$op) { @@ -139,6 +147,7 @@ public function dispatch(string $id) { $candidateAddresses = $this->EmailVerifiers->assembleVerifiableAddresses($cfg, $petition); $this->set('vv_config', $cfg); + $this->set('controller', $this); $this->set('vv_email_addresses', $candidateAddresses); // To make things easier for the view, we'll create a separate view var with the @@ -183,18 +192,31 @@ public function dispatch(string $id) { $this->Flash->error(__d('core_enroller', 'error.EmailVerifiers.verified')); } else { + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + $pVerification = $PetitionVerifications->getPetitionVerification($petition->id, $mail, false); + // Reset the counter if nothing happened for the last 30 minutes + if (!empty($pVerification->modified) && !$pVerification->modified->wasWithinLast('30 minute')) { + $pVerification->attempts_count = 0; + $PetitionVerifications->save($pVerification); + } + + // Tell dispatch.inc to render a verification form + $this->set('vv_verify_address', $mail); + $this->set('vv_attempts_count', $pVerification->attempts_count ?? 0); + if($this->request->is('post')) { - $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); // We're back with the code. Note many parameters (but not code) will be in // both the URL and the post body because of how dispatch.php sets up // FormHelper. $code = $this->requestParam('code'); + // Strip any dashes from the code + $code = str_replace('-', '', $code); try { $PetitionVerifications->verifyCode( - $petition->id, + $petition->id, $cfg->enrollment_flow_step_id, $mail, $code @@ -227,16 +249,30 @@ public function dispatch(string $id) { catch(\Exception $e) { $this->llog('error', $e->getMessage()); $this->Flash->error($e->getMessage()); - } + + if ($e->getMessage() === __d('error', 'Verifications.code')) { + // Add a flag to the session to instruct the UI to handle blocking. + $this->request->getSession()->write('verification_error', 1); + // Get preferences if we have an Auth.User.co_person_id + if(!empty($username)) { + $ApplicationStates = $this->fetchTable('ApplicationStates'); + $columnStatement = $this->viewBuilder()->getVar('vv_person_id') === null ? 'person_id IS' : 'person_id'; + $data = [ + 'tag' => ApplicationStateEnum::VerifyEmailBlocked, + 'username' => $username, + 'co_id' => $this->getCOID(), + $columnStatement => $this->viewBuilder()->getVar('vv_person_id') ?? null + ]; + $ApplicationStates->createOrUpdate($data, 'lock'); + } + } + } } else { // Generate a Verification request, then render a form to collect it. // If there is already a pending request, overwrite it (generate a new code). $this->EmailVerifiers->sendVerificationRequest($cfg, $petition, $mail); } - - // Tell dispatch.inc to render a verification form - $this->set('vv_verify_address', $mail); } } elseif($op == 'finish') { if($minimumMet) { diff --git a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationDefaultsEnum.php b/app/plugins/CoreEnroller/src/Lib/Enum/VerificationDefaultsEnum.php new file mode 100644 index 000000000..89f28766c --- /dev/null +++ b/app/plugins/CoreEnroller/src/Lib/Enum/VerificationDefaultsEnum.php @@ -0,0 +1,38 @@ +addBehavior('Log'); $this->addBehavior('Timestamp'); - $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + $this->setTableType(TableTypeEnum::Configuration); // Define associations $this->belongsTo('EnrollmentFlowSteps'); @@ -108,6 +106,10 @@ public function initialize(array $config): void { 'type' => 'enum', 'class' => 'CoreEnroller.VerificationModeEnum' ], + 'defaults' => [ + 'type' => 'enum', + 'class' => 'CoreEnroller.VerificationDefaultsEnum' + ], 'messageTemplates' => [ 'type' => 'select', 'model' => 'MessageTemplates', @@ -444,10 +446,12 @@ public function sendVerificationRequest( $this->llog('debug', "Sending verification code to $mail for Petition " . $petition->id); $verificationId = $Verifications->requestCodeForPetition( - $petition->id, - $mail, - $emailVerifier->message_template_id, - $emailVerifier->request_validity + petitionId: $petition->id, + mail: $mail, + messageTemplateId: $emailVerifier->message_template_id, + validity: $emailVerifier->request_validity, + codeLength: !empty($emailVerifier->verification_code_length) ? $emailVerifier->verification_code_length : VerificationDefaultsEnum::DefaultCodeLength, + codeCharset: !empty($emailVerifier->verification_code_charset) ? $emailVerifier->verification_code_charset : VerificationDefaultsEnum::DefaultCharset, ); $pVerification = $PetitionVerifications->saveOrFail( @@ -465,11 +469,13 @@ public function sendVerificationRequest( $this->llog('debug', "Sending replacement verification code to $mail for Petition " . $petition->id); $verificationId = $Verifications->requestCodeForPetition( - $petition->id, - $mail, - $emailVerifier->message_template_id, - $emailVerifier->request_validity, - $pVerification->verification_id + petitionId: $petition->id, + mail: $mail, + messageTemplateId: $emailVerifier->message_template_id, + validity: $emailVerifier->request_validity, + codeLength: !empty($emailVerifier->verification_code_length) ? $emailVerifier->verification_code_length : VerificationDefaultsEnum::DefaultCodeLength, + codeCharset: !empty($emailVerifier->verification_code_charset) ? $emailVerifier->verification_code_charset : VerificationDefaultsEnum::DefaultCharset, + verificationId: $pVerification->verification_id, ); // There's nothing to update in the Petition Verification @@ -510,6 +516,50 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('request_validity'); + $validator + ->add('verification_code_charset', [ + 'content' => [ + 'rule' => 'alphaNumeric', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.verification_code_charset.content'), + ], + 'is_upper_case' => [ + 'rule' => fn($value, $context) => $value === strtoupper($value), + 'message' => __d('core_enroller', 'error.EmailVerifiers.verification_code_charset.is_upper_case'), + 'last' => true, + ], + ]); + $validator->allowEmptyString('verification_code_charset'); + + $validator + ->add('verification_code_length', 'content', [ + 'rule' => 'isInteger', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.content'), + ]) + ->add('verification_code_length', 'comparison_max', [ + 'rule' => ['comparison', '>=', 1], + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.comparison_max'), + ]) + ->add('verification_code_length', 'comparison_less', [ + 'rule' => ['comparison', '<=', 20], + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.comparison_less'), + ]) + ->add('verification_code_length', 'step_four', [ + 'rule' => ['validateIncreaseStep', 4], + 'provider' => 'table', + 'last' => true, + 'message' => __d('core_enroller', 'error.EmailVerifiers.code_length.step_four'), + ]); + $validator->allowEmptyString('verification_code_length'); + + $validator->add('enable_blockonfailure', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('enable_blockonfailure'); + return $validator; } } diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php index ecf39b690..21dd4ba13 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php @@ -115,13 +115,11 @@ public function verifyCode(int $petitionId, int $enrollmentFlowStepId, string $m // Find the PetitionVerification for the requested petition and address, // then use the verification ID to process the code. - $pVerification = $this->find() - ->where([ - 'petition_id' => $petitionId, - 'mail' => $mail - ]) - ->firstOrFail(); - + $pVerification = $this->getPetitionVerification($petitionId, $mail); + // Increase the attempt counter and save regardless of the code validation + $pVerification->attempts_count = ($pVerification->attempts_count ?? 0) + 1; + $this->save($pVerification); + // This will throw an error on failure $this->Verifications->verifyCode($pVerification->verification_id, $code); @@ -202,4 +200,25 @@ public function validationDefault(Validator $validator): Validator { return $validator; } + + + /** + * Retrieve a PetitionVerification entity based on a petition ID and email address. + * + * @since COmanage Registry v5.2.0 + * @param integer $petitionId Petition ID + * @param string $mail Email Address associated with the PetitionVerification + * @param bool $strict Whether to throw an error if no result is found (default: true) + * @return \CoreEnroller\Model\Entity\PetitionVerification|null PetitionVerification entity if found, or null + * @throws \Cake\Datasource\Exception\RecordNotFoundException If $strict is true and no result is found + */ + public function getPetitionVerification(int $petitionId, string $mail, bool $strict = true): ?PetitionVerification + { + $query = $this->find() + ->where([ + 'petition_id' => $petitionId, + 'mail' => $mail + ]); + return $strict ? $query->firstOrFail() : $query->first(); + } } diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json index bb58ddb56..ed2552daf 100644 --- a/app/plugins/CoreEnroller/src/config/plugin.json +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -43,7 +43,10 @@ "enrollment_flow_step_id": {}, "mode": { "type": "string", "size": 2 }, "message_template_id": {}, - "request_validity": { "type": "integer" } + "request_validity": { "type": "integer" }, + "enable_blockonfailure": { "type": "boolean" }, + "verification_code_length": { "type": "integer" }, + "verification_code_charset": { "type": "string", "size": 64 } }, "indexes": { "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] }, @@ -151,6 +154,7 @@ "id": {}, "petition_id": {}, "mail": {}, + "attempts_count": { "type": "integer" }, "verification_id": { "type": "integer", "foreignkey": { "table": "verifications", "column": "id" } } }, "indexes": { diff --git a/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc index 10c07364b..1bd7a2c6c 100644 --- a/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc +++ b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc @@ -26,20 +26,53 @@ */ // This view only supports edit -if($vv_action == 'edit') { - foreach(['mode', - 'message_template_id' - ] as $field) { - print $this->element('form/listItem', [ - 'arguments' => ['fieldName' => $field] - ]); - } +if($vv_action !== 'edit') { + return; +} + +$defaultValues = array_combine( + array_values($defaults), + array_keys($defaults) +); +foreach(['mode', + 'message_template_id' + ] as $field) { print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'request_validity', - 'fieldOptions' => [ - 'default' => 60 - ]] + 'arguments' => ['fieldName' => $field] ]); } + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'request_validity', + 'fieldOptions' => [ + 'placeholder' => $defaultValues['DefaultVerificationValidity'] + ]] +]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'verification_code_charset', + 'fieldOptions' => [ + 'required' => false, + 'placeholder' => $defaultValues['DefaultCharset'] + ]] +]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'verification_code_length', + 'fieldOptions' => [ + 'required' => false, + 'placeholder' => $defaultValues['DefaultCodeLength'] + ]] +]); + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'enable_blockonfailure', + 'fieldOptions' => [ + 'default' => true, + ]] +]); diff --git a/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php b/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php index 75081bf52..63d5ecd30 100644 --- a/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php +++ b/app/plugins/CoreEnroller/templates/element/emailVerifiers/verify.php @@ -27,7 +27,35 @@ declare(strict_types = 1); +use App\Lib\Enum\ApplicationStateEnum; use App\Lib\Util\StringUtilities; +use Cake\Routing\Router; + + +$session = $this->getRequest()->getSession(); +$hasVerificationError = $session->read('verification_error') ?? 0; // Replace 'keyName' with the actual session key you want to access +if ( + filter_var($vv_config->enable_blockonfailure, FILTER_VALIDATE_BOOLEAN) + && filter_var($hasVerificationError, FILTER_VALIDATE_BOOLEAN) +) { + $session->delete('verification_error'); + $stateAttr = ApplicationStateEnum::VerifyEmailBlocked; + $appStateValue = $this->ApplicationState->getValue($stateAttr, 'lock', true); + $appStateId = $this->ApplicationState->getId($stateAttr, true); + + if ($vv_attempts_count > 0 && $appStateValue !== 'unlock') { + $currentUrl = Router::url(null, true); + print $this->element('notify/blockUser', compact( + 'vv_attempts_count', + 'currentUrl', + 'stateAttr', + 'appStateId', + )); + return; + } +} + +$this->Field->enableFormEditMode(); if(empty($vv_verify_address)) { print __d('core_enroller', 'information.EmailVerifiers.done'); @@ -36,16 +64,14 @@ // Render a form prompting for the code that was sent to the Enrollee -print __d('core_enroller', 'information.EmailVerifiers.code_sent', [$vv_verify_address]); - -$this->Field->enableFormEditMode(); - $m = StringUtilities::urlbase64encode($vv_verify_address); print $this->Form->hidden('op', ['default' => 'verify']); print $this->Form->hidden('co_id', ['default' => $vv_cur_co->id]); print $this->Form->hidden('m', ['default' => $m]); +print __d('core_enroller', 'information.EmailVerifiers.code_sent', [$vv_verify_address]); + print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'code', @@ -62,6 +88,7 @@ ); ?> + Field->isEditable()): ?>
  • @@ -89,3 +116,34 @@ 'vv_config' => $vv_config, ]) ?> + diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 408e7187b..96ae32f41 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -178,4 +178,13 @@ msgid "ug.installMostlyStaticPages" msgstr "Installing default Mostly Static Pages for CO {0}" msgid "value.copied" -msgstr "Value copied to clipboard." \ No newline at end of file +msgstr "Value copied to clipboard." + +msgid "user.block.message" +msgstr "Oops! Some information appears to be incomplete or incorrect." + +msgid "user.block.retry" +msgstr "You will be able to try again in {0} seconds." + +msgid "user.block.attempt" +msgstr "This is your attempt #{0}." \ No newline at end of file diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 393a0ab9f..2efcf0452 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -384,7 +384,7 @@ protected function primaryLinkLookup(): void * - postcondition: Application Preferences variable set * @since COmanage Registry v5.1.0 */ - protected function getAppPrefs() { + public function getAppPrefs() { $request = $this->getRequest(); $session = $request->getSession(); diff --git a/app/src/Lib/Enum/ApplicationStateEnum.php b/app/src/Lib/Enum/ApplicationStateEnum.php index c5fce6a63..767389b97 100644 --- a/app/src/Lib/Enum/ApplicationStateEnum.php +++ b/app/src/Lib/Enum/ApplicationStateEnum.php @@ -35,4 +35,5 @@ class ApplicationStateEnum extends StandardEnum { const PaginationLimit = 'PL'; const ProfileDarkMode = 'PM'; const ProfileDensity = 'PD'; + const VerifyEmailBlocked = 'VE'; } \ No newline at end of file diff --git a/app/src/Lib/Random/RandomString.php b/app/src/Lib/Random/RandomString.php index c7b93ad76..4038fc43b 100644 --- a/app/src/Lib/Random/RandomString.php +++ b/app/src/Lib/Random/RandomString.php @@ -29,6 +29,8 @@ namespace App\Lib\Random; +use Random\RandomException; + class RandomString { /** * Generate a string suitable for use as an application key. @@ -66,13 +68,14 @@ public static function generateAppKey(): string { return $key; } - /** + /** * Generate a string suitable for use as a confirmation code. Codes are intended to be * typed in or copied by a human, for confirmation codes that are embedded in URLs use * generateToken() instead. - * - * @since COmanage REgistry v5.1.0 + * * @return string Token + * @throws RandomException + * @since COmanage REgistry v5.1.0 */ public static function generateCode(): string { @@ -93,20 +96,23 @@ public static function generateCode(): string { * Generate a string suitable for use as a token. Unlike an application key, * a token is not expected to be directly visible or handled by a human, but * will (eg) be injected into a URL or a message. - * - * @since COmanage Registry v5.0.0 + * * @return string Token + * @throws RandomException + * @since COmanage Registry v5.0.0 */ - public static function generateToken(): string { + public static function generateToken(int $length = 16, string $allowedCharset = null): string + { // Unlike App Keys, tokens don't have restrictions on characters. - $chars = 'abcdefghijklmnopqrstuvwxyz01234567890'; + $chars = $allowedCharset ?? 'abcdefghijklmnopqrstuvwxyz01234567890'; $token = ""; - for($i = 0;$i < 16;$i++) { - $token .= $chars[random_int(0, strlen($chars)-1)]; + $allowedCharsetLength = strlen($chars) - 1; + for($i = 0;$i < $length;$i++) { + $token .= $chars[random_int(0, $allowedCharsetLength)]; } return $token; diff --git a/app/src/Lib/Traits/ApplicationStatesTrait.php b/app/src/Lib/Traits/ApplicationStatesTrait.php index b99e76001..c9e315458 100644 --- a/app/src/Lib/Traits/ApplicationStatesTrait.php +++ b/app/src/Lib/Traits/ApplicationStatesTrait.php @@ -30,6 +30,8 @@ namespace App\Lib\Traits; use Cake\Collection\Collection; +use Cake\Controller\ControllerFactory; +use Cake\ORM\TableRegistry; trait ApplicationStatesTrait { @@ -48,16 +50,22 @@ public function constructComplexStateTag(array $nameParts): string } /** - * @param string $stateTag - * @param string|int $defaultValue - * + * @param string $stateTag + * @param string|int $defaultValue + * @param bool $invalidate * @return string * @since COmanage Registry v5.1.0 */ - public function getValue(string $stateTag, string|int $defaultValue): string + public function getValue(string $stateTag, string|int $defaultValue, $invalidate = false): string { $vv_app_prefs = []; - if(method_exists($this, 'getView')) { + + if ($invalidate) { + // View, refetch + $curController = $this->getView()->get('controller'); + $curController->getAppPrefs(); + $vv_app_prefs = $curController->viewBuilder()->getVar('vv_app_prefs'); + } elseif(method_exists($this, 'getView')) { // View $vv_app_prefs = $this->getView()?->get('vv_app_prefs'); } elseif(method_exists($this, 'viewBuilder')) { @@ -77,15 +85,21 @@ public function getValue(string $stateTag, string|int $defaultValue): string } /** - * @param string $stateTag - * + * @param string $stateTag + * @param bool $invalidate * @return string * @since COmanage Registry v5.1.0 */ - public function getId(string $stateTag): string + public function getId(string $stateTag, $invalidate = false): string { $vv_app_prefs = []; - if(method_exists($this, 'getView')) { + + if ($invalidate) { + // View, refetch + $curController = $this->getView()->get('controller'); + $curController->getAppPrefs(); + $vv_app_prefs = $curController->viewBuilder()->getVar('vv_app_prefs'); + } elseif(method_exists($this, 'getView')) { // View $vv_app_prefs = $this->getView()?->get('vv_app_prefs'); } elseif(method_exists($this, 'viewBuilder')) { diff --git a/app/src/Lib/Traits/AutoViewVarsTrait.php b/app/src/Lib/Traits/AutoViewVarsTrait.php index 72962f05c..853fbb1e4 100644 --- a/app/src/Lib/Traits/AutoViewVarsTrait.php +++ b/app/src/Lib/Traits/AutoViewVarsTrait.php @@ -55,14 +55,14 @@ public function getAutoViewVars() { * @param array $vars Array of auto view variables */ - public function setAutoViewVars($vars) { + public function setAutoViewVars($vars): void { $this->autoViewVars = $vars; } /** * Calculate the AutoView Vars * - * @param int $coId + * @param int|null $coId * @param Object|null $obj Current object (eg: from edit), if set * * @return \Generator diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index 0658af1a3..5602dd84a 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -216,6 +216,23 @@ public function validateConditionalRequire($value, array $context) { return true; } + + + /** + * Validate that a numerical value is a multiple of a given step. + * + * This validation rule ensures that the $value is evenly divisible by the specified $step. + * It can be used, for example, to validate values in increments like 5, 10, etc. + * + * @since COmanage Registry v5.2.0 + * @param string $value The numerical value to validate. + * @param int $step The step value the $value should be divisible by. + * @param array $context Validation context. + * @return bool True if $value validates as a multiple of $step, false otherwise. + */ + public function validateIncreaseStep(string $value, int $step, array $context) { + return (int)$value%$step == 0; + } /** * Determine if a string submitted from a form is valid input. diff --git a/app/src/Model/Table/ApplicationStatesTable.php b/app/src/Model/Table/ApplicationStatesTable.php index 77cf110ac..6843c2f3c 100644 --- a/app/src/Model/Table/ApplicationStatesTable.php +++ b/app/src/Model/Table/ApplicationStatesTable.php @@ -29,6 +29,7 @@ namespace App\Model\Table; +use App\Lib\Enum\ApplicationStateEnum; use Cake\Database\Expression\QueryExpression; use Cake\ORM\Query; use Cake\ORM\Table; @@ -141,4 +142,37 @@ public function retrieveAll(string $username, ?int $coid, ?int $personid): array return $tags ?? []; } + + + /** + * Create or update an application state. + * + * This method either updates an existing application state record or creates + * a new one based on the provided data and value. + * + * @param array $data Associative array containing fields and their values. + * @param string $value Value to set for the application state. + * @return void + * @throws \Cake\ORM\Exception\PersistenceFailedException When a save operation fails. + * @since COmanage Registry v5.2.0 + */ + public function createOrUpdate(array $data, string $value): void + { + $appId = $this->find() + ->cache(false) + ->where($data)->first(); + + // Enter the new value + $data['value'] = $value; + + If ($appId->id !== null) { + // Update existing record + $appId->set($data); + $this->saveOrFail($appId); + } else { + $newRecord = $this->newEntity($data); + // Create new entry + $this->saveOrFail($newRecord); + } + } } \ No newline at end of file diff --git a/app/src/Model/Table/VerificationsTable.php b/app/src/Model/Table/VerificationsTable.php index 72a7d8ed7..07ab7b117 100644 --- a/app/src/Model/Table/VerificationsTable.php +++ b/app/src/Model/Table/VerificationsTable.php @@ -39,6 +39,7 @@ use App\Lib\Random\RandomString; use App\Lib\Util\DeliveryUtilities; use App\Model\Entity\Verification; +use Random\RandomException; class VerificationsTable extends Table { use \App\Lib\Traits\CoLinkTrait; @@ -241,14 +242,17 @@ protected function recordHistory( /** * Request a Verification for the specified petition and email address. - * - * @since COmanage Registry v5.1.0 - * @param int $petitionId Petition ID - * @param string $mail Email Address to verify - * @param int $messageTemplateId Message Template ID - * @param int $validity Request validity, in minutes - * @param int $verificationId If set, resend Verification for this request + * + * @param int $petitionId Petition ID + * @param string $mail Email Address to verify + * @param int $messageTemplateId Message Template ID + * @param int $validity Request validity, in minutes + * @param int $codeLength + * @param string $codeCharset + * @param int|null $verificationId If set, resend Verification for this request * @return int Verification ID + * @throws RandomException + * @since COmanage Registry v5.1.0 */ public function requestCodeForPetition( @@ -256,10 +260,12 @@ public function requestCodeForPetition( string $mail, int $messageTemplateId, int $validity, - int $verificationId=null + int $codeLength, + string $codeCharset, + int $verificationId = null ): int { // First generate a new code - $code = RandomString::generateCode(); + $code = RandomString::generateToken($codeLength, $codeCharset); $expiry = date('Y-m-d H:i:s', time() + ($validity * 60)); $verification = null; @@ -292,7 +298,7 @@ public function requestCodeForPetition( DeliveryUtilities::sendEmailFromTemplate( address: $mail, messageTemplateId: $messageTemplateId, - code: $code + code: $this->tokenToD($code) ); // We'll try to record history, but most likely it'll fail due to lack of a Person @@ -389,19 +395,20 @@ public function verifyCode(int $id, string $code): bool { throw new \InvalidArgumentException(__d('error', 'Verifications.processed')); } - if($verification->request_expiration_time->lessThan(FrozenTime::now())) { - $this->llog('debug', "Verification $id has expired"); - throw new \InvalidArgumentException(__d('error', 'Verifications.expired')); - } - if($verification->code !== $code) { $this->llog('debug', "Invalid code provided for Verification $id"); throw new \InvalidArgumentException(__d('error', 'Verifications.code')); } + if($verification->request_expiration_time->lessThan(FrozenTime::now())) { + $this->llog('debug', "Verification $id has expired"); + throw new \InvalidArgumentException(__d('error', 'Verifications.expired')); + } + $this->llog('debug', "Successfully processed Verification $id"); $verification->method = VerificationMethodEnum::Code; + // This field signifies that the email is verified $verification->verification_time = time(); $this->saveOrFail($verification); @@ -433,6 +440,32 @@ public function verifyFromPetition(int $id, int $emailAddressId) { $this->saveOrFail($newVerification); } + + /** + * Converts a token by adding dashes for improved readability. + * + * @param string $token The token to be formatted + * @param int $jump Characters to skip before adding a dash + * @return string The formatted token with dashes + * @since COmanage Registry v5.2.0 + */ + public function tokenToD(string $token, int $jump = 4): string + { + // Insert some dashes to improve readability + $dtoken = ''; + + for($i = 0, $iMax = strlen($token); $i < $iMax; $i++) { + $dtoken .= $token[$i]; + + if((($i + 1) % $jump == 0) + && ($i + 1 < strlen($token))) { + $dtoken .= '-'; + } + } + + return $dtoken; + } + /** * Set validation rules. * @@ -477,6 +510,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('petition_id'); - return $validator; + $validator->add('attempts_count', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('attempts_count'); + + return $validator; } } \ No newline at end of file diff --git a/app/templates/EnrollmentFlows/start.php b/app/templates/EnrollmentFlows/start.php index 2887d05f2..d0a293c81 100644 --- a/app/templates/EnrollmentFlows/start.php +++ b/app/templates/EnrollmentFlows/start.php @@ -42,7 +42,10 @@ $this->set('vv_is_editable', true); // Create the Form -print $this->Form->create(); +print $this->Form->create(null, [ + 'id' => 'enrollment-flow-start-form', + 'type' => 'post', +]); // Form body print $this->element('form/unorderedList'); // Close the Form diff --git a/app/templates/Standard/dispatch.php b/app/templates/Standard/dispatch.php index c65fc1283..dbd658ff5 100644 --- a/app/templates/Standard/dispatch.php +++ b/app/templates/Standard/dispatch.php @@ -28,9 +28,11 @@ declare(strict_types = 1); // $this->name = Models +use Cake\Utility\Inflector; + $modelsName = $this->name; // $tablename = models -$tableName = \Cake\Utility\Inflector::tableize(\Cake\Utility\Inflector::singularize($this->name)); +$tableName = Inflector::tableize(Inflector::singularize($this->name)); // Populate the AutoViewVars. These are the same we do for the EnrollmentAttributes configuration view $this->Petition->populateAutoViewVars(); // We just populated the AutoViewVars. Add them to the current context @@ -86,7 +88,11 @@ // By default, the form will POST to the current controller // Note we need to open the form for view so Cake will autopopulate values -print $this->Form->create(); +$idPrefix = Inflector::dasherize($modelsName); +print $this->Form->create(null, [ + 'id' => "$idPrefix-dispatch-form", + 'type' => 'post', +]); // Form body print '
    '; diff --git a/app/templates/element/notify/blockUser.php b/app/templates/element/notify/blockUser.php new file mode 100644 index 000000000..70966ca9d --- /dev/null +++ b/app/templates/element/notify/blockUser.php @@ -0,0 +1,106 @@ + + + + +
    +
    + sec +
    +
    + +
    +
    + +
    + +
    +
    + + + diff --git a/app/templates/layout/default.php b/app/templates/layout/default.php index 2f25fc480..22d583ea1 100644 --- a/app/templates/layout/default.php +++ b/app/templates/layout/default.php @@ -72,7 +72,9 @@ print $this->Html->scriptBlock( sprintf( 'var csrfToken = %s;', - json_encode($this->request->getAttribute('csrfToken'), JSON_THROW_ON_ERROR) + json_encode( + $this->request->getAttribute('csrfToken') ?? $this->request->getCookie('csrfToken'), + JSON_THROW_ON_ERROR) ) ); } catch (JsonException $e) { diff --git a/app/webroot/js/comanage/comanage.js b/app/webroot/js/comanage/comanage.js index c2c87cf49..41af5426e 100644 --- a/app/webroot/js/comanage/comanage.js +++ b/app/webroot/js/comanage/comanage.js @@ -394,7 +394,6 @@ function setApplicationState(value, elem, reload= false) { } } } - // jsonData.noty = displayNoty let jqxhr = $.ajax({ cache: false, @@ -419,8 +418,9 @@ function setApplicationState(value, elem, reload= false) { }); jqxhr.fail(function(jqXHR, textStatus, errorThrown) { - if(parseInt(jqXHR.status) > 300 && displayNoty) { - generateFlash("" + errorThrown + " (" + jqXHR.status + ")", 'error') + if(parseInt(jqXHR.status) > 300) { + console.log('status:', jqXHR.status) + console.log('error:', errorThrown) } }); }