Skip to content

CFM-333_Email_Verifier_Enroller_Plugin #309

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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