From 06673d340c82a34850018c223aec2c0cc4b3f10c Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Tue, 26 Aug 2025 17:09:06 -0400 Subject: [PATCH] Initial implementation of Authenticators, PasswordAuthenticator, and SshKeyAuthenticator (CFM-43, CFM-181, CFM-183) --- README.md | 4 +- .../PasswordAuthenticator/README.md | 11 + .../PasswordAuthenticator/composer.json | 24 ++ .../PasswordAuthenticator/config/plugin.json | 39 ++ .../PasswordAuthenticator/phpunit.xml.dist | 30 ++ .../locales/en_US/password_authenticator.po | 107 ++++++ .../src/Controller/AppController.php | 10 + .../PasswordAuthenticatorsController.php | 40 ++ .../src/Controller/PasswordsController.php | 42 +++ .../src/Lib/Enum/PasswordEncodingEnum.php | 39 ++ .../src/Lib/Enum/PasswordSourceEnum.php | 38 ++ .../src/Model/Entity/Password.php | 49 +++ .../Model/Entity/PasswordAuthenticator.php | 49 +++ .../Table/PasswordAuthenticatorsTable.php | 184 ++++++++++ .../src/Model/Table/PasswordsTable.php | 341 ++++++++++++++++++ .../src/PasswordAuthenticatorPlugin.php | 93 +++++ .../PasswordAuthenticators/fields.inc | 99 +++++ .../templates/Passwords/fields.inc | 48 +++ .../PasswordAuthenticator/tests/bootstrap.php | 55 +++ .../PasswordAuthenticator/tests/schema.sql | 1 + .../PasswordAuthenticator/webroot/.gitkeep | 0 app/composer.json | 4 + app/config/schema/schema.json | 36 +- app/plugins/SshKeyAuthenticator/README.md | 11 + app/plugins/SshKeyAuthenticator/composer.json | 24 ++ .../SshKeyAuthenticator/config/plugin.json | 34 ++ .../SshKeyAuthenticator/phpunit.xml.dist | 30 ++ .../locales/en_US/ssh_key_authenticator.po | 77 ++++ .../src/Controller/AppController.php | 10 + .../SshKeyAuthenticatorsController.php | 40 ++ .../src/Controller/SshKeysController.php | 88 +++++ .../src/Lib/Enum/SshKeyActionEnum.php | 40 ++ .../src/Lib/Enum/SshKeyTypeEnum.php | 44 +++ .../src/Model/Entity/SshKey.php | 74 ++++ .../src/Model/Entity/SshKeyAuthenticator.php | 49 +++ .../Model/Table/SshKeyAuthenticatorsTable.php | 110 ++++++ .../src/Model/Table/SshKeysTable.php | 329 +++++++++++++++++ .../src/SshKeyAuthenticatorPlugin.php | 93 +++++ .../templates/SshKeyAuthenticators/fields.inc | 29 ++ .../templates/SshKeys/add.php | 46 +++ .../templates/SshKeys/columns.inc | 56 +++ .../templates/SshKeys/fields.inc | 60 +++ .../SshKeyAuthenticator/tests/bootstrap.php | 55 +++ .../SshKeyAuthenticator/tests/schema.sql | 1 + .../SshKeyAuthenticator/webroot/.gitkeep | 0 app/resources/locales/en_US/controller.po | 6 + app/resources/locales/en_US/enumeration.po | 16 +- app/resources/locales/en_US/error.po | 9 + app/resources/locales/en_US/field.po | 3 + app/resources/locales/en_US/operation.po | 27 ++ app/resources/locales/en_US/result.po | 21 ++ .../AuthenticatorStatusesController.php | 95 +++++ .../Controller/AuthenticatorsController.php | 221 ++++++++++++ app/src/Controller/DashboardsController.php | 5 + .../MultipleAuthenticatorController.php | 242 +++++++++++++ .../SingleAuthenticatorController.php | 116 ++++++ app/src/Lib/Enum/ActionEnum.php | 4 + app/src/Lib/Enum/AuthenticatorStatusEnum.php | 37 ++ .../Lib/Enum/MessageTemplateContextEnum.php | 2 +- app/src/Lib/Traits/AuthenticatorTrait.php | 95 +++++ app/src/Model/Behavior/ChangelogBehavior.php | 2 +- app/src/Model/Entity/Authenticator.php | 42 +++ app/src/Model/Entity/AuthenticatorStatus.php | 64 ++++ .../Table/AuthenticatorStatusesTable.php | 209 +++++++++++ app/src/Model/Table/AuthenticatorsTable.php | 325 +++++++++++++++++ .../Model/Table/JobHistoryRecordsTable.php | 2 +- app/src/Model/Table/PeopleTable.php | 4 + .../AuthenticatorStatuses/columns.inc | 129 +++++++ app/templates/Authenticators/columns.inc | 59 +++ app/templates/Authenticators/fields.inc | 51 +++ app/templates/People/fields-nav.inc | 14 + app/templates/Standard/index.php | 20 +- app/templates/Standard/manage.php | 92 +++++ 73 files changed, 4543 insertions(+), 12 deletions(-) create mode 100644 app/availableplugins/PasswordAuthenticator/README.md create mode 100644 app/availableplugins/PasswordAuthenticator/composer.json create mode 100644 app/availableplugins/PasswordAuthenticator/config/plugin.json create mode 100644 app/availableplugins/PasswordAuthenticator/phpunit.xml.dist create mode 100644 app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po create mode 100644 app/availableplugins/PasswordAuthenticator/src/Controller/AppController.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Controller/PasswordAuthenticatorsController.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Controller/PasswordsController.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordSourceEnum.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Model/Entity/Password.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Model/Entity/PasswordAuthenticator.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php create mode 100644 app/availableplugins/PasswordAuthenticator/src/PasswordAuthenticatorPlugin.php create mode 100644 app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc create mode 100644 app/availableplugins/PasswordAuthenticator/templates/Passwords/fields.inc create mode 100644 app/availableplugins/PasswordAuthenticator/tests/bootstrap.php create mode 100644 app/availableplugins/PasswordAuthenticator/tests/schema.sql create mode 100644 app/availableplugins/PasswordAuthenticator/webroot/.gitkeep create mode 100644 app/plugins/SshKeyAuthenticator/README.md create mode 100644 app/plugins/SshKeyAuthenticator/composer.json create mode 100644 app/plugins/SshKeyAuthenticator/config/plugin.json create mode 100644 app/plugins/SshKeyAuthenticator/phpunit.xml.dist create mode 100644 app/plugins/SshKeyAuthenticator/resources/locales/en_US/ssh_key_authenticator.po create mode 100644 app/plugins/SshKeyAuthenticator/src/Controller/AppController.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Controller/SshKeyAuthenticatorsController.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Controller/SshKeysController.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyActionEnum.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Lib/Enum/SshKeyTypeEnum.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKey.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Model/Entity/SshKeyAuthenticator.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeyAuthenticatorsTable.php create mode 100644 app/plugins/SshKeyAuthenticator/src/Model/Table/SshKeysTable.php create mode 100644 app/plugins/SshKeyAuthenticator/src/SshKeyAuthenticatorPlugin.php create mode 100644 app/plugins/SshKeyAuthenticator/templates/SshKeyAuthenticators/fields.inc create mode 100644 app/plugins/SshKeyAuthenticator/templates/SshKeys/add.php create mode 100644 app/plugins/SshKeyAuthenticator/templates/SshKeys/columns.inc create mode 100644 app/plugins/SshKeyAuthenticator/templates/SshKeys/fields.inc create mode 100644 app/plugins/SshKeyAuthenticator/tests/bootstrap.php create mode 100644 app/plugins/SshKeyAuthenticator/tests/schema.sql create mode 100644 app/plugins/SshKeyAuthenticator/webroot/.gitkeep create mode 100644 app/src/Controller/AuthenticatorStatusesController.php create mode 100644 app/src/Controller/AuthenticatorsController.php create mode 100644 app/src/Controller/MultipleAuthenticatorController.php create mode 100644 app/src/Controller/SingleAuthenticatorController.php create mode 100644 app/src/Lib/Enum/AuthenticatorStatusEnum.php create mode 100644 app/src/Lib/Traits/AuthenticatorTrait.php create mode 100644 app/src/Model/Entity/Authenticator.php create mode 100644 app/src/Model/Entity/AuthenticatorStatus.php create mode 100644 app/src/Model/Table/AuthenticatorStatusesTable.php create mode 100644 app/src/Model/Table/AuthenticatorsTable.php create mode 100644 app/templates/AuthenticatorStatuses/columns.inc create mode 100644 app/templates/Authenticators/columns.inc create mode 100644 app/templates/Authenticators/fields.inc create mode 100644 app/templates/Standard/manage.php 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 @@ -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(); +?> + +
+
+

+
+ + +
+ + '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();