Random users cannot be created when setting password policy to ‘Enforce numeric characters’

Hello,
I am new to this community and it’s the first time that I’ll address a question here.

Nextcloud version: 18.0.4
Operating system and version: Ubuntu 18.04.4 LTS
Apache2
PHP version: 7.4.7

What I tried to do:
I create Nextcloud users via the “User provisioning API” by sending an HTTP request. For the new Nextcloud users I only specify userid, email and language, but not the password. Because I like the users to get a welcome email and be able to set their own password.

While testing, I noticed that randomly some users could not be created.
For random users, I received the following error.

<ocs>
 <meta>
  <status>failure</status>
  <statuscode>107</statuscode>
  <message>Password needs to contain at least one numeric character.</message>
  <totalitems></totalitems>
  <itemsperpage></itemsperpage>
 </meta>
 <data/>
</ocs>

My password policy settings are as follows:
Capture

Unfortunately, there was no helpful information or error message in my nextcloud.log file.

When I disable ‘Enforce numeric characters’ everything works successfully.
As a consequence, I had a look at apps/password_policy/lib/Generator.php and apps/password_policy/lib/PasswordValidator.php .

What could be the reason for this behaviour?
And where does apps/password_policy/lib/PasswordValidator.php throw an HintException when for example NumericCharacterValidator would fail?

Thank you very much for your help.

class PasswordValidator {

        /** @var IAppContainer */
        private $container;
        /** @var ILogger */
        private $logger;

        public function __construct(IAppContainer $container, ILogger $logger) {
                $this->container = $container;
                $this->logger = $logger;
        }

        /**
         * check if the given password matches the conditions defined by the admin
         *
         * @param string $password
         * @throws HintException
         */
        public function validate(string $password): void {
                $validators = [
                        CommonPasswordsValidator::class,
                        LengthValidator::class,
                        NumericCharacterValidator::class,
                        UpperCaseLoweCaseValidator::class,
                        SpecialCharactersValidator::class,
                        HIBPValidator::class,
                ];

                foreach ($validators as $validator) {
                        try {
                                /** @var IValidator $instance */
                                $instance = $this->container->query($validator);
                        } catch (QueryException $e) {
                                //ignore and continue
                                $this->logger->logException($e, ['level' => ILogger::INFO]);
                                continue;
                        }

                        $instance->validate($password);
                }
        }

}

Additional information /config/config.php :

$CONFIG = array (
  'instanceid' => '********',
  'passwordsalt' => '*****************************************',
  'secret' => *****************************************,
  'trusted_domains' =>
  array (
    0 => ‘MY_IP’,
  ),
  'datadirectory' => '/opt/nextcloud/data',
  'dbtype' => 'mysql',
  'version' => '18.0.4.2',
  'overwrite.cli.url' => 'http://MY_IP/nextcloud',
  'dbname' => 'nextcloud',
  'dbhost' => 'localhost',
  'dbport' => '',
  'dbtableprefix' => 'oc_',
  'dbuser' => 'NEXTCLOUDUSER',
  'dbpassword' => '*********',
  'installed' => true,
  'maintenance' => false,
  'mysql.utf8mb4' => true,
  'memcache.local' => '\\OC\\Memcache\\Redis',
  'logtimezone' => 'Europe/Zurich',
  'enable_previews' => false,
  'skeletondirectory' => '',
  'mail_from_address' => 'EMAIL',
  'mail_smtpmode' => 'smtp',
  'mail_sendmailmode' => 'smtp',
  'mail_domain' => 'DOMAIN',
  'mail_smtphost' => 'MY_IP',
  'theme' => 'ThemeBlue',
  'mail_template_class' => '\\OC\\Mail\\EMailTemplate',
  'mail_smtpport' => '3025',
  'knowledgebaseenabled' => false,
  'allow_user_to_change_display_name' => false,
);

Meanwhile, I tried to find the class which is used to generate the initial password. I used Xdebug and set a breakpoint at PasswordValidator.php line 70).
The password is created in UsersController.php instead of first suspected /apps/password_policy/lib/Generator.php.

The random generator selects 10 characters from ABC…Z…abc…z123…9 set. Then two special characters get randomly selected and added to ensure the password policy ‘Enforce special characters’.

/apps/provisioning_api/lib/Controller/UsersController.php (line324)

...
$password = $this->secureRandom->generate(10);
// Make sure we pass the password_policy
$password .= $this->secureRandom->generate(2, '$!.,;:-~+*[]{}()');
...

But with this code, it is not ensured that always an upper or lower case letter or a numeric value will be selected. In these cases, the password validation from PasswordValidator.php will fail and throw the seen exception.

As a quick fix, I added the following lines in UsersController.php to ensure that the initial random password always passes the password policy rules.

$password = $this->secureRandom->generate(10);
$password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_UPPER);
$password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_LOWER);
$password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_DIGITS);
$password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_SYMBOLS);
namespace OCA\Provisioning_API\Controller;

use OC\Accounts\AccountManager;
use OC\Authentication\Token\RemoteWipe;
use OC\HintException;
use OCA\Provisioning_API\FederatedFileSharingFactory;
use OCA\Settings\Mailer\NewUserMailHelper;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\ILogger;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Security\ISecureRandom;

class UsersController extends AUserData {
...

/**
         * @PasswordConfirmationRequired
         * @NoAdminRequired
         *
         * @param string $userid
         * @param string $password
         * @param string $displayName
         * @param string $email
         * @param array $groups
         * @param array $subadmin
         * @param string $quota
         * @param string $language
         * @return DataResponse
         * @throws OCSException
         */
        public function addUser(string $userid,
                                                        string $password = '',
                                                        string $displayName = '',
                                                        string $email = '',
                                                        array $groups = [],
                                                        array $subadmin = [],
                                                        string $quota = '',
                                                        string $language = ''): DataResponse {
                $user = $this->userSession->getUser();
                $isAdmin = $this->groupManager->isAdmin($user->getUID());
                $subAdminManager = $this->groupManager->getSubAdmin();

                if(empty($userid) && $this->config->getAppValue('core', 'newUser.generateUserID', 'no') === 'yes') {
                        $userid = $this->createNewUserId();
                }

                if ($this->userManager->userExists($userid)) {
                        $this->logger->error('Failed addUser attempt: User already exists.', ['app' => 'ocs_api']);
                        throw new OCSException('User already exists', 102);
                }

                if ($groups !== []) {
                        foreach ($groups as $group) {
                                if (!$this->groupManager->groupExists($group)) {
                                        throw new OCSException('group '.$group.' does not exist', 104);
                                }
                                if (!$isAdmin && !$subAdminManager->isSubAdminOfGroup($user, $this->groupManager->get($group))) {
                                        throw new OCSException('insufficient privileges for group '. $group, 105);
                                }
                        }
                } else {
                        if (!$isAdmin) {
                                throw new OCSException('no group specified (required for subadmins)', 106);
                        }
                }

                $subadminGroups = [];
                if ($subadmin !== []) {
                        foreach ($subadmin as $groupid) {
                                $group = $this->groupManager->get($groupid);
                                // Check if group exists
                                if ($group === null) {
                                        throw new OCSException('Subadmin group does not exist',  102);
                                }
                                // Check if trying to make subadmin of admin group
                                if ($group->getGID() === 'admin') {
                                        throw new OCSException('Cannot create subadmins for admin group', 103);
                                }
                                // Check if has permission to promote subadmins
                                if (!$subAdminManager->isSubAdminOfGroup($user, $group) && !$isAdmin) {
                                        throw new OCSForbiddenException('No permissions to promote subadmins');
                                }
                                $subadminGroups[] = $group;
                        }
                }

                $generatePasswordResetToken = false;
                if ($password === '') {
                        if ($email === '') {
                                throw new OCSException('To send a password link to the user an email address is required.', 108);
                        }

                        //Using Generator difficult because required parameters are not provided here
                        //Make sure to pass password_policy with this easy FIX
                        $password = $this->secureRandom->generate(10);
                        $password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_UPPER);
                        $password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_LOWER);
                        $password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_DIGITS);
                        $password .= $this->secureRandom->generate(1, ISecureRandom::CHAR_SYMBOLS);

                        /* Original
                        $password = $this->secureRandom->generate(10);
                        // Make sure we pass the password_policy
                        $password .= $this->secureRandom->generate(2, '$!.,;:-~+*[]{}()');
                         */
                        $generatePasswordResetToken = true;
                }
...
}