diff --git a/README.md b/README.md
index 7a1b748e0..7e77fdfd7 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# COmanage Registry (Pupal Eclosion)
-This is the development repository for COmanage Registry v5.0.0.
+This is the repository for COmanage Registry v5+.
-For production deployments, see [this repository](https://github.com/Internet2/comanage-registry) instead.
+For v4, see [this repository](https://github.com/Internet2/comanage-registry) instead.
diff --git a/app/availableplugins/PasswordAuthenticator/README.md b/app/availableplugins/PasswordAuthenticator/README.md
new file mode 100644
index 000000000..07be512ba
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/README.md
@@ -0,0 +1,11 @@
+# PasswordAuthenticator plugin for CakePHP
+
+## Installation
+
+You can install this plugin into your CakePHP application using [composer](https://getcomposer.org).
+
+The recommended way to install composer packages is:
+
+```
+composer require your-name-here/password-authenticator
+```
diff --git a/app/availableplugins/PasswordAuthenticator/composer.json b/app/availableplugins/PasswordAuthenticator/composer.json
new file mode 100644
index 000000000..5ab64ddd8
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "your-name-here/password-authenticator",
+ "description": "PasswordAuthenticator plugin for CakePHP",
+ "type": "cakephp-plugin",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.2",
+ "cakephp/cakephp": "4.6.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "PasswordAuthenticator\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "PasswordAuthenticator\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ }
+ }
+}
diff --git a/app/availableplugins/PasswordAuthenticator/config/plugin.json b/app/availableplugins/PasswordAuthenticator/config/plugin.json
new file mode 100644
index 000000000..3d4ec4188
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/config/plugin.json
@@ -0,0 +1,39 @@
+{
+ "types": {
+ "authenticator": [
+ "PasswordAuthenticators"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "password_authenticators": {
+ "columns": {
+ "id": {},
+ "authenticator_id": {},
+ "source_mode": { "type": "string", "size": 2 },
+ "min_length": { "type": "integer" },
+ "max_length": { "type": "integer" },
+ "format_crypt_php": { "type": "boolean" },
+ "format_plaintext": { "type": "boolean" },
+ "format_sha1_ldap": { "type": "boolean" }
+ },
+ "indexes": {
+ "password_authenticators_i1": { "columns": [ "authenticator_id" ]}
+ }
+ },
+ "passwords": {
+ "columns": {
+ "id": {},
+ "password_authenticator_id": { "type": "integer", "foreignkey": { "table": "password_authenticators", "column": "id" }, "notnull": true },
+ "person_id": {},
+ "password": { "type": "string", "size": 256 },
+ "type": { "type": "string", "size": 2 }
+ },
+ "indexes": {
+ "passwords_i1": { "columns": [ "password_authenticator_id" ]},
+ "passwords_i2": { "columns": [ "person_id" ]}
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist b/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist
new file mode 100644
index 000000000..4219d9568
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
diff --git a/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po
new file mode 100644
index 000000000..04195b043
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po
@@ -0,0 +1,107 @@
+# COmanage Registry Localizations (password_authenticator domain)
+#
+# 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)
+
+msgid "controller.PasswordAuthenticators"
+msgstr "{0,plural,=1{Password Authenticator} other{Password Authenticators}}"
+
+msgid "controller.Passwords"
+msgstr "{0,plural,=1{Password} other{Passwords}}"
+
+msgid "enumeration.PasswordEncodingEnum.CR"
+msgstr "Crypt"
+
+msgid "enumeration.PasswordEncodingEnum.EX"
+msgstr "External"
+
+msgid "enumeration.PasswordEncodingEnum.NO"
+msgstr "Plain"
+
+msgid "enumeration.PasswordEncodingEnum.SH"
+msgstr "SSHA"
+
+msgid "enumeration.PasswordSourceEnum.AG"
+msgstr "Autogenerate"
+
+msgid "enumeration.PasswordSourceEnum.EX"
+msgstr "External"
+
+msgid "enumeration.PasswordSourceEnum.SL"
+msgstr "Self Select"
+
+msgid "error.Passwords.current"
+msgstr "Incorrect current password"
+
+msgid "error.Passwords.len.max"
+msgstr "Password cannot be more than {0} characters"
+
+msgid "error.Passwords.len.min"
+msgstr "Password must be at least {0} characters"
+
+msgid "error.Passwords.match"
+msgstr "New passwords do not match"
+
+msgid "field.PasswordAuthenticators.source_mode"
+msgstr "Password Source"
+
+msgid "field.PasswordAuthenticators.min_length"
+msgstr "Minimum Password Length"
+
+msgid "field.PasswordAuthenticators.min_length.desc"
+msgstr "Must be between 8 and 64 characters (inclusive), default is 8"
+
+msgid "field.PasswordAuthenticators.max_length"
+msgstr "Maximum Password Length"
+
+msgid "field.PasswordAuthenticators.max_length.desc"
+msgstr "Must be between 8 and 64 characters (inclusive), default is 64 for Self Select and 16 for Autogenerate"
+
+msgid "field.PasswordAuthenticators.format_crypt_php"
+msgstr "Store as Crypt"
+
+msgid "field.PasswordAuthenticators.format_crypt_php.desc"
+msgstr "The password will be stored in Crypt format (required for Self Select)"
+
+msgid "field.PasswordAuthenticators.format_plaintext"
+msgstr "Store as Plain Text"
+
+msgid "field.PasswordAuthenticators.format_plaintext.desc"
+msgstr "If enabled, the password will be stored unhashed in the database"
+
+msgid "field.PasswordAuthenticators.format_sha1_ldap"
+msgstr "Store as Salted SHA 1"
+
+msgid "field.PasswordAuthenticators.format_sha1_ldap.desc"
+msgstr "If enabled, the password will be stored in Salted SHA 1 format"
+
+msgid "field.Passwords.password2"
+msgstr "Password (Again)"
+
+msgid "operation.set"
+msgstr "Set Password for {0}"
+
+msgid "result.Passwords.modified"
+msgstr "Last changed {0} UTC"
+
+msgid "result.Passwords.set"
+msgstr "Password {0} Set"
diff --git a/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php b/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php
new file mode 100644
index 000000000..125ed5d89
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+ [
+ 'PasswordAuthenticators.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php b/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php
new file mode 100644
index 000000000..e21b59833
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php
@@ -0,0 +1,42 @@
+ [
+ 'Passwords.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php
new file mode 100644
index 000000000..a07d3cdc9
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php
@@ -0,0 +1,39 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php b/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php
new file mode 100644
index 000000000..a06adedbe
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php
new file mode 100644
index 000000000..ff7c05b7e
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php
@@ -0,0 +1,184 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Authenticators');
+
+ $this->hasMany('PasswordAuthenticator.Passwords')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('authenticator_id');
+ $this->setRequiresCO(true);
+
+ $this->setAutoViewVars([
+ 'sourceModes' => [
+ 'type' => 'enum',
+ 'class' => 'PasswordAuthenticator.PasswordSourceEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Callback before data is marshaled into an entity.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param EventInterface $event beforeMarshal event
+ * @param ArrayObject $data Entity data
+ * @param ArrayObject $options Callback options
+ */
+
+ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) {
+ // PAR-PasswordAuthenticator-1 When the Password Source is Self Select, the password
+ // must be stored in PHP Crypt format
+
+ if(!empty($data['source_mode']) && $data['source_mode'] == PasswordSourceEnum::SelfSelect) {
+ $data['format_crypt_php'] = true;
+ }
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('authenticator_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('authenticator_id');
+
+ $validator->add('source_mode', [
+ 'content' => ['rule' => ['inList', PasswordSourceEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('source_mode');
+
+// XXX min_length and max_length required depend on source_mode
+ $validator->add('min_length', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->add('min_length', [
+ 'content' => ['rule' => ['comparison', '>', 7]]
+ ]);
+ $validator->add('min_length', [
+ 'content' => ['rule' => ['comparison', '<', 65]]
+ ]);
+ $validator->allowEmptyString('min_length');
+
+ $validator->add('max_length', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->add('max_length', [
+ 'content' => ['rule' => ['comparison', '>', 7]]
+ ]);
+ $validator->add('max_length', [
+ 'content' => ['rule' => ['comparison', '<', 65]]
+ ]);
+ $validator->allowEmptyString('max_length');
+
+ $validator->add('format_crypt_php', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('format_crypt_php');
+
+ $validator->add('format_plaintext', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('format_plaintext');
+
+ $validator->add('format_sha1_ldap', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('format_sha1_ldap');
+
+ return $validator;
+ }
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php
new file mode 100644
index 000000000..6f6a1545d
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php
@@ -0,0 +1,341 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary);
+
+ // Define associations
+ $this->belongsTo('PasswordAuthenticator.PasswordAuthenticators');
+ $this->belongsTo('People');
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('PasswordAuthenticator.password_authenticator_id');
+ $this->setRequiresCO(true);
+ $this->setAllowUnkeyedPrimaryLink(['manage']);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => false, // use manage instead ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false, //['platformAdmin', 'coAdmin'],
+ 'manage' => ['platformAdmin', 'coAdmin'],
+ 'index' => false // ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Handle an Authenticator update from a manage() request.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Authenticator $cfg Authenticator configuration
+ * @param int $personId Person ID
+ * @param array $data Array of data from fields.inc
+ */
+
+ public function manage(
+ Authenticator $cfg,
+ int $personId,
+ array $data
+ ): void {
+ $minlen = $cfg->password_authenticator->min_length ?: 8;
+ $maxlen = $cfg->password_authenticator->max_length ?: 64;
+
+ // Perform sanity checks on Self Selected passwords only
+ if($cfg->password_authenticator->source_mode == PasswordSourceEnum::SelfSelect) {
+ // Check minimum length
+ if(strlen($data['password']) < $minlen) {
+ throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.len.min', [$minlen]));
+ }
+
+ // Check maximum length
+ if(strlen($data['password']) > $maxlen) {
+ throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.len.max', [$minlen]));
+ }
+
+ // Check that passwords match
+ if($data['password'] != $data['password2']) {
+ throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.match'));
+ }
+ }
+
+// XXX Note we're not checking the current password yet because we don't support self
+// service yet. It might make sense to implement that check in the code that receives
+// the self service request (presumably a dashboard widget).
+
+ $cxn = $this->getConnection();
+ $cxn->begin();
+
+ try {
+ // Delete any existing password for the user. We do it this way in case the
+ // plugin configuration is changed.
+
+ $passwords = $this->find()->where([
+ 'password_authenticator_id' => $cfg->password_authenticator->id,
+ 'person_id' => $personId
+ ])
+ ->all();
+
+ foreach($passwords as $password) {
+ $this->delete($password);
+ }
+
+ // We'll store one entry per hashing type. We always store CRYPT
+ // so we can use the native php routines (which require PHP 5.5+).
+ // Enabling SSHA requires PHP 7 for random_bytes.
+
+ // We could use something like https://multiformats.io/multihash, but the
+ // password_type column basically accomplishes the same thing.
+
+ $pdata = null;
+
+ if(true || $cfg->password_authenticator->format_crypt_php) {
+ // We use password_hash, which due to various portability issues with crypt
+ // is really only useful with password_verify.
+
+ $pdata = $this->newEntity([
+ 'password_authenticator_id' => $data['password_authenticator_id'],
+ 'person_id' => $personId,
+ 'password' => password_hash($data['password'], PASSWORD_DEFAULT),
+ 'password_type' => PasswordEncodingEnum::Crypt
+ ]);
+
+ $this->saveOrFail($pdata);
+ }
+
+ if($cfg->password_authenticator->format_sha1_ldap) {
+ // Salted SHA1 isn't really a great algorithm (and our salt generation
+ // could probably be better), but OpenLDAP doesn't support a better option
+ // out of the box.
+
+ $salt = substr(bin2hex(random_bytes(8)),0,4);
+ $shapwd = base64_encode(sha1($data['password'].$salt, true) . $salt);
+
+ $pdata = $this->newEntity([
+ 'password_authenticator_id' => $data['password_authenticator_id'],
+ 'person_id' => $personId,
+ 'password' => $shapwd,
+ 'password_type' => PasswordEncodingEnum::SSHA
+ ]);
+
+ $this->saveOrFail($pdata);
+ }
+
+ if($cfg->password_authenticator->format_plaintext) {
+ // Other than being easily readable by admins, plaintext is arguably not
+ // that much less secure than the other supported options...
+
+ $pdata = $this->newEntity([
+ 'password_authenticator_id' => $data['password_authenticator_id'],
+ 'person_id' => $personId,
+ 'password' => $data['password'],
+ 'password_type' => PasswordEncodingEnum::Plain
+ ]);
+
+ $this->saveOrFail($pdata);
+ }
+
+ // At this point we've deleted any existing Passwords and correctly stored all
+ // configured variations, so we can commit the transaction.
+ $cxn->commit();
+
+ // Record history
+ $comment = __d('password_authenticator', 'result.Passwords.set', [$cfg->description]);
+
+ $this->People->recordHistory(
+ // It doesn't matter which version of $pdata we use
+ $pdata,
+ ActionEnum::AuthenticatorEdited,
+ $comment
+ );
+ }
+ catch(\Exception $e) {
+ $cxn->rollback();
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Reset a Password for a Person,
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Authenticator $cfg Authenticator Configuration
+ * @param int $personId Person ID
+ */
+
+ public function reset(
+ Authenticator $cfg,
+ int $personId
+ ): void{
+ // We simply delete all Passwords for $personId, if any
+
+ $cxn = $this->getConnection();
+ $cxn->begin();
+
+ try {
+ $passwords = $this->find()->where([
+ 'password_authenticator_id' => $cfg->password_authenticator->id,
+ 'person_id' => $personId
+ ])
+ ->all();
+
+ foreach($passwords as $password) {
+ $this->delete($password, ['reset' => true]);
+ }
+
+ $cxn->commit();
+
+ // We don't need to record history or provision because the infrastructure will handle that
+ }
+ catch(\Exception $e) {
+ $cxn->rollback();
+
+ throw $e;
+ }
+ }
+
+ /**
+ * Obtain the current Authenticator status for a Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Authenticator $cfg Authenticator Configuration
+ * @param int $personId Person ID
+ * @return array Array with values
+ * status: AuthenticatorStatusEnum
+ * comment: Human readable string, visible to the CO Person
+ */
+
+ public function status(Authenticator $cfg, int $personId): array {
+ // Is there a password for this person?
+
+ $pwd = $this->find()
+ ->where([
+ 'password_authenticator_id' => $cfg->password_authenticator->id,
+ 'person_id' => $personId
+ ])
+ ->first();
+
+ // We don't know which password_type we have, but they should all have the
+ // same mod time
+
+ if(!empty($pwd->modified)) {
+ return [
+ 'status' => AuthenticatorStatusEnum::Active,
+ // Note we don't currently have access to local timezone setting
+// XXX is this still true?
+ 'comment' => __d('password_authenticator', 'result.Passwords.modified', [$pwd->modified])
+ ];
+ }
+
+ return [
+ 'status' => AuthenticatorStatusEnum::NotSet,
+ 'comment' => __d('result', 'set.not')
+ ];
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('password_authenticator_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('password_authenticator_id');
+
+ $validator->add('person_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('person_id');
+
+ $this->registerStringValidation($validator, $schema, 'password', true);
+
+ $this->registerStringValidation($validator, $schema, 'password2', true);
+
+ $validator->add('type', [
+ 'content' => ['rule' => ['inList', PasswordEncodingEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('type');
+
+ return $validator;
+ }
+}
diff --git a/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php b/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php
new file mode 100644
index 000000000..e2888ede4
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php
@@ -0,0 +1,93 @@
+plugin(
+ 'PasswordAuthenticator',
+ ['path' => '/password-authenticator'],
+ function (RouteBuilder $builder) {
+ // Add custom routes here
+
+ $builder->fallbacks();
+ }
+ );
+ parent::routes($routes);
+ }
+
+ /**
+ * Add middleware for the plugin.
+ *
+ * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update.
+ * @return \Cake\Http\MiddlewareQueue
+ */
+ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
+ {
+ // Add your middlewares here
+
+ return $middlewareQueue;
+ }
+
+ /**
+ * Add commands for the plugin.
+ *
+ * @param \Cake\Console\CommandCollection $commands The command collection to update.
+ * @return \Cake\Console\CommandCollection
+ */
+ public function console(CommandCollection $commands): CommandCollection
+ {
+ // Add your commands here
+
+ $commands = parent::console($commands);
+
+ return $commands;
+ }
+
+ /**
+ * Register application container services.
+ *
+ * @param \Cake\Core\ContainerInterface $container The Container to update.
+ * @return void
+ * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection
+ */
+ public function services(ContainerInterface $container): void
+ {
+ // Add your services here
+ }
+}
diff --git a/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc
new file mode 100644
index 000000000..d7356d7c3
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc
@@ -0,0 +1,99 @@
+
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'source_mode',
+ 'fieldOptions' => [
+ 'onChange' => 'updateGadgets(false)'
+ ]
+ ]
+ ]);
+
+ foreach(['min_length',
+ 'max_length'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field,
+ ]
+ ]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'format_crypt_php',
+ 'fieldOptions' => [
+ // When source is Self Select we want crypt to remain checked
+ // (though this will also be enforced in the backend)
+ 'onChange' => 'updateGadgets(false)'
+ ]
+ ]
+ ]);
+
+ foreach(['format_sha1_ldap',
+ 'format_plaintext'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field,
+ ]
+ ]);
+ }
+}
diff --git a/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc b/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc
new file mode 100644
index 000000000..69b393fc0
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc
@@ -0,0 +1,48 @@
+element('notify/banner', ['info' => $vv_status->comment]);
+
+if(!$vv_status->locked) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'password',
+ ]
+ ]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'password2',
+ 'fieldOptions' => [
+ 'type' => 'password'
+ ]
+ ]
+ ]);
+}
\ No newline at end of file
diff --git a/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php b/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php
new file mode 100644
index 000000000..e78a9863a
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/tests/bootstrap.php
@@ -0,0 +1,55 @@
+loadSqlFiles('tests/schema.sql', 'test');
diff --git a/app/availableplugins/PasswordAuthenticator/tests/schema.sql b/app/availableplugins/PasswordAuthenticator/tests/schema.sql
new file mode 100644
index 000000000..c1ded9668
--- /dev/null
+++ b/app/availableplugins/PasswordAuthenticator/tests/schema.sql
@@ -0,0 +1 @@
+-- Test database schema for PasswordAuthenticator
diff --git a/app/availableplugins/PasswordAuthenticator/webroot/.gitkeep b/app/availableplugins/PasswordAuthenticator/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/composer.json b/app/composer.json
index 907a8bfba..2139c370c 100644
--- a/app/composer.json
+++ b/app/composer.json
@@ -39,8 +39,10 @@
"EnvSource\\": "plugins/EnvSource/src/",
"FileConnector\\": "availableplugins/FileConnector/src/",
"OrcidSource\\": "plugins/OrcidSource/src/",
+ "PasswordAuthenticator\\": "availableplugins/PasswordAuthenticator/src/",
"PipelineToolkit\\": "availableplugins/PipelineToolkit/src/",
"SqlConnector\\": "availableplugins/SqlConnector/src/",
+ "SshKeyAuthenticator\\": "plugins/SshKeyAuthenticator/src/",
"CoreJob\\": "plugins/CoreJob/src/"
}
},
@@ -56,8 +58,10 @@
"EnvSource\\Test\\": "plugins/EnvSource/tests/",
"FileConnector\\Test\\": "availableplugins/FileConnector/tests/",
"OrcidSource\\Test\\": "plugins/OrcidSource/tests/",
+ "PasswordAuthenticator\\Test\\": "availableplugins/PasswordAuthenticator/tests/",
"PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/",
"SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/",
+ "SshKeyAuthenticator\\Test\\": "plugins/SshKeyAuthenticator/tests/",
"CoreJob\\Test\\": "plugins/CoreJob/tests/"
}
},
diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index 86ea747fe..39b933ebe 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -12,6 +12,7 @@
"action": { "type": "string", "size": 4 },
"api_id": { "type": "integer", "foreignkey": { "table": "apis", "column": "id" } },
"api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } },
+ "authenticator_id": { "type": "integer", "foreignkey": { "table": "authenticators", "column": "id" }, "notnull": true },
"co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true },
"comment": { "type": "string", "size": 256 },
"context": { "type": "string", "size": 2 },
@@ -423,7 +424,8 @@
"indexes": {
"email_addresses_i1": { "columns": [ "mail", "type_id", "person_id" ] },
"email_addresses_i2": { "columns": [ "mail", "type_id", "external_identity_id" ] },
- "email_addresses_i3": { "columns": [ "type_id" ] }
+ "email_addresses_i3": { "columns": [ "type_id" ] },
+ "email_addresses_i4": { "columns": [ "type_id", "person_id" ] }
},
"mvea": [ "person", "external_identity" ],
"sourced": true
@@ -477,7 +479,8 @@
"identifiers_i1": { "columns": [ "identifier", "type_id", "person_id" ] },
"identifiers_i2": { "columns": [ "identifier", "type_id", "external_identity_id" ] },
"identifiers_i3": { "columns": [ "type_id" ] },
- "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] }
+ "identifiers_i4": { "needed": false, "columns": [ "provisioning_target_id" ] },
+ "identifiers_i5": { "columns": [ "type_id", "person_id" ] }
},
"mvea": [ "person", "external_identity", "group" ],
"sourced": true
@@ -939,6 +942,35 @@
},
"indexes": {
}
+ },
+
+ "authenticators": {
+ "columns": {
+ "id": {},
+ "co_id": {},
+ "description": {},
+ "plugin": {},
+ "status": {},
+ "message_template_id": {}
+ },
+ "indexes": {
+ "authenticators_i1": { "columns": [ "co_id" ] },
+ "authenticators_i2": { "needed": false, "columns": [ "message_template_id" ] }
+ }
+ },
+
+ "authenticator_statuses": {
+ "columns": {
+ "id": {},
+ "authenticator_id": {},
+ "person_id": {},
+ "locked": { "type": "boolean" }
+ },
+ "indexes": {
+ "authenticator_statuses_i1": { "columns": [ "authenticator_id", "person_id" ] },
+ "authenticator_statuses_i2": { "needed": false, "columns": [ "authenticator_id"] },
+ "authenticator_statuses_i3": { "needed": false, "columns": [ "person_id"] }
+ }
}
},
diff --git a/app/plugins/SshKeyAuthenticator/README.md b/app/plugins/SshKeyAuthenticator/README.md
new file mode 100644
index 000000000..f73123136
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/README.md
@@ -0,0 +1,11 @@
+# SshKeyAuthenticator plugin for CakePHP
+
+## Installation
+
+You can install this plugin into your CakePHP application using [composer](https://getcomposer.org).
+
+The recommended way to install composer packages is:
+
+```
+composer require your-name-here/ssh-key-authenticator
+```
diff --git a/app/plugins/SshKeyAuthenticator/composer.json b/app/plugins/SshKeyAuthenticator/composer.json
new file mode 100644
index 000000000..c8e124adb
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "your-name-here/ssh-key-authenticator",
+ "description": "SshKeyAuthenticator plugin for CakePHP",
+ "type": "cakephp-plugin",
+ "license": "MIT",
+ "require": {
+ "php": ">=7.2",
+ "cakephp/cakephp": "4.6.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5 || ^9.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "SshKeyAuthenticator\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "SshKeyAuthenticator\\Test\\": "tests/",
+ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/"
+ }
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/config/plugin.json b/app/plugins/SshKeyAuthenticator/config/plugin.json
new file mode 100644
index 000000000..80f8350de
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/config/plugin.json
@@ -0,0 +1,34 @@
+{
+ "types": {
+ "authenticator": [
+ "SshKeyAuthenticators"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "ssh_key_authenticators": {
+ "columns": {
+ "id": {},
+ "authenticator_id": {}
+ },
+ "indexes": {
+ "ssh_key_authenticators_i1": { "columns": [ "authenticator_id" ]}
+ }
+ },
+ "ssh_keys": {
+ "columns": {
+ "id": {},
+ "ssh_key_authenticator_id": { "type": "integer", "foreignkey": { "table": "ssh_key_authenticators", "column": "id" }, "notnull": true },
+ "person_id": {},
+ "skey": { "type": "text" },
+ "comment": {},
+ "type": { "type": "string", "size": 32 }
+ },
+ "indexes": {
+ "ssh_keys_i1": { "columns": [ "ssh_key_authenticator_id" ]},
+ "ssh_keys_i2": { "columns": [ "person_id" ]}
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/SshKeyAuthenticator/phpunit.xml.dist b/app/plugins/SshKeyAuthenticator/phpunit.xml.dist
new file mode 100644
index 000000000..3f44f9113
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+ tests/TestCase/
+
+
+
+
+
+
+
+
+
+
+ src/
+
+
+
diff --git a/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po b/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po
new file mode 100644
index 000000000..2097e9daf
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po
@@ -0,0 +1,77 @@
+# COmanage Registry Localizations (ssh_key_authenticator domain)
+#
+# 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)
+
+msgid "controller.SshKeyAuthenticators"
+msgstr "{0,plural,=1{SSH Key Authenticator} other{SSH Key Authenticators}}"
+
+msgid "controller.SshKeys"
+msgstr "{0,plural,=1{SSH Key} other{SSH Keys}}"
+
+msgid "enumeration.SshKeyActionEnum.SSHD"
+msgstr "SSH Key Deleted"
+
+msgid "enumeration.SshKeyActionEnum.SSHU"
+msgstr "SSH Key Uploaded"
+
+msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp256"
+msgstr "ECDSA"
+
+msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp384"
+msgstr "ECDSA384"
+
+msgid "enumeration.SshKeyTypeEnum.ecdsa-sha2-nistp521"
+msgstr "ECDSA521"
+
+msgid "enumeration.SshKeyTypeEnum.ssh-dss"
+msgstr "DSA"
+
+msgid "enumeration.SshKeyTypeEnum.ssh-ed25519"
+msgstr "ED25519"
+
+msgid "enumeration.SshKeyTypeEnum.ssh-rsa"
+msgstr "RSA"
+
+msgid "enumeration.SshKeyTypeEnum.ssh-rsa"
+msgstr "RSA1"
+
+msgid "error.SshKeys.empty"
+msgstr "SSH Key file was empty"
+
+msgid "error.SshKeys.format"
+msgstr "File does not appear to be a valid ssh public key"
+
+msgid "error.SshKeys.private"
+msgstr "Uploaded file appears to be a private key"
+
+msgid "field.keyFile"
+msgstr "Select an SSH Public Key file to upload"
+
+msgid "operation.upload"
+msgstr "Upload a New SSH Key"
+
+msgid "result.registered"
+msgstr "{0} {1} registered"
+
+msgid "result.uploaded"
+msgstr "SSH Key {0} uploaded"
\ No newline at end of file
diff --git a/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php b/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php
new file mode 100644
index 000000000..97b0e1aa5
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+ [
+ 'SshKeyAuthenticators.id' => 'asc'
+ ]
+ ];
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php
new file mode 100644
index 000000000..ff4372514
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php
@@ -0,0 +1,88 @@
+ [
+ 'SshKeys.comment' => 'asc'
+ ]
+ ];
+
+ /**
+ * Handle an add action for an SSH Key.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function add() {
+ $obj = $this->SshKeys->newEmptyEntity();
+
+ if(empty($this->requestParam('person_id'))) {
+ throw new \InvalidArgumentException(__d('error', 'notprov', [__d('controller', 'People', [1])]));
+ }
+
+ if($this->request->is('post')) {
+ try {
+ $upload = $this->getRequest()->getData('keyFile')->getStream()->getContents();
+
+ $obj = $this->SshKeys->addFromKeyFile(
+ sshKeyAuthenticatorId: (int)$this->requestParam('ssh_key_authenticator_id'),
+ personId: (int)$this->requestParam('person_id'),
+ contents: $upload
+ );
+
+ return $this->generateRedirect($obj);
+ }
+ catch(\Exception $e) {
+ // This throws \Cake\ORM\Exception\RolledbackTransactionException if
+ // aborted in afterSave
+
+ $this->Flash->error($e->getMessage());
+ }
+ }
+
+ // Pass $obj as context so the view can render validation errors
+ $this->set('vv_obj', $obj);
+
+ // Default title is add new object
+ [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, 'SshKeys', 'add');
+ $this->set('vv_title', $title);
+ $this->set('vv_supertitle', $supertitle);
+ $this->set('vv_subtitle', $subtitle);
+
+ // Let the view render - see add.php for why we don't currently use the standard view
+ // $this->render('/Standard/add-edit-view');
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php b/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php
new file mode 100644
index 000000000..20d4fad4f
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php
@@ -0,0 +1,40 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+
+ /**
+ * Determine if this entity record can be deleted.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return bool True if the record can be deleted, false otherwise
+ */
+
+ public function canDelete(): bool {
+ return true;
+ }
+
+ /**
+ * Determine if this entity is Read Only.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Entity $entity Cake Entity
+ * @return boolean true if the entity is read only, false otherwise
+ */
+
+ public function isReadOnly(): bool {
+ // SSH Keys can't be altered once created (though they can be deleted)
+
+ return true;
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php b/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php
new file mode 100644
index 000000000..de4d184f3
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php
new file mode 100644
index 000000000..7d2c8ff70
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php
@@ -0,0 +1,110 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Authenticators');
+
+ $this->hasMany('SshKeyAuthenticator.SshKeys')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('authenticator_id');
+ $this->setRequiresCO(true);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('authenticator_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('authenticator_id');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php
new file mode 100644
index 000000000..4a3877abc
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php
@@ -0,0 +1,329 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ // Timestamp behavior handles created/modified updates
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Secondary);
+
+ // Define associations
+ $this->belongsTo('SshKeyAuthenticator.SshKeyAuthenticators');
+ $this->belongsTo('People');
+
+ $this->setDisplayField('comment');
+
+ $this->setPrimaryLink('SshKeyAuthenticator.ssh_key_authenticator_id');
+ $this->setRequiresCO(true);
+ $this->setRedirectGoal('index');
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ // Following the v4 pattern, SSH Keys cannot be edited
+ 'edit' => false,
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that are permitted on readonly entities (besides view)
+ // SSH Entities are readOnly but permit delete, so we need to re-enable the action
+ 'readOnly' => ['delete'],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Add an SSH Key from a string parsed from a file.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $sshKeyAuthenticatorId SSH Key Authenticator ID
+ * @param int $personId Person ID
+ * @param string $contents Contents of SSH key file (not parsed)
+ */
+
+ public function addFromKeyFile(
+ int $sshKeyAuthenticatorId,
+ int $personId,
+ string $contents
+ ): SshKey {
+ // GMR-2 will handle checking that $personId and $sshKeyAuthenticatorId are in the
+ // same CO, so we don't have to.
+
+ // Process the key file
+ $keyFileString = rtrim($contents);
+
+ if(empty($keyFileString) || ctype_space($keyFileString)) {
+ throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.empty'));
+ }
+
+ if(preg_match("/-----BEGIN.*PRIVATE.*/", $keyFileString) == 1) {
+ // This is the private key, not the public key
+ throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.private'));
+ }
+
+ // We currently only support OpenSSH format, which is a triple of type/key/comment,
+ // and RFC 4716 Secure Shell (SSH) Public Key File format.
+
+ $newEntityData = [
+ 'ssh_key_authenticator_id' => $sshKeyAuthenticatorId,
+ 'person_id' => $personId
+ ];
+
+ // Currently, we only support one key per file regardless of format.
+
+ if(preg_match("/---- BEGIN SSH2 PUBLIC KEY ----.*/", $keyFileString) == 1) {
+ // RFC4716 format
+ $newEntityData = array_merge($newEntityData, $this->parseRfc4716($keyFileString));
+ } else {
+ // OpenSSH format
+
+ $sshKeyLine = explode("\n", $keyFileString);
+ $bits = explode(' ', $sshKeyLine[0], 3);
+
+ $newEntityData['type'] = $bits[0];
+ $newEntityData['skey'] = $bits[1];
+ $newEntityData['comment'] = $bits[2];
+ }
+
+ if(empty($newEntityData['skey'])) {
+ throw new \InvalidArgumentException(__d('ssh_key_authenticator', 'error.SshKeys.private'));
+ }
+
+ $sshkey = $this->newEntity($newEntityData);
+
+ $this->saveOrFail($sshkey);
+
+ // Record History and trigger provisioning
+
+ $this->People->recordHistory(
+ $sshkey,
+ SshKeyActionEnum::SshKeyUploaded,
+ __d('ssh_key_authenticator', 'result.uploaded', [$sshkey->comment])
+ );
+
+ $this->People->requestProvisioning($personId, ProvisioningContextEnum::Automatic);
+
+ return $sshkey;
+ }
+
+ /**
+ * Parse an RFC 4716 SSH Public Key File.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $keyFileString SSH Key File contents
+ * @return array Array of 'type', 'skey', and 'comment'
+ */
+
+ public function parseRfc4716(string $keyFileString) {
+ // RFC 4716 format is line based.
+ $lines = explode("\n", $keyFileString);
+
+ $firstLineFound = false;
+ $keyFileHeaders = [];
+ $lineContinuationInProgress = false;
+ $base64EncodedBody = "";
+
+ foreach ($lines as $line) {
+ // A Conforming key file would begin immediately with the begin marker
+ // but try to be liberal in what we accept so skip any initial lines.
+ if(!$firstLineFound) {
+ if(preg_match("/^---- BEGIN SSH2 PUBLIC KEY ----.*/", $line) === 1) {
+ $firstLineFound = true;
+ }
+ continue;
+ }
+
+ // Parse key file headers with possible continuation lines
+ // until the base64-encoded body begins.
+ if(empty($base64EncodedBody)) {
+ if(!$lineContinuationInProgress) {
+ $headerLineParts = preg_split("/:/u", $line, 2);
+ if(count($headerLineParts) == 1) {
+ $base64EncodedBody .= $line;
+ } elseif(count($headerLineParts) == 2) {
+ $headerTag = $headerLineParts[0];
+ if(substr($headerLineParts[1], -1) == '\\') {
+ $lineContinuationInProgress = true;
+ $headerValue = substr($headerLineParts[1], 0, -1);
+ } else {
+ $headerValue = $headerLineParts[1];
+ $keyFileHeaders[$headerTag] = $headerValue;
+ }
+ continue;
+ }
+ } else {
+ if(substr($line, -1) == '\\') {
+ $headerValue = $headerValue . substr($line, 0, -1);
+ } else {
+ $headerValue = $headerValue . $line;
+ $lineContinuationInProgress = false;
+ $keyFileHeaders[$headerTag] = $headerValue;
+ }
+ continue;
+ }
+ } else {
+ // Stop parsing when we find the end marker and so ignore any
+ // non-conforming end material.
+ if(preg_match("/^---- END SSH2 PUBLIC KEY ----.*/", $line) === 1) {
+ break;
+ }
+ $base64EncodedBody .= $line;
+ continue;
+ }
+ }
+
+ // Base-64 decode the body. The resulting binary string has the format
+ // 3 null bytes, key type string, 3 null bytes, public key. The key type
+ // string needs to further be trimmed to remove non-ascii characters.
+ $bodyBinaryString = base64_decode($base64EncodedBody);
+ $keyTypeString = trim(explode("\x00\x00\x00", $bodyBinaryString)[1], "\x00..\x1F");
+
+ // An empty comment is allowed.
+ $comment = "";
+
+ if(array_key_exists("Comment", $keyFileHeaders)) {
+ $comment = trim($keyFileHeaders["Comment"]);
+ } elseif (array_key_exists("Subject", $keyFileHeaders)) {
+ $comment = trim($keyFileHeaders["Subject"]);
+ }
+
+ return [
+ 'type' => $keyTypeString,
+ 'skey' => $base64EncodedBody,
+ 'comment' => $comment
+ ];
+ }
+
+ /**
+ * Obtain the current Authenticator status for a Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Authenticator $cfg Authenticator Configuration
+ * @param int $personId Person ID
+ * @return array Array with values
+ * status: AuthenticatorStatusEnum
+ * comment: Human readable string, visible to the CO Person
+ */
+
+ public function status(\App\Model\Entity\Authenticator $cfg, int $personId): array {
+ // Are there any SSH Keys for this person?
+
+ $count = $this->find()
+ ->where([
+ 'ssh_key_authenticator_id' => $cfg->ssh_key_authenticator->id,
+ 'person_id' => $personId
+ ])
+ ->count();
+
+ if($count > 0) {
+ return [
+ 'status' => AuthenticatorStatusEnum::Active,
+ 'comment' => __d('ssh_key_authenticator', 'result.registered', [$count, __d('ssh_key_authenticator', 'controller.SshKeys', [$count])])
+ ];
+ }
+
+ return [
+ 'status' => AuthenticatorStatusEnum::NotSet,
+ 'comment' => __d('result', 'set.not')
+ ];
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('ssh_key_authenticator_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('ssh_keyauthenticator_id');
+
+ $validator->add('person_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('person_id');
+
+ $validator->add('skey', [
+ 'filter' => ['rule' => ['validateInput'],
+ 'provider' => 'table']
+ ]);
+ $validator->notEmptyString('skey');
+
+ $this->registerStringValidation($validator, $schema, 'comment', false);
+
+ $validator->add('type', [
+ 'content' => ['rule' => ['inList', SshKeyTypeEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('type');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php b/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php
new file mode 100644
index 000000000..d5e6fe584
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php
@@ -0,0 +1,93 @@
+plugin(
+ 'SshKeyAuthenticator',
+ ['path' => '/ssh-key-authenticator'],
+ function (RouteBuilder $builder) {
+ // Add custom routes here
+
+ $builder->fallbacks();
+ }
+ );
+ parent::routes($routes);
+ }
+
+ /**
+ * Add middleware for the plugin.
+ *
+ * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update.
+ * @return \Cake\Http\MiddlewareQueue
+ */
+ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
+ {
+ // Add your middlewares here
+
+ return $middlewareQueue;
+ }
+
+ /**
+ * Add commands for the plugin.
+ *
+ * @param \Cake\Console\CommandCollection $commands The command collection to update.
+ * @return \Cake\Console\CommandCollection
+ */
+ public function console(CommandCollection $commands): CommandCollection
+ {
+ // Add your commands here
+
+ $commands = parent::console($commands);
+
+ return $commands;
+ }
+
+ /**
+ * Register application container services.
+ *
+ * @param \Cake\Core\ContainerInterface $container The Container to update.
+ * @return void
+ * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection
+ */
+ public function services(ContainerInterface $container): void
+ {
+ // Add your services here
+ }
+}
diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc
new file mode 100644
index 000000000..aeb267672
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc
@@ -0,0 +1,29 @@
+element('notify/banner', ['info' => __d('information', 'plugin.config.none')]);
diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeys/add.php b/app/plugins/SshKeyAuthenticator/templates/SshKeys/add.php
new file mode 100644
index 000000000..713bb372c
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/templates/SshKeys/add.php
@@ -0,0 +1,46 @@
+
+
+element('flash');
+
+ print $this->Form->create($vv_obj, ['type' => 'file']);
+
+ // List of records to collect, this will be pulled from fields.inc
+ print $this->element('form/unorderedList');
+
+ // Close the Form
+ print $this->Form->end();
diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc
new file mode 100644
index 000000000..4f428592f
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc
@@ -0,0 +1,56 @@
+ [
+ 'type' => 'link'
+ ],
+ 'type' => [
+ 'type' => 'enum',
+ 'plugin' => 'SshKeyAuthenticator',
+ 'class' => 'SshKeyTypeEnum'
+ ]
+];
+
+// $topLinks appear as an upper right menu.
+// We use $topLinks to rebuild the add link because we need additional parameters.
+$suppressAddLink = true;
+
+$topLinks = [
+ [
+ 'icon' => 'upload',
+ 'order' => 'Default',
+ 'label' => __d('ssh_key_authenticator', 'operation.upload'),
+ 'link' => [
+ 'action' => 'add',
+ '?' => [
+ 'person_id' => $vv_person_id
+ ]
+ ],
+ 'class' => ''
+ ]
+];
diff --git a/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc b/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc
new file mode 100644
index 000000000..4260eca0f
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc
@@ -0,0 +1,60 @@
+ $vv_primary_link_obj->id,
+ 'person_id' => $vv_person_id
+ ];
+
+ // As of v3.2.0, we only allow uploading of SSH Keys, not manually adding or editing
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'keyFile',
+ 'fieldOptions' => ['type' => 'file']
+ ]
+ ]);
+} elseif($vv_action == 'view') {
+ foreach([ 'type',
+ 'comment',
+ 'skey',
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field,
+ ]
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/SshKeyAuthenticator/tests/bootstrap.php b/app/plugins/SshKeyAuthenticator/tests/bootstrap.php
new file mode 100644
index 000000000..033ab6831
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/tests/bootstrap.php
@@ -0,0 +1,55 @@
+loadSqlFiles('tests/schema.sql', 'test');
diff --git a/app/plugins/SshKeyAuthenticator/tests/schema.sql b/app/plugins/SshKeyAuthenticator/tests/schema.sql
new file mode 100644
index 000000000..024cfa376
--- /dev/null
+++ b/app/plugins/SshKeyAuthenticator/tests/schema.sql
@@ -0,0 +1 @@
+-- Test database schema for SshKeyAuthenticator
diff --git a/app/plugins/SshKeyAuthenticator/webroot/.gitkeep b/app/plugins/SshKeyAuthenticator/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po
index 2005d0e44..6c2736943 100644
--- a/app/resources/locales/en_US/controller.po
+++ b/app/resources/locales/en_US/controller.po
@@ -39,6 +39,12 @@ msgstr "{0,plural,=1{API} other{APIs}}"
msgid "AuthenticationEvents"
msgstr "{0,plural,=1{Authentication Event} other{Authentication Events}}"
+msgid "AuthenticatorStatuses"
+msgstr "{0,plural,=1{Authenticator Status} other{Authenticator Statuses}}"
+
+msgid "Authenticators"
+msgstr "{0,plural,=1{Authenticator} other{Authenticators}}"
+
msgid "CoSettings"
msgstr "{0,plural,=1{CO Setting} other{CO Settings}}"
diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po
index 99a9effe2..61c13ea04 100644
--- a/app/resources/locales/en_US/enumeration.po
+++ b/app/resources/locales/en_US/enumeration.po
@@ -39,6 +39,18 @@ msgstr "API Login"
msgid "AuthenticationEventEnum.IN"
msgstr "Registry Login"
+msgid "AuthenticatorStatusEnum.A"
+msgstr "Active"
+
+msgid "AuthenticatorStatusEnum.L"
+msgstr "Locked"
+
+msgid "AuthenticatorStatusEnum.NS"
+msgstr "Not Set"
+
+msgid "AuthenticatorStatusEnum.XP"
+msgstr "Expired"
+
msgid "BooleanEnum.0"
msgstr "False"
@@ -366,8 +378,8 @@ msgstr "HTML"
msgid "MessageFormatEnum.text"
msgstr "Plain Text"
-# msgid "MessageTemplateContextEnum.AU"
-# msgstr "Authenticator"
+msgid "MessageTemplateContextEnum.AU"
+msgstr "Authenticator"
msgid "MessageTemplateContextEnum.EA"
# We no longer have notifications on Approval (use Finalization or Handoff instead)
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index 8ab5fe927..56700ae3d 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -112,6 +112,15 @@ msgstr "Cannot read file {0}"
msgid "flash"
msgstr "{0}: {1}"
+msgid "Authenticators.manage.locked"
+msgstr "Authenticator is locked and cannot be updated"
+
+msgid "Authenticators.status.locked"
+msgstr "Authenticator is already locked"
+
+msgid "Authenticators.status.unlocked"
+msgstr "Authenticator is not currently locked"
+
msgid "Cos.active"
msgstr "Requested CO {0} is not active"
diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po
index 56d7fb893..02ff23c97 100644
--- a/app/resources/locales/en_US/field.po
+++ b/app/resources/locales/en_US/field.po
@@ -369,6 +369,9 @@ msgstr "Authenticated Identifier"
msgid "AuthenticationEvents.authentication_event"
msgstr "Authentication Event"
+msgid "Authenticators.message_template_id.desc"
+msgstr "If set, when an Authenticator is updated the associated Person will be sent a message using this Template"
+
msgid "Cos.member.not"
msgstr "{0} (Not a Member)"
diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po
index 01aafaf1c..fb2402a55 100644
--- a/app/resources/locales/en_US/operation.po
+++ b/app/resources/locales/en_US/operation.po
@@ -57,6 +57,30 @@ msgstr "Adopt"
msgid "apply"
msgstr "Apply"
+msgid "Authenticators.lock"
+msgstr "Lock"
+
+msgid "Authenticators.lock.confirm"
+msgstr "Are you sure you wish to lock this Authenticator?"
+
+msgid "Authenticators.manage"
+msgstr "Manage"
+
+msgid "Authenticators.reset"
+msgstr "Reset"
+
+msgid "Authenticators.reset.confirm"
+msgstr "Are you sure you wish to reset this Authenticator?"
+
+msgid "Authenticators.unlock"
+msgstr "Unlock"
+
+msgid "Authenticators.unlock.confirm"
+msgstr "Are you sure you wish to unlock this Authenticator?"
+
+msgid "Authenticators.view"
+msgstr "View"
+
msgid "autocomplete.pager.show.more"
msgstr "show more"
@@ -330,6 +354,9 @@ msgstr "Toggle All"
msgid "unfreeze"
msgstr "Unfreeze"
+msgid "upload"
+msgstr "Upload"
+
msgid "verify"
msgstr "Verify"
diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po
index f60b5b1e3..c4480d72e 100644
--- a/app/resources/locales/en_US/result.po
+++ b/app/resources/locales/en_US/result.po
@@ -36,6 +36,24 @@ msgstr "Ad hoc attribute deleted"
msgid "applied.schema"
msgstr "Successfully applied database schema"
+msgid "Authenticators.locked"
+msgstr "Authenticator Locked"
+
+msgid "Authenticators.locked-a"
+msgstr "Authenticator {0} Locked"
+
+msgid "Authenticators.reset"
+msgstr "Authenticator Reset"
+
+msgid "Authenticators.reset-a"
+msgstr "Authenticator {0} Reset"
+
+msgid "Authenticators.unlocked"
+msgstr "Authenticator Unlocked"
+
+msgid "Authenticators.unlocked-a"
+msgstr "Authenticator {0} Unlocked"
+
msgid "copied"
msgstr "Copied"
@@ -265,6 +283,9 @@ msgstr "Search Results"
msgid "search.retry"
msgstr "Please select an option from a menu, or try your search again."
+msgid "set.not"
+msgstr "Not Set"
+
msgid "TelephoneNumber.deleted"
msgstr "Telephone number deleted"
diff --git a/app/src/Controller/AuthenticatorStatusesController.php b/app/src/Controller/AuthenticatorStatusesController.php
new file mode 100644
index 000000000..829e1ab32
--- /dev/null
+++ b/app/src/Controller/AuthenticatorStatusesController.php
@@ -0,0 +1,95 @@
+ [
+ 'AuthenticatorStatuses.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Generate an index of available Authenticator Statuses for the requested Person.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function index() {
+ // Because we're not using the standard controller we need to set our own page title
+ $this->set('vv_title', __d('controller', 'AuthenticatorStatuses', [99]));
+
+ // We want to always provide one row for each configured Authenticator.
+
+ $statuses = $this->AuthenticatorStatuses->getAllForPerson((int)$this->getRequest()->getQuery('person_id'));
+
+ // We don't have a typical Result Set for an index view, so we just build a permission set
+ // that will allow $rowActions to render. For that, we need to inject an entity ID. Note
+ // some status entities will have an $id (ie: those that are locked), but for consistency
+ // we'll overwrite those.
+
+ $i = 0;
+ $vv_permission_set = [];
+
+ foreach($statuses as $status) {
+ // Single-instance authenticators get view and reset options, if the Authenticator is set
+ $singleActions = false;
+
+ if($status->status == AuthenticatorStatusEnum::Active
+ || $status->status == AuthenticatorStatusEnum::Expired) {
+ // Query the Plugin to see if multiple instances are supported
+
+ $Plugin = TableRegistry::getTableLocator()->get($status->plugin);
+
+ $singleActions = !$Plugin->multiple;
+ }
+
+ $status->id = ++$i;
+ $vv_permission_set[$i]['Authenticators'] = [
+ 'lock' => true,
+ 'manage' => true,
+ 'reset' => $singleActions,
+ 'unlock' => true,
+ // 'view' => $singleActions
+ ];
+ }
+
+ $this->set('authenticator_statuses', $statuses);
+ $this->set('vv_permission_set', $vv_permission_set);
+
+ // Use the standard view
+ $this->render('/Standard/index');
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/AuthenticatorsController.php b/app/src/Controller/AuthenticatorsController.php
new file mode 100644
index 000000000..860d0179e
--- /dev/null
+++ b/app/src/Controller/AuthenticatorsController.php
@@ -0,0 +1,221 @@
+ [
+ 'Authenticators.description' => 'asc'
+ ]
+ ];
+
+ /**
+ * Lock an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function lock() {
+ // We'll receive a URL with three query params: authenticator_id, authenticator_status_id,
+ // and person_id. authenticator_status_id is just an artifact of how the standard index view
+ // constructs $rowActions, and we ignore it completely. We pass the other parameters to the
+ // model.
+
+ try {
+ // Perform the lock
+ $this->Authenticators->lock(
+ (int)$this->getRequest()->getQuery('authenticator_id'),
+ (int)$this->getRequest()->getQuery('person_id')
+ );
+
+ $this->Flash->success(__d('result', 'Authenticators.locked'));
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+
+ return $this->redirect([
+ 'controller' => 'authenticator_statuses',
+ 'action' => 'index',
+ '?' => [
+ 'person_id' => $this->getRequest()->getQuery('person_id')
+ ]
+ ]);
+ }
+
+ /**
+ * Manage an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function manage() {
+ // Issue a redirect from authenticator_statuses/index into the appropriate Plugin.
+ // This function is not intended to support more complex behavior. If it becomes necessary
+ // to add additional behavior, be sure to review the permissions settings and primary link
+ // handling in AuthenticatorsTable.
+
+ // We'll receive a URL with three query params: authenticator_id, authenticator_status_id,
+ // and person_id. authenticator_status_id is just an artifact of how the standard index view
+ // constructs $rowActions, and we ignore it completely. We lookup authenticator_id and map
+ // it to a Plugin configuration, and redirect there without further validation.
+
+ $cfg = $this->Authenticators->get(
+ (int)$this->getRequest()->getQuery('authenticator_id'),
+ ['contain' => $this->Authenticators->getPluginRelations()]
+ );
+
+ // We need to know what type of Authenticator we're managing to construct the redirect.
+ // This implies each Plugin can only manage one type of Authenticator (ie: we can't have a
+ // "CoreAuthenticator" that implements SshKeys _and_ Passwords because we won't know which
+ // one to redirect to). While we could have the plugin declare the Authenticator type, it's
+ // simpler and clearer to require the plugin to use the name of the token in the plugin
+ // name (ie: "SshKeyAuthenticator" and not "CoreAuthenticator"). Note this applies only to
+ // the plugin _model_, not the plugin _name_, so a fully qualified plugin name of (eg)
+ // "CoreAuthenticator.PasswordAuthenticators" is permitted.
+
+ // eg: SshKeyAuthenticator, though this doesn't need to follow the pattern
+ $pluginName = StringUtilities::pluginPlugin($cfg->plugin);
+ // eg: SshKeyAuthenticators
+ $pluginModel = StringUtilities::pluginModel($cfg->plugin);
+ // eg: SshKey
+ $authenticatorType = $this->Authenticators->authenticatorEntityName($pluginModel);
+ // eg: ssh_key_authenticator_id
+ $pluginfk = StringUtilities::classNameToForeignKey($pluginModel);
+ // eg: ssh_key_authenticator
+ $pluginfield = StringUtilities::pluginToEntityField($cfg->plugin);
+
+ // The redirect depends on whether the Plugin supports multiple instantiation or not.
+ $Plugin = TableRegistry::getTableLocator()->get($cfg->plugin);
+
+ if($Plugin->multiple) {
+ // For multiple instance, we redirect to the Plugin index view
+
+ return $this->redirect([
+ 'plugin' => $pluginName,
+ 'controller' => Inflector::pluralize($authenticatorType),
+ 'action' => 'index',
+ '?' => [
+ $pluginfk => $cfg->$pluginfield->id,
+ 'person_id' => $this->getRequest()->getQuery('person_id')
+ ]
+ ]);
+ } else {
+ // Redirect to the manage view for the Plugin. In most cases, the Plugin will extend
+ // SingleAuthenticatorController, which will implement manage(). Plugins with more
+ // complex requirements can directly implement manage().
+
+ return $this->redirect([
+ 'plugin' => $pluginName,
+ 'controller' => Inflector::pluralize($authenticatorType),
+ 'action' => 'manage',
+ '?' => [
+ $pluginfk => $cfg->$pluginfield->id,
+ 'person_id' => $this->getRequest()->getQuery('person_id')
+ ]
+ ]);
+ }
+ }
+
+ /**
+ * Reset an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function reset() {
+ // We'll receive a URL with three query params: authenticator_id, authenticator_status_id,
+ // and person_id. authenticator_status_id is just an artifact of how the standard index view
+ // constructs $rowActions, and we ignore it completely. We pass the other parameters to the
+ // model.
+
+ try {
+ // Perform the reset
+ $this->Authenticators->reset(
+ (int)$this->getRequest()->getQuery('authenticator_id'),
+ (int)$this->getRequest()->getQuery('person_id')
+ );
+
+ $this->Flash->success(__d('result', 'Authenticators.reset'));
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+
+ return $this->redirect([
+ 'controller' => 'authenticator_statuses',
+ 'action' => 'index',
+ '?' => [
+ 'person_id' => $this->getRequest()->getQuery('person_id')
+ ]
+ ]);
+ }
+
+ /**
+ * Unlock an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function unlock() {
+ // We'll receive a URL with three query params: authenticator_id, authenticator_status_id,
+ // and person_id. authenticator_status_id is just an artifact of how the standard index view
+ // constructs $rowActions, and we ignore it completely. We pass the other parameters to the
+ // model.
+
+ try {
+ // Perform the lock
+ $this->Authenticators->unlock(
+ (int)$this->getRequest()->getQuery('authenticator_id'),
+ (int)$this->getRequest()->getQuery('person_id')
+ );
+
+ $this->Flash->success(__d('result', 'Authenticators.unlocked'));
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+
+ return $this->redirect([
+ 'controller' => 'authenticator_statuses',
+ 'action' => 'index',
+ '?' => [
+ 'person_id' => $this->getRequest()->getQuery('person_id')
+ ]
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php
index 5c26a3d89..a17e138c5 100644
--- a/app/src/Controller/DashboardsController.php
+++ b/app/src/Controller/DashboardsController.php
@@ -92,6 +92,11 @@ public function configuration() {
'controller' => 'apis',
'action' => 'index'
],
+ __d('controller', 'Authenticators', [99]) => [
+ 'icon' => 'lock',
+ 'controller' => 'authenticators',
+ 'action' => 'index'
+ ],
__d('controller', 'Cous', [99]) => [
'icon' => 'people_outline',
'controller' => 'cous',
diff --git a/app/src/Controller/MultipleAuthenticatorController.php b/app/src/Controller/MultipleAuthenticatorController.php
new file mode 100644
index 000000000..4bc5168cb
--- /dev/null
+++ b/app/src/Controller/MultipleAuthenticatorController.php
@@ -0,0 +1,242 @@
+name;
+ // $authModelName = eg SshKeyAuthenticators
+ $authModelsName = Inflector::singularize($modelsName) . "Authenticators";
+ // $table = the actual table object
+ $Table = $this->$modelsName;
+ // $authFK = eg ssh_key_authenticator_id
+ $authFK = StringUtilities::classNameToForeignKey($authModelsName);
+
+ // We need to cache our plugin ID and the person ID to be able to issue redirects,
+ // in particular for delete views. For consistency, we'll do this for all views.
+
+ $id = $this->request->getParam('pass.0');
+
+ if(!empty($id)) {
+ $obj = $Table->get($id);
+
+ $this->redirectInfo[$authFK] = $obj->$authFK;
+ $this->redirectInfo['person_id'] = $obj->person_id;
+ } else {
+ $this->redirectInfo[$authFK] = $this->requestParam($authFK);
+ $this->redirectInfo['person_id'] = $this->requestParam('person_id');
+ }
+
+ return parent::beforeFilter($event);
+ }
+
+ /**
+ * Callback run prior to the request render.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EventInterface $event Cake Event
+ * @return \Cake\Http\Response HTTP Response
+ */
+
+ public function beforeRender(\Cake\Event\EventInterface $event) {
+ $this->set('vv_person_id', $this->requestParam('person_id'));
+
+ return parent::beforeRender($event);
+ }
+
+ /**
+ * Generate a redirect for an SSH Key operation.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Entity $entity Entity to redirect to
+ * @return \Cake\Http\Response
+ */
+
+ public function generateRedirect($entity) {
+ // $modelsName = Models (eg: SshKeys)
+ $modelsName = $this->name;
+ // $authModelName = eg SshKeyAuthenticators
+ $authModelsName = Inflector::singularize($modelsName) . "Authenticators";
+ // $table = the actual table object
+ $Table = $this->$modelsName;
+ // $authFK = eg ssh_key_authenticator_id
+ $authFK = StringUtilities::classNameToForeignKey($authModelsName);
+
+ // We override the default behavior because we need to construct an index URL
+ // with multiple parameters. We'll ignore $entity because it isn't always provided
+ // (eg: on delete) and beforeFilter has cached what we need for all actions.
+
+ return $this->redirect([
+ // We have to rely on Cake auto-injecting the plugin based on the current plugin
+ // because we can't directly determine the plugin name without figuring out our
+ // configuration (via widget_authenticator_id) and looking up the fully qualified
+ // plugin name.
+ // 'plugin' => 'SshKeyAuthenticator',
+ 'controller' => Inflector::dasherize($modelsName),
+ 'action' => 'index',
+ '?' => [
+ $authFK => $this->redirectInfo[$authFK],
+ 'person_id' => $this->redirectInfo['person_id']
+ ]
+ ]);
+ }
+
+ /**
+ * Generate an index for a set of Authenticator Objects.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function index() {
+ // $this->name = Models
+ $modelsName = $this->name;
+ // $table = the actual table object
+ $table = $this->$modelsName;
+ // $tableName = models
+ $tableName = $table->getTable();
+ // Construct the Query
+ $query = $this->getIndexQuery();
+
+ // We need to filter on both the primary key (widget_authenticator_id)
+ // _and_ person_id. In v4 we would do that via paginationConditions, but we don't
+ // appear to have an equivalent for v5 yet. So for now we just override index.
+
+ $personId = $this->requestParam('person_id');
+
+ if($personId) {
+ $query = $query->where(['person_id' => $personId]);
+ }
+
+ // Fetch the data and paginate
+ $paginationLimit = $this->getValue(\App\Lib\Enum\ApplicationStateEnum::PaginationLimit, DEF_SEARCH_LIMIT);
+ $resultSet = $this->paginate($query, [
+ 'limit' => (int)$paginationLimit
+ ]);
+
+ // Pass vars to the View
+ $this->set($tableName, $resultSet);
+ $this->set('vv_permission_set', $this->RegistryAuth->calculatePermissionsForResultSet($resultSet));
+ // AutoViewVarsTrait
+ $this->populateAutoViewVars();
+
+ // Default index view title is model name
+ [$title, , ] = StringUtilities::entityAndActionToTitle($resultSet, $modelsName, 'index');
+ $this->set('vv_title', $title);
+
+ // Let the view render
+ $this->render('/Standard/index');
+ }
+
+ /**
+ * Indicate whether this Controller will handle some or all authnz.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EventInterface $event Cake event, ie: from beforeFilter
+ * @return string "no", "notauth", "open", "authz", or "yes"
+ */
+
+ public function willHandleAuth(\Cake\Event\EventInterface $event): string {
+ // $modelsName = Models (eg: SshKeys)
+ $modelsName = $this->name;
+ // $authModelName = eg SshKeyAuthenticators
+ $authModelsName = Inflector::singularize($modelsName) . "Authenticators";
+ // $table = the actual table object
+ $Table = $this->$modelsName;
+ // $authFK = eg ssh_key_authenticator_id
+ $authFK = StringUtilities::classNameToForeignKey($authModelsName);
+
+ $request = $this->getRequest();
+ $action = $request->getParam('action');
+ $id = (int)$this->request->getParam('pass.0');
+
+ // If the current Authenticator is locked, we need to reject all requests.
+ // We need to perform this test from within the Plugin for the actions the
+ // Plugin handles, such as index and add.
+
+ $pluginAuthenticatorId = null;
+ $personId = null;
+
+ if(in_array($action, ['add', 'index', 'manage'])) {
+ // Get the parameters from the request
+
+ $pluginAuthenticatorId = $this->requestParam($authFK);
+ $personId = $this->requestParam('person_id');
+ } elseif(!empty($this->request->getParam('pass.0'))) {
+ // Lookup the parameters from the record ID
+
+ $obj = $Table->get($this->request->getParam('pass.0'));
+
+ $pluginAuthenticatorId = $obj->$authFK;
+ $personId = $obj->person_id;
+ }
+
+ if(!$pluginAuthenticatorId) {
+ throw new \InvalidArgumentException(__d('error', 'notprov', [$authFK]));
+ }
+
+ if(!$personId) {
+ throw new \InvalidArgumentException(__d('error', 'notprov', ['person_id']));
+ }
+
+ $cfg = $Table->$authModelsName->get($pluginAuthenticatorId);
+
+ $AuthenticatorStatuses = TableRegistry::getTableLocator()->get('AuthenticatorStatuses');
+
+ $status = $AuthenticatorStatuses->find()
+ ->where([
+ 'authenticator_id' => $cfg->authenticator_id,
+ 'person_id' => $personId
+ ])
+ ->first();
+
+ if(!empty($status) && $status->locked) {
+ return 'notauth';
+ }
+
+ return 'no';
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/SingleAuthenticatorController.php b/app/src/Controller/SingleAuthenticatorController.php
new file mode 100644
index 000000000..0c06b48b3
--- /dev/null
+++ b/app/src/Controller/SingleAuthenticatorController.php
@@ -0,0 +1,116 @@
+name;
+ // $authModelName = eg PasswordAuthenticators
+ $authModelsName = Inflector::singularize($modelsName) . "Authenticators";
+ // $table = the actual table object
+ $Table = $this->$modelsName;
+ // $authFK = eg password_authenticator_id
+ $authFK = StringUtilities::classNameToForeignKey($authModelsName);
+
+ // We will be passed person_id and foo_authenticator_id. AR-GMR-2 should ensure
+ // they're in the same CO when we try to save an entity that references both.
+
+ // Pull the current Authenticator status to pass to the view. For this, we need
+ // the Authenticator configuration. We set the Authenticator as the top level object
+ // for consistency with the other interfaces, and also because getForPerson requires
+ // the same interface. However, in order to do that we need the Authenticator ID,
+ // so we'll need an extra sort-of redundant query.
+
+ $cfg = $Table->$authModelsName
+ ->get($this->getRequest()->getQuery($authFK));
+
+ // getForPerson expects the Authenticator with the PasswordAuthenticator configuration
+ // under it, so we need to flip $cfg. We do this by retrieving it a second time from
+ // the database because if we try to manually manipulate the order we'll get into
+ // weird loops and dereferencing issues in various contexts.
+
+ $Authenticators = TableRegistry::getTableLocator()->get('Authenticators');
+
+ $authcfg = $Authenticators->get($cfg->authenticator_id, ['contain' => $authModelsName]);
+
+ $status = $Authenticators->AuthenticatorStatuses->getForPerson(
+ $authcfg,
+ (int)$this->getRequest()->getQuery('person_id')
+ );
+
+ if($this->request->is('post')) {
+ try {
+ $Table->manage($authcfg, $status->person_id, $this->request->getData());
+
+ // Plugins are expected to record history. We'll handle provisioning here.
+
+ $Table->People->requestProvisioning($status->person_id, ProvisioningContextEnum::Automatic);
+
+ // Redirect to the main authenticator index for this Person
+ return $this->redirect([
+ 'plugin' => null,
+ 'controller' => 'AuthenticatorStatuses',
+ 'action' => 'index',
+ '?' => [
+ 'person_id' => $status->person_id
+ ]
+ ]);
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+ }
+
+ $this->set('vv_authenticator', $authcfg);
+ $this->set('vv_status', $status);
+
+ // Pull the Person name for use in the page title
+ $Names = TableRegistry::getTableLocator()->get('Names');
+
+ $name = $Names->primaryName($status->person_id);
+
+ $this->set('vv_title', __d('password_authenticator', 'operation.set', [$name->full_name]));
+
+ // Let the view render
+ $this->render('/Standard/manage');
+ }
+}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php
index dc5799bde..180932b4b 100644
--- a/app/src/Lib/Enum/ActionEnum.php
+++ b/app/src/Lib/Enum/ActionEnum.php
@@ -32,6 +32,10 @@
class ActionEnum extends StandardEnum {
// Codes beginning with 'X' (eg: 'XABC') are reserved for local use
// Codes beginning with a lowercase 'p' (eg: 'pABC') are reserved for plugin use
+ const AuthenticatorEdited = 'EAUT';
+ const AuthenticatorLocked = 'LAUT';
+ const AuthenticatorReset = 'RAUT';
+ const AuthenticatorUnlocked = 'UAUT';
const CommentAdded = 'CMNT';
const EmailForceVerified = 'EMFV';
const EmailVerified = 'EMLV';
diff --git a/app/src/Lib/Enum/AuthenticatorStatusEnum.php b/app/src/Lib/Enum/AuthenticatorStatusEnum.php
new file mode 100644
index 000000000..2c4964386
--- /dev/null
+++ b/app/src/Lib/Enum/AuthenticatorStatusEnum.php
@@ -0,0 +1,37 @@
+getSubject();
+ // table = (eg) passwords
+ $table = $subject->getTable();
+ // alias = (eg) Passwords
+ $alias = $subject->getAlias();
+ // entityName = (eg) Password
+ $entityName = Inflector::singularize($alias);
+
+ // For reset operation, we allow a locked authenticator to be reset, in which case
+ // we just skip this check.
+ if(isset($options['reset']) && $options['reset']) {
+ return;
+ }
+
+ // We need to check if the Authenticator is locked for the subject Person, and if so
+ // reject the request. For this, we need the Authenticator ID, which we need to look up.
+ // For that, we need to map the Authenticator (eg "Password") to its parent model
+ // (eg "PasswordAuthenticator").
+
+ // $pluginModel = (eg) PasswordAuthenticator.PasswordAuthenticators
+ $pluginModel = $entityName . "Authenticator." . $entityName . "Authenticators";
+ // $pluginFK = (eg) password_authenticator_id
+ $pluginFK = Inflector::singularize($table) . "_authenticator_id";
+
+ $pluginTable = TableRegistry::getTableLocator()->get($pluginModel);
+ $Authenticators = TableRegistry::getTableLocator()->get('Authenticators');
+
+ $pluginCfg = $pluginTable->get($entity->$pluginFK);
+
+ $status = $Authenticators->AuthenticatorStatuses->find()
+ ->where([
+ 'authenticator_id' => $pluginCfg->authenticator_id,
+ 'person_id' => $entity->person_id
+ ])
+ ->first();
+
+ if($status && $status->isLocked()) {
+ throw new \RuntimeException(__d('error', 'Authenticators.manage.locked'));
+ }
+
+ return;
+ }
+}
diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php
index fad6f5610..2788271cc 100644
--- a/app/src/Model/Behavior/ChangelogBehavior.php
+++ b/app/src/Model/Behavior/ChangelogBehavior.php
@@ -69,7 +69,7 @@ public function beforeDelete(Event $event, $entity, \ArrayObject $options) {
// Update this record as deleted
$entity->deleted = true;
- $subject->saveOrFail($entity, ['checkRules' => false, 'archive' => false]);
+ $subject->saveOrFail($entity, new \ArrayObject(array_merge($options->getArrayCopy(), ['checkRules' => false, 'archive' => false])));
// Stop the delete from actually happening
$event->stopPropagation();
diff --git a/app/src/Model/Entity/Authenticator.php b/app/src/Model/Entity/Authenticator.php
new file mode 100644
index 000000000..20c27b16e
--- /dev/null
+++ b/app/src/Model/Entity/Authenticator.php
@@ -0,0 +1,42 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Entity/AuthenticatorStatus.php b/app/src/Model/Entity/AuthenticatorStatus.php
new file mode 100644
index 000000000..2e1854ed5
--- /dev/null
+++ b/app/src/Model/Entity/AuthenticatorStatus.php
@@ -0,0 +1,64 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+
+ /**
+ * Determine if the corresponding Authenticator is locked.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return bool true if the Authenticator is Active or GracePeriod, false otherwise
+ */
+
+ public function isLocked(): bool {
+ return $this->locked;
+ }
+
+ /**
+ * Determine if the corresponding Authenticator is not locked.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return bool true if Person is Active or GracePeriod, false otherwise
+ */
+
+ public function notLocked(): bool {
+ return !$this->locked;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/AuthenticatorStatusesTable.php b/app/src/Model/Table/AuthenticatorStatusesTable.php
new file mode 100644
index 000000000..5011341b1
--- /dev/null
+++ b/app/src/Model/Table/AuthenticatorStatusesTable.php
@@ -0,0 +1,209 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('Authenticators');
+ $this->belongsTo('People');
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('person_id');
+ $this->setRequiresCO(true);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false,
+ 'edit' => false,
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false,
+ 'index' => ['platformAdmin', 'coAdmin']
+ ],
+ 'related' => [
+ 'table' => [
+ 'Authenticators'
+ ]
+ ]
+ ]);
+ }
+
+ /**
+ * Obtain the set of Authenticator Statuses for a Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $personId Person ID
+ * @return array Array of Authenticator Status, one for each Authenticator configured in the CO
+ * @throws RecordNotFoundException
+ */
+
+ public function getAllForPerson(int $personId): array {
+ $ret = [];
+
+ // We start by mapping the Person to their CO, then retrieving all of the CO's
+ // active Authenticators. We use calculateCoId specifically since it will throw
+ // an Exception if not found.
+
+ $coId = $this->People->calculateCoId($personId);
+
+ $authenticators = $this->Authenticators->find()
+ ->where([
+ 'status' => SuspendableStatusEnum::Active,
+ 'co_id' => $coId
+ ])
+ ->contain($this->Authenticators->getPluginRelations())
+ ->all();
+
+ foreach($authenticators as $authenticator) {
+ $ret[] = $this->getForPerson($authenticator, $personId);
+ }
+
+ return $ret;
+ }
+
+ /**
+ * Obtain Authenticator Status for a Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $authenticator Authenticator, including plugin model
+ * @param int $personId Person ID
+ * @return array AuthenticatorStatus
+ * @throws RecordNotFoundException
+ */
+
+ public function getForPerson(Authenticator $authenticator, int $personId): AuthenticatorStatus {
+ // See if we have an Authenticator Status for this Person.
+ // If not, create a placeholder.
+
+ $status = $this->find()
+ ->where([
+ 'authenticator_id' => $authenticator->id,
+ 'person_id' => $personId
+ ])
+ ->first();
+
+ // This only indicates if the authenticator is locked, and it may not even be present.
+ // We need to query the backend for actual status.
+
+ if(!$status) {
+ $status = $this->newEntity([
+ 'authenticator_id' => $authenticator->id,
+ 'person_id' => $personId,
+ 'locked' => false
+ ]);
+ }
+
+ // Query the Plugin for status for this Person, unless the Authenticator is locked
+ if($status->locked) {
+ $status->status = AuthenticatorStatusEnum::Locked;
+ $status->comment = __d('result', 'Authenticators.locked');
+ } else {
+ $Plugin = $this->Authenticators->authenticatorTable($authenticator->plugin);
+
+ $backendStatus = $Plugin->status($authenticator, $personId);
+
+ $status->status = $backendStatus['status'];
+ $status->comment = $backendStatus['comment'];
+ }
+
+ // Inject additional metadata for the view
+ $status->description = $authenticator->description;
+ $status->plugin = $authenticator->plugin;
+
+ return $status;
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('authenticator_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('authenticator_id');
+
+ $validator->add('person_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('person_id');
+
+ $validator->add('locked', [
+ 'content' => ['rule' => 'boolean']
+ ]);
+ $validator->allowEmptyString('locked');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/AuthenticatorsTable.php b/app/src/Model/Table/AuthenticatorsTable.php
new file mode 100644
index 000000000..843ee697e
--- /dev/null
+++ b/app/src/Model/Table/AuthenticatorsTable.php
@@ -0,0 +1,325 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Cos');
+ $this->belongsTo('MessageTemplates');
+
+ $this->hasMany('AuthenticatorStatuses')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setPluginRelations();
+
+ $this->setDisplayField('description');
+
+ // $this->setPrimaryLink('co_id');
+ $this->setPrimaryLink(['co_id', 'authenticator_id']);
+ $this->setRequiresCO(true);
+ $this->setAllowUnkeyedPrimaryLink(['lock', 'manage', 'reset', 'unlock']);
+
+ $this->setAutoViewVars([
+ 'messageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::Authenticator]
+ ],
+ 'plugins' => [
+ 'type' => 'plugin',
+ 'pluginType' => 'authenticator'
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'configure' => ['platformAdmin', 'coAdmin'],
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ // Note because of how parameters are passed to Authenticators many actions here
+ // are "table" rather than "entity" actions
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin'],
+ 'lock' => ['platformAdmin', 'coAdmin'],
+ // 'manage' will just issue a redirect based on the query params, but we still
+ // require authz since there's no reason to leave it fully open
+ 'manage' => ['platformAdmin', 'coAdmin'],
+ 'reset' => ['platformAdmin', 'coAdmin'],
+ // 'status' => ['platformAdmin', 'coAdmin']
+ 'unlock' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Determine the fully qualified Authenticator Entity Name based on the provided
+ * fully qualified Plugin name.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $pluginName Plugin Name, eg PasswordAuthenticator.PasswordAuthenticators
+ * @return string Authenticator Entity Name, eg PasswordAuthenticator.Password
+ */
+
+ public function authenticatorEntityName(string $pluginName) {
+ // $plugin is something like PasswordAuthenticator.PasswordAuthenticators,
+ // the actual Authenticator entity is something like PasswordAuthenticator.Password
+
+ return substr($pluginName, 0, strlen($pluginName)-14);
+ }
+
+ /**
+ * Obtain the Authenticator Table for the provided fully qualified Plugin.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $pluginName Plugin Name, eg PasswordAuthenticator.PasswordAuthenticators
+ * @return Table Authenticator Table, eg PasswordAuthenticator.Passwords
+ */
+
+ public function authenticatorTable(string $pluginName) {
+ // We need to pluralize the entity name to get back to the table
+
+ $tableName = Inflector::pluralize($this->authenticatorEntityName($pluginName));
+
+ return TableRegistry::getTableLocator()->get($tableName);
+ }
+
+ /**
+ * Lock an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Authenticator ID
+ * @param int $personId Person ID
+ * @throws InvalidArgumentException
+ */
+
+ public function lock(int $id, int $personId) {
+ $this->processLock($id, $personId, 'lock');
+ }
+
+ /**
+ * Process a status change of a lock.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Authenticator ID
+ * @param int $personId Person ID
+ * @param string $action "lock" or "unlock"
+ * @throws InvalidArgumentException
+ */
+
+ protected function processLock(int $id, int $personId, string $action) {
+ $cfg = $this->get($id, ['contain' => $this->getPluginRelations()]);
+
+ // Make sure our configuration is active
+ if($cfg->status != SuspendableStatusEnum::Active) {
+ throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Authenticators', [1], $cfg->id)]));
+ }
+
+ // See if there is a current status that is incompatible with $action
+ $curStatus = $this->AuthenticatorStatuses->find()->where([
+ 'authenticator_id' => $id,
+ 'person_id' => $personId
+ ])->first();
+
+ if($action == 'lock' && !empty($curStatus) && $curStatus->locked) {
+ throw new \InvalidArgumentException(__d('error', 'Authenticators.status.locked'));
+ } elseif($action == 'unlock' && (empty($curStatus) || !$curStatus->locked)) {
+ throw new \InvalidArgumentException(__d('error', 'Authenticators.status.unlocked'));
+ }
+
+ // Give the backend a chance to do something
+ $PluginTable = TableRegistry::getTableLocator()->get($cfg->plugin);
+
+ if(method_exists($PluginTable, $action)) {
+ $PluginTable->$action($cfg, $personId);
+ }
+
+ // Upsert
+ if($curStatus) {
+ $curStatus->locked = ($action == 'lock');
+ } else {
+ $curStatus = $this->AuthenticatorStatuses->newEntity([
+ 'authenticator_id' => $id,
+ 'person_id' => $personId,
+ 'locked' => ($action == 'lock')
+ ]);
+ }
+
+ $this->AuthenticatorStatuses->saveOrFail($curStatus);
+
+ // Record History and Provision
+
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ $People->recordHistory(
+ $curStatus,
+ ActionEnum::AuthenticatorLocked,
+ __d('result', 'Authenticators.'.$action.'ed-a', [$cfg->description])
+ );
+
+ $People->requestProvisioning($personId, ProvisioningContextEnum::Automatic);
+ }
+
+ /**
+ * Reset an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Authenticator ID
+ * @param int $personId Person ID
+ * @throws InvalidArgumentException
+ */
+
+ public function reset(int $id, int $personId) {
+ $cfg = $this->get($id, ['contain' => $this->getPluginRelations()]);
+
+ // Make sure our configuration is active
+ if($cfg->status != SuspendableStatusEnum::Active) {
+ throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Authenticators', [1], $cfg->id)]));
+ }
+
+ // Per AR-Authenticator-2 a locked Authenticator may be reset, so we don't need to
+ // check for that here.
+
+ // The actual reset logic is backend specific
+
+ $PluginTable = $this->authenticatorTable($cfg->plugin);
+
+ if(method_exists($PluginTable, 'reset')) {
+ $PluginTable->reset($cfg, $personId);
+ }
+
+ // Record History and Provision
+
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ // We need an entity to pass to recordHistory, so we create a new Person entity
+ // and pass it around
+
+ $person = $People->get($personId);
+
+ $People->recordHistory(
+ $person,
+ ActionEnum::AuthenticatorReset,
+ __d('result', 'Authenticators.reset-a', [$cfg->description])
+ );
+
+ $People->requestProvisioning($personId, ProvisioningContextEnum::Automatic);
+ }
+
+ /**
+ * Unlock an Authenticator.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Authenticator ID
+ * @param int $personId Person ID
+ * @throws InvalidArgumentException
+ */
+
+ public function unlock(int $id, int $personId) {
+ $this->processLock($id, $personId, 'unlock');
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('co_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('co_id');
+
+ $this->registerStringValidation($validator, $schema, 'description', true);
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $this->registerStringValidation($validator, $schema, 'plugin', true);
+
+ $validator->add('message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('message_template_id');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/JobHistoryRecordsTable.php b/app/src/Model/Table/JobHistoryRecordsTable.php
index cc1cba7ae..b13e0052a 100644
--- a/app/src/Model/Table/JobHistoryRecordsTable.php
+++ b/app/src/Model/Table/JobHistoryRecordsTable.php
@@ -144,7 +144,7 @@ public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity)
*/
public function record(int $jobId,
- string $recordKey,
+ ?string $recordKey,
string $comment,
string $status=JobStatusEnum::Notice,
?int $personId=null,
diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php
index 275c58328..92e8ec0fc 100644
--- a/app/src/Model/Table/PeopleTable.php
+++ b/app/src/Model/Table/PeopleTable.php
@@ -94,6 +94,9 @@ public function initialize(array $config): void {
$this->hasMany('ApplicationStates')
->setDependent(true)
->setCascadeCallbacks(true);
+ $this->hasMany('AuthenticatorStatuses')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
$this->hasMany('EmailAddresses')
->setDependent(true)
->setCascadeCallbacks(true);
@@ -284,6 +287,7 @@ public function initialize(array $config): void {
'table' => [
'Addresses',
'AdHocAttributes',
+ 'AuthenticatorStatuses',
'Names',
'EmailAddresses',
'ExternalIdentities',
diff --git a/app/templates/AuthenticatorStatuses/columns.inc b/app/templates/AuthenticatorStatuses/columns.inc
new file mode 100644
index 000000000..38fd26587
--- /dev/null
+++ b/app/templates/AuthenticatorStatuses/columns.inc
@@ -0,0 +1,129 @@
+ [
+ 'type' => 'echo'
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'AuthenticatorStatusEnum'
+ ],
+ 'comment' => [
+ 'type' => 'echo'
+ ]
+ ];
+
+// $rowActions appear as row-level menu items in the index view gear icon
+// Because these apply generically per row, we can't deep link into each plugin,
+// but instead we need a generic URL that can redirect to the correct endpoint.
+$rowActions = [
+/* We can't have a view icon because the permissions configuration will conflict with
+ Authenticators::view, but it's also a simpler interface to route everything through
+ manage instead
+ [
+ 'controller' => 'authenticators',
+ 'action' => 'view',
+ 'icon' => 'settings',
+ 'if' => 'notLocked',
+ 'label' => __d('operation', 'view'),
+ 'query' => function($entity) {
+ // The standard index code will also inject authenticator_status_id, which we'll just ignore
+ return [
+ 'authenticator_id' => $entity->authenticator_id,
+ 'person_id' => $entity->person_id
+ ];
+ }
+ ],*/
+ [
+ 'controller' => 'authenticators',
+ 'action' => 'manage',
+ 'icon' => 'settings',
+ 'if' => 'notLocked',
+ 'label' => __d('operation', 'Authenticators.manage'),
+ 'query' => function($entity) {
+ // The standard index code will also inject authenticator_status_id, which we'll just ignore
+ return [
+ 'authenticator_id' => $entity->authenticator_id,
+ 'person_id' => $entity->person_id
+ ];
+ }
+ ],
+ [
+ 'controller' => 'authenticators',
+ 'action' => 'reset',
+ 'icon' => 'lock_reset',
+ 'if' => 'notLocked',
+ 'label' => __d('operation', 'Authenticators.reset'),
+ 'query' => function($entity) {
+ // The standard index code will also inject authenticator_status_id, which we'll just ignore
+ return [
+ 'authenticator_id' => $entity->authenticator_id,
+ 'person_id' => $entity->person_id
+ ];
+ },
+ 'confirm' => [
+ 'dg_body_txt' => __d('operation', 'Authenticators.reset.confirm')
+ ]
+ ],
+ [
+ 'controller' => 'authenticators',
+ 'action' => 'lock',
+ 'icon' => 'lock',
+ 'if' => 'notLocked',
+ 'label' => __d('operation', 'Authenticators.lock'),
+ 'query' => function($entity) {
+ // The standard index code will also inject authenticator_status_id, which we'll just ignore
+ return [
+ 'authenticator_id' => $entity->authenticator_id,
+ 'person_id' => $entity->person_id
+ ];
+ },
+ 'confirm' => [
+ 'dg_body_txt' => __d('operation', 'Authenticators.lock.confirm')
+ ]
+ ],
+ [
+ 'controller' => 'authenticators',
+ 'action' => 'unlock',
+ 'icon' => 'lock_open_right',
+ 'if' => 'isLocked',
+ 'label' => __d('operation', 'Authenticators.unlock'),
+ 'query' => function($entity) {
+ // The standard index code will also inject authenticator_status_id, which we'll just ignore
+ return [
+ 'authenticator_id' => $entity->authenticator_id,
+ 'person_id' => $entity->person_id
+ ];
+ },
+ 'confirm' => [
+ 'dg_body_txt' => __d('operation', 'Authenticators.unlock.confirm')
+ ]
+ ]
+];
\ No newline at end of file
diff --git a/app/templates/Authenticators/columns.inc b/app/templates/Authenticators/columns.inc
new file mode 100644
index 000000000..c26e059d0
--- /dev/null
+++ b/app/templates/Authenticators/columns.inc
@@ -0,0 +1,59 @@
+ [
+ 'type' => 'link',
+ 'sortable' => true
+ ],
+ 'plugin' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum',
+ 'sortable' => true
+ ]
+];
+
+// $rowActions appear as row-level menu items in the index view gear icon
+$rowActions = [
+ [
+ 'action' => 'configure',
+ 'label' => __d('operation', 'configure.plugin'),
+ 'icon' => 'electrical_services'
+ ]
+];
+
+/*
+// When the $bulkActions variable exists in a columns.inc config, the "Bulk edit" switch will appear in the index.
+$bulkActions = [
+ // TODO: develop bulk actions. For now, use a placeholder.
+ 'delete' => true
+];
+*/
\ No newline at end of file
diff --git a/app/templates/Authenticators/fields.inc b/app/templates/Authenticators/fields.inc
new file mode 100644
index 000000000..e69f1cb99
--- /dev/null
+++ b/app/templates/Authenticators/fields.inc
@@ -0,0 +1,51 @@
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'plugin',
+ 'labelIsTextOnly' => true
+ ]
+ ]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'message_template_id'
+ ]]);
+}
diff --git a/app/templates/People/fields-nav.inc b/app/templates/People/fields-nav.inc
index 3ed6b7351..9f4b6f856 100644
--- a/app/templates/People/fields-nav.inc
+++ b/app/templates/People/fields-nav.inc
@@ -69,6 +69,20 @@ $topLinks = [
],
'class' => ''
],
+ [
+ 'icon' => 'lock',
+ 'order' => 'Default',
+ // We use "Authenticators" as the label because it's less confusiong
+ 'label' => __d('controller', 'Authenticators', [99]),
+ 'link' => [
+ 'controller' => 'authenticator_statuses',
+ 'action' => 'index',
+ '?' => [
+ 'person_id' => $vv_obj->id
+ ]
+ ],
+ 'class' => ''
+ ],
[
'icon' => 'notifications',
'iconClass' => 'material-symbols-outlined',
diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php
index 5d4583567..b586292a6 100644
--- a/app/templates/Standard/index.php
+++ b/app/templates/Standard/index.php
@@ -437,13 +437,23 @@
break;
case 'enum':
// XXX Need to add badging - see index.php in Match
+ // Core enumerations are __d('enumeration', 'Enum.value')
+ $d = 'enumeration';
+ $p = "";
+
+ if(!empty($cfg['plugin'])) {
+ // Plugin enumerations are __d($plugindomain, 'enumeration.Enum.value')
+ $d = Inflector::underscore($cfg['plugin']);
+ $p = "enumeration.";
+ }
+
if(!empty($cfg['model']) && !empty($cfg['field'])) {
$m = $cfg['model'];
$f = $cfg['field'];
- print __d('enumeration', $cfg['class'].'.'.$entity->$m->$f) . $suffix;
+ print __d($d, $p.$cfg['class'].'.'.$entity->$m->$f) . $suffix;
} elseif($entity->$col) {
- print __d('enumeration', $cfg['class'].'.'.$entity->$col) . $suffix;
+ print __d($d, $p.$cfg['class'].'.'.$entity->$col) . $suffix;
}
break;
case 'fk':
@@ -658,4 +668,8 @@
-= $this->element("pagination");
\ No newline at end of file
+element("pagination");
+}
\ No newline at end of file
diff --git a/app/templates/Standard/manage.php b/app/templates/Standard/manage.php
new file mode 100644
index 000000000..9f4ef7ab9
--- /dev/null
+++ b/app/templates/Standard/manage.php
@@ -0,0 +1,92 @@
+element('flash', []);
+
+// Make the Form fields editable
+$this->Field->enableFormEditMode();
+?>
+
+
+
+
= $vv_title ?>
+
+
+
+
+
+ 'history',
+ 'order' => 'Default',
+ 'label' => __d('operation', 'reset'),
+ 'link' => [
+ 'controller' => 'authenticators',
+ 'action' => 'reset',
+ ]
+ ];
+
+ // XXX maybe other authenticators will want to add custom topLinks?
+/* if(file_exists($templatePath . DS . "fields-nav.inc")) {
+ include($templatePath . DS . "fields-nav.inc");
+ } */
+}
+
+print $this->Form->create();
+
+if(!$vv_status->locked) {
+ // Inject the parent keys
+
+ $hidden = [
+ 'password_authenticator_id' => $vv_authenticator->password_authenticator->id,
+ 'person_id' => $vv_status->person_id
+ ];
+} else {
+ $suppress_submit = true;
+}
+
+// List of records to collect
+// We allow the form to render even if the Authenticator is locked so the Plugin
+// can render (read-only) information if it wants to
+print $this->element('form/unorderedList');
+
+// Close the Form
+print $this->Form->end();