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()): ?>