From 02d99296e52cddf390865cf389e3863dbf719bd6 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sat, 28 Mar 2026 19:46:37 -0400 Subject: [PATCH] Pass Through Provisioning (CFM-463) --- .../locales/en_US/kerberos_connector.po | 3 + .../Model/Table/KerberosProvisionersTable.php | 34 +++- .../PasswordAuthenticator/config/plugin.json | 4 +- .../locales/en_US/password_authenticator.po | 43 +++-- .../src/Lib/Enum/PasswordEncodingEnum.php | 1 + .../Table/PasswordAuthenticatorsTable.php | 28 ++- .../src/Model/Table/PasswordsTable.php | 180 ++++++++++++++---- .../PasswordAuthenticators/fields.inc | 34 +++- app/config/schema/schema.json | 1 + app/resources/locales/en_US/error.po | 3 + app/resources/locales/en_US/field.po | 3 + .../SingleAuthenticatorController.php | 28 ++- app/src/Lib/Traits/ProvisionableTrait.php | 25 ++- app/src/Model/Table/AuthenticatorsTable.php | 5 + app/src/Model/Table/PeopleTable.php | 50 +++-- app/templates/Authenticators/fields.inc | 1 + 16 files changed, 370 insertions(+), 73 deletions(-) diff --git a/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po index b4b0a2455..6ea378430 100644 --- a/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po +++ b/app/availableplugins/KerberosConnector/resources/locales/en_US/kerberos_connector.po @@ -92,5 +92,8 @@ msgid "result.pwexpired" # Note we swap the rendering order to make it more obvious, but the _parameter_ order is unchanged msgstr "Password expired {1}, Principal active (expires: {0})" +msgid "result.unlocked-p" +msgstr "Principal {0} is unlocked" + msgid "result.synced" msgstr "Synced existing principal {0}" diff --git a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php index 5dbff5d7d..0adebfbc1 100644 --- a/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php +++ b/app/availableplugins/KerberosConnector/src/Model/Table/KerberosProvisionersTable.php @@ -196,12 +196,19 @@ public function provision( if(!empty($password)) { $action = 'update'; } else { - // If we don't find a Password we lock the principal. We will typically get here - // if the Authenticator is Locked (Person is Active but Password is Locked), so we - // don't need to check the Authenticator Status specifically. There may be other - // edge cases that get us here as well. + // If Pass Through Provisioning is enabled, we may have various scenarios where + // we will get a blank password for an Eligible Person, including reprovisioning + // or Authenticator lock/unlock. We'll need to look at the Authenticator Status + // for more information, but we'll default to locking. $action = 'lock'; + + // There should be at least one entry with an authenticator status + + if(!empty($data->passwords[0]->authenticator_status) + && !$data->passwords[0]->authenticator_status->locked) { + $action = 'unlock'; + } } } elseif($eligibility == ProvisioningEligibilityEnum::Ineligible) { // Check to see if the principal exists in the KDC, and if so lock it @@ -250,6 +257,25 @@ public function provision( 'comment' => __d('kerberos_connector', 'result.locked-p', [$principal]), 'identifier' => $principal ]; + } elseif($action == 'unlock') { + // We only end up here if Pass Through Provisioning is enabled, in which case + // we have an authenticator with no Password (but presumably a disabled password + // in the KDC). + + $attributes = $curprinc->getAttributes(); + + if($attributes & 64) { + // Remove the locked bit + $curprinc->setAttributes($curprinc->getAttributes() ^ 64); + $curprinc->save(); + } + // else the principal is already unlocked + + return [ + 'status' => ProvisioningStatusEnum::Provisioned, + 'comment' => __d('kerberos_connector', 'result.unlocked-p', [$principal]), + 'identifier' => $principal + ]; } elseif($action == 'update') { // Make sure we aren't currently DISALLOWING_ALL_TIX -- if we are clear the flag. diff --git a/app/availableplugins/PasswordAuthenticator/config/plugin.json b/app/availableplugins/PasswordAuthenticator/config/plugin.json index 3d4ec4188..679af58e3 100644 --- a/app/availableplugins/PasswordAuthenticator/config/plugin.json +++ b/app/availableplugins/PasswordAuthenticator/config/plugin.json @@ -15,7 +15,9 @@ "max_length": { "type": "integer" }, "format_crypt_php": { "type": "boolean" }, "format_plaintext": { "type": "boolean" }, - "format_sha1_ldap": { "type": "boolean" } + "format_sha1_ldap": { "type": "boolean" }, + "prevent_reuse": { "type": "boolean" }, + "use_hard_delete": { "type": "boolean" } }, "indexes": { "password_authenticators_i1": { "columns": [ "authenticator_id" ]} diff --git a/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po index 04195b043..843b681b8 100644 --- a/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po +++ b/app/availableplugins/PasswordAuthenticator/resources/locales/en_US/password_authenticator.po @@ -34,6 +34,9 @@ msgstr "Crypt" msgid "enumeration.PasswordEncodingEnum.EX" msgstr "External" +msgid "enumeration.PasswordEncodingEnum.MT" +msgstr "Empty" + msgid "enumeration.PasswordEncodingEnum.NO" msgstr "Plain" @@ -61,20 +64,8 @@ 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 "error.Passwords.reuse" +msgstr "Password was previously used, please pick a new one" msgid "field.PasswordAuthenticators.format_crypt_php" msgstr "Store as Crypt" @@ -94,6 +85,30 @@ 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.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.prevent_reuse" +msgstr "Prevent Password Reuse" + +msgid "field.PasswordAuthenticators.prevent_reuse.desc" +msgstr "Require a new Password to be different than any prior Password (requires Store as Crypt, cannot be used with Hard Delete)" + +msgid "field.PasswordAuthenticators.source_mode" +msgstr "Password Source" + +msgid "field.PasswordAuthenticators.use_hard_delete" +msgstr "Use Hard Delete" + msgid "field.Passwords.password2" msgstr "Password (Again)" diff --git a/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php index a07d3cdc9..dc731c2bf 100644 --- a/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php +++ b/app/availableplugins/PasswordAuthenticator/src/Lib/Enum/PasswordEncodingEnum.php @@ -33,6 +33,7 @@ class PasswordEncodingEnum extends StandardEnum { const Crypt = 'CR'; // Crypt/bcrypt/etc as implemented by php's password_hash + const Empty = 'MT'; // "Empty" type used to track mod time when PTP enabled const External = 'EX'; // Externally defined (ie: managed outside of Registry) const Plain = 'NO'; // Not hashed const SSHA = 'SH'; // Salted SHA 1 as intended for LDAP diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php index 0995e7c02..158f0cf03 100644 --- a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordAuthenticatorsTable.php @@ -114,9 +114,16 @@ public function initialize(array $config): void { 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 + // must be stored in PHP Crypt format, unless the Authenticator is configured in Pass + // Through Provisioning mode. - if(!empty($data['source_mode']) && $data['source_mode'] == PasswordSourceEnum::SelfSelect) { + $authenticator = $this->Authenticators->get($data['authenticator_id']); + + if($authenticator->enable_ptp) { + $data['format_crypt_php'] = false; + $data['format_sha1_ldap'] = false; + $data['format_plaintext'] = false; + } elseif(!empty($data['source_mode']) && $data['source_mode'] == PasswordSourceEnum::SelfSelect) { $data['format_crypt_php'] = true; } } @@ -134,6 +141,13 @@ public function marshalProvisioningData( \App\Model\Entity\Authenticator $cfg, int $personId ): array { + // If the Authenticator is in Pass Through Provisioning mode, do not pull any existing + // records from the database. + + if($cfg->enable_ptp) { + return []; + } + // Retrieve any Passwords associated with this Person and the requested configuration. // We'll include all available Password types (encodings) since we don't know which types // any specific Provisioner will be interested in. @@ -207,6 +221,16 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('format_sha1_ldap'); + $validator->add('prevent_reuse', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('prevent_reuse'); + + $validator->add('use_hard_delete', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('use_hard_delete'); + return $validator; } } diff --git a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php index e5217e727..a56dbf7ec 100644 --- a/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php +++ b/app/availableplugins/PasswordAuthenticator/src/Model/Table/PasswordsTable.php @@ -51,6 +51,7 @@ class PasswordsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\UpsertTrait; use \App\Lib\Traits\ValidationTrait; /** @@ -103,6 +104,7 @@ public function initialize(array $config): void { * @param Authenticator $cfg Authenticator configuration * @param int $personId Person ID * @param array $data Array of data from fields.inc + * @throws InvalidArgumentException */ public function manage( @@ -110,26 +112,8 @@ public function manage( 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')); - } - } + // Run password validation checks and let any Exceptions bubble up + $this->validateRequest($cfg, $data); // 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 @@ -142,14 +126,19 @@ public function manage( // 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(); + $passwords = $this->find('all', archived: $cfg->password_authenticator->use_hard_delete) + ->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId + ]) + ->all(); foreach($passwords as $password) { - $this->delete($password); + // Note this is a changelog "soft" delete, meaning the old Password is + // kept as an archive. This would allow us to eventually support Password + // policies (eg to prevent reuse). + + $this->delete($password, ['useHardDelete' => $cfg->password_authenticator->use_hard_delete]); } // We'll store one entry per hashing type. We always store CRYPT @@ -161,7 +150,7 @@ public function manage( $pdata = null; - if(true || $cfg->password_authenticator->format_crypt_php) { + if($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. @@ -228,6 +217,78 @@ public function manage( } } + /** + * Handle an Authenticator update from a manage() request, but process as a + * Pass Through 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 + * @return array Array of data in the same format as marshalProvisioningData() + * @throws InvalidArgumentException + */ + + public function process( + Authenticator $cfg, + int $personId, + array $data + ): array { + // For Pass Through Mode, we need to do a little extra record keeping. + + // First lun password validation checks and let any Exceptions bubble up. + $this->validateRequest($cfg, $data); + + // Like for manage(), delete any previously existing Passwords. This will catch + // the scenario where the configuration was switched from normal provisioning to PTP. + // Unlike manage(), we will perform a hard delete. (Note we don't delete the password + // of type "Empty", which is our placeholder to track time of last update.) + + // Because we're using a hard delete we need to pull archived records as well. + $passwords = $this->find('all', archived: true)->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'type <>' => PasswordEncodingEnum::Empty + ]) + ->all(); + + foreach($passwords as $password) { + $this->delete($password, ['useHardDelete' => true]); + } + + // Upsert an Empty password so we have a timestamp of last Password changes. + // Upsert will also allow us to recreate a history of Password changes via changelog. + // Because we haven't actually returned data to pass through to the Provisioners yet, + // if this fails we'll fail the whole request. + + $this->upsertOrFail( + data: [ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'password' => "*", + 'type' => PasswordEncodingEnum::Empty + ], + whereClause: [ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'type' => PasswordEncodingEnum::Empty + ] + ); + + // Finally convert the submitted password into a new entity, in plaintext format + // (since presumably the configured Provisioner needs to know what the password + // is in order to do something with it). + + $entity = $this->newEntity([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $personId, + 'password' => $data['password'], + 'type' => PasswordEncodingEnum::Plain + ]); + + return [$entity]; + } + /** * Reset a Password for a Person, * @@ -239,7 +300,7 @@ public function manage( public function reset( Authenticator $cfg, int $personId - ): void{ + ): void { // We simply delete all Passwords for $personId, if any $cxn = $this->getConnection(); @@ -279,8 +340,6 @@ public function reset( */ 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, @@ -288,8 +347,8 @@ public function status(Authenticator $cfg, int $personId): array { ]) ->first(); - // We don't know which password type we have, but they should all have the - // same mod time + // We don't know which password type we have (unless PTP is enabled, in which + // case the type is "Empty"), but they should all have the same mod time if(!empty($pwd->modified)) { return [ @@ -306,6 +365,63 @@ public function status(Authenticator $cfg, int $personId): array { ]; } + /** + * Validate a password change request according to the configuration. + * + * @since COmanage Registry v5.2.0 + * @param Authenticator $cfg Authenticator configuration + * @param array $data Array of data from fields.inc + * @throws InvalidArgumentException + */ + + protected function validateRequest( + Authenticator $cfg, + array $data + ) { + // Perform sanity checks on Self Selected passwords only + if($cfg->password_authenticator->source_mode == PasswordSourceEnum::SelfSelect) { + $minlen = $cfg->password_authenticator->min_length ?: 8; + $maxlen = $cfg->password_authenticator->max_length ?: 64; + + // 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')); + } + } + + // If Password Reuse Prevention is enabled (and we're not using hard delete or PTP) + // check the Password against the previous ones + if($cfg->password_authenticator->prevent_reuse + && $cfg->password_authenticator->format_crypt_php + && !$cfg->password_authenticator->use_hard_delete + && !$cfg->enable_ptp) { + $passwords = $this->find('all', archived: true) + ->where([ + 'password_authenticator_id' => $cfg->password_authenticator->id, + 'person_id' => $data['person_id'], + 'type' => PasswordEncodingEnum::Crypt + ]) + ->all(); + + foreach($passwords as $p) { + if(password_verify($data['password'], $p->password)) { + // The passwords match, throw a validation error + throw new \InvalidArgumentException(__d('password_authenticator', 'error.Passwords.reuse')); + } + } + } + } + /** * Set validation rules. * diff --git a/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc index 59452354b..b7351c29a 100644 --- a/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc +++ b/app/availableplugins/PasswordAuthenticator/templates/PasswordAuthenticators/fields.inc @@ -31,7 +31,9 @@ $fields = [ 'max_length', 'format_crypt_php', 'format_sha1_ldap', - 'format_plaintext' + 'format_plaintext', + 'prevent_reuse', + 'use_hard_delete' ]; $subnav = [ @@ -40,8 +42,10 @@ $subnav = [ 'Authenticators' => ['edit'], 'PasswordAuthenticator.PasswordAuthenticators' => ['edit'] ] -] - +]; + +// Determine if Pass Through Provisioning is enabled +$ptp = $vv_bc_parent_obj?->enable_ptp ? "PTP" : "NO"; ?>