Skip to content

Commit

Permalink
CFM-333_Email_Verifier_Enroller_Plugin (#309)
Browse files Browse the repository at this point in the history
* Email Verificiation.Charset and code length configuration. Block user on failed attempt.

* Add block on failure configuration

* Fix redirects and state saves
  • Loading branch information
Ioannis authored May 5, 2025
1 parent f5ef42c commit de05874
Show file tree
Hide file tree
Showing 23 changed files with 616 additions and 91 deletions.
7 changes: 6 additions & 1 deletion app/config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
46 changes: 46 additions & 0 deletions app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@
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;
use CoreEnroller\Lib\Enum\VerificationModeEnum;
use \App\Lib\Enum\HttpStatusCodesEnum;

class EmailVerifiersController extends StandardEnrollerController {
use ApplicationStatesTrait;

public $paginate = [
'order' => [
'EmailVerifiers.id' => 'asc'
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions app/plugins/CoreEnroller/src/Lib/Enum/VerificationDefaultsEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
* COmanage Registry Verification Defaults Enum
*
* Portions licensed to the University Corporation for Advanced Internet
* Development, Inc. ("UCAID") under one or more contributor license agreements.
* See the NOTICE file distributed with this work for additional information
* regarding copyright ownership.
*
* UCAID licenses this file to you under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with the
* License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* @link https://www.internet2.edu/comanage COmanage Project
* @package registry-plugins
* @since COmanage Registry v5.2.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/

declare(strict_types = 1);

namespace CoreEnroller\Lib\Enum;

use App\Lib\Enum\StandardEnum;

class VerificationDefaultsEnum extends StandardEnum {
const DefaultCharset = '234679CDFGHJKLMNPQRTVWXZ';
const DefaultCodeLength = 8;
const DefaultVerificationValidity = 60;
}
78 changes: 64 additions & 14 deletions app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,14 @@
use App\Lib\Enum\EnrollmentActorEnum;
use App\Lib\Enum\PetitionStatusEnum;
use App\Lib\Enum\SuspendableStatusEnum;
use App\Lib\Enum\TableTypeEnum;
use App\Lib\Util\StringUtilities;
use App\Model\Entity\Petition;
use Cake\Datasource\ConnectionManager;
use Cake\Datasource\EntityInterface;
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Validation\Validator;
use CoreEnroller\Lib\Enum\VerificationModeEnum;
use CoreEnroller\Lib\Enum\VerificationDefaultsEnum;
use CoreEnroller\Model\Entity\EmailVerifier;

class EmailVerifiersTable extends Table {
Expand Down Expand Up @@ -69,7 +67,7 @@ public function initialize(array $config): void {
$this->addBehavior('Log');
$this->addBehavior('Timestamp');

$this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
$this->setTableType(TableTypeEnum::Configuration);

// Define associations
$this->belongsTo('EnrollmentFlowSteps');
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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(
Expand All @@ -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

Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
}
}
Loading

0 comments on commit de05874

Please sign in to comment.