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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 100 additions & 18 deletions lib/Db/Provisioning.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,24 @@ class Provisioning extends Entity implements JsonSerializable {
public const WILDCARD = '*';
public const MASTER_PASSWORD_PLACEHOLDER = '********';

/**
* Captured names reach the directory as the requested attribute of an LDAP read,
* so the class stays too narrow for any LDAP special character to pass.
*/
private const LDAP_PLACEHOLDER_PATTERN = '%LDAP:([A-Za-z][A-Za-z0-9-]*)%';
private const LDAP_PLACEHOLDER_REGEX = '/' . self::LDAP_PLACEHOLDER_PATTERN . '/';
private const LDAP_PLACEHOLDER_ANCHORED_REGEX = '/^' . self::LDAP_PLACEHOLDER_PATTERN . '$/D';

/** Anything shaped like an LDAP placeholder, including unsupported syntax */
private const LDAP_PLACEHOLDER_LOOSE_REGEX = '/%ldap:[^%]*%/i';

/**
* '%' opens and closes a placeholder, so replacing the token types one after
* another lets adjacent placeholders consume each other's delimiter. All of
* them therefore have to be substituted in a single pass.
*/
private const PLACEHOLDER_REGEX = '/%USERID%|%EMAIL%|' . self::LDAP_PLACEHOLDER_PATTERN . '/';

protected $provisioningDomain;
protected $emailTemplate;
protected $imapUser;
Expand Down Expand Up @@ -120,57 +138,121 @@ public function jsonSerialize() {
}

/**
* @param IUser $user
* @param array<string, string> $ldapValues resolved %LDAP:attr% tokens, keyed by full token
* @return string
*/
public function buildImapUser(IUser $user) {
public function buildImapUser(IUser $user, array $ldapValues = []) {
if (!is_null($this->getImapUser())) {
return $this->buildUserEmail($this->getImapUser(), $user);
return $this->buildUserEmail($this->getImapUser(), $user, $ldapValues);
}
return $this->buildEmail($user);
return $this->buildEmail($user, $ldapValues);
}

/**
* @param IUser $user
* @param array<string, string> $ldapValues resolved %LDAP:attr% tokens, keyed by full token
* @return string
*/
public function buildEmail(IUser $user) {
return $this->buildUserEmail($this->getEmailTemplate(), $user);
public function buildEmail(IUser $user, array $ldapValues = []) {
return $this->buildUserEmail($this->getEmailTemplate(), $user, $ldapValues);
}

/**
* Unique attribute names referenced via %LDAP:attr%, spelled as in the templates.
* The sieve template only counts while sieve is enabled, as its account settings
* are not built otherwise.
*
* @return string[]
*/
public function ldapAttributesInTemplates(): array {
$attributes = [];
$templates = [
$this->getEmailTemplate(),
$this->getImapUser(),
$this->getSmtpUser(),
];
if ($this->getSieveEnabled()) {
$templates[] = $this->getSieveUser();
}
foreach ($templates as $template) {
if ($template === null) {
continue;
}
if (preg_match_all(self::LDAP_PLACEHOLDER_REGEX, $template, $matches) > 0) {
$attributes = array_merge($attributes, $matches[1]);
}
}
return array_values(array_unique($attributes));
}

/**
* Placeholders using unsupported syntax, e.g. a lowercase prefix or an attribute
* option. They would never be substituted and end up literally in an account.
*
* @return string[]
*/
public static function findMalformedLdapPlaceholders(?string $template): array {
if ($template === null || preg_match_all(self::LDAP_PLACEHOLDER_LOOSE_REGEX, $template, $matches) === 0) {
return [];
}
$malformed = [];
foreach ($matches[0] as $candidate) {
if (preg_match(self::LDAP_PLACEHOLDER_ANCHORED_REGEX, $candidate) !== 1) {
$malformed[] = $candidate;
}
}
return array_values(array_unique($malformed));
}

/**
* Replace %USERID% and %EMAIL% to allow special configurations
* Replace %USERID%, %EMAIL% and %LDAP:attr% to allow special configurations.
* Tokens without a value stay literal.
*
* @param string $original
* @param IUser $user
* @param array<string, string> $ldapValues resolved %LDAP:attr% tokens, keyed by full token
* @return string
*/
private function buildUserEmail(string $original, IUser $user) {
$original = str_replace('%USERID%', $user->getUID(), $original);
if ($user->getEMailAddress() !== null) {
$original = str_replace('%EMAIL%', $user->getEMailAddress(), $original);
}
return $original;
private function buildUserEmail(string $original, IUser $user, array $ldapValues = []) {
$replaced = preg_replace_callback(
self::PLACEHOLDER_REGEX,
static function (array $match) use ($user, $ldapValues): string {
switch ($match[0]) {
case '%USERID%':
return $user->getUID();
case '%EMAIL%':
return $user->getEMailAddress() ?? $match[0];
default:
return $ldapValues[$match[0]] ?? $match[0];
}
},
$original
);
return $replaced ?? $original;
}

/**
* @param IUser $user
* @param array<string, string> $ldapValues resolved %LDAP:attr% tokens, keyed by full token
* @return string
*/
public function buildSmtpUser(IUser $user) {
public function buildSmtpUser(IUser $user, array $ldapValues = []) {
if (!is_null($this->getSmtpUser())) {
return $this->buildUserEmail($this->getSmtpUser(), $user);
return $this->buildUserEmail($this->getSmtpUser(), $user, $ldapValues);
}
return $this->buildEmail($user);
return $this->buildEmail($user, $ldapValues);
}

/**
* @param IUser $user
* @param array<string, string> $ldapValues resolved %LDAP:attr% tokens, keyed by full token
* @return string
*/
public function buildSieveUser(IUser $user) {
public function buildSieveUser(IUser $user, array $ldapValues = []) {
if (!is_null($this->getSieveUser())) {
return $this->buildUserEmail($this->getSieveUser(), $user);
return $this->buildUserEmail($this->getSieveUser(), $user, $ldapValues);
}
return $this->buildEmail($user);
return $this->buildEmail($user, $ldapValues);
}
}
6 changes: 6 additions & 0 deletions lib/Db/ProvisioningMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public function validate(array $data): Provisioning {
$exception->setField('smtpSslMode', false);
}

foreach (['emailTemplate', 'imapUser', 'smtpUser', 'sieveUser'] as $templateField) {
if (Provisioning::findMalformedLdapPlaceholders($data[$templateField] ?? null) !== []) {
$exception->setField($templateField, false);
}
}

$ldapAliasesProvisioning = (bool)($data['ldapAliasesProvisioning'] ?? false);
$ldapAliasesAttribute = $data['ldapAliasesAttribute'] ?? '';

Expand Down
87 changes: 80 additions & 7 deletions lib/Service/Provisioning/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,18 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool {
return false;
}

$ldapValues = $this->resolveLdapPlaceholders($provisioning, $user);
if ($ldapValues === null) {
// Persisting the raw templates would break an already working account
return false;
}

try {
// TODO: match by UID only, catch multiple objects returned below and delete all those accounts
$mailAccount = $this->mailAccountMapper->findProvisionedAccount($user);

$mailAccount = $this->mailAccountMapper->update(
$this->updateAccount($user, $mailAccount, $provisioning)
$this->updateAccount($user, $mailAccount, $provisioning, $ldapValues)
);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
if ($e instanceof MultipleObjectsReturnedException) {
Expand All @@ -226,7 +232,7 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool {
$mailAccount->setUserId($user->getUID());
$mailAccount->setClassificationEnabled($this->classificationSettingsService->isClassificationEnabledByDefault());
$mailAccount = $this->mailAccountMapper->insert(
$this->updateAccount($user, $mailAccount, $provisioning)
$this->updateAccount($user, $mailAccount, $provisioning, $ldapValues)
);

$this->accountService->scheduleBackgroundJobs($mailAccount->getId());
Expand Down Expand Up @@ -283,24 +289,91 @@ public function updateProvisioning(array $data): void {
}
}

private function updateAccount(IUser $user, MailAccount $account, Provisioning $config): MailAccount {
/**
* Resolved values become login names and the account email address. Commas and
* angle brackets would let the directory value break out of the templated domain,
* spaces and control characters yield a login that can never authenticate, and 64
* is the narrowest account column.
*/
private const LDAP_VALUE_REGEX = '/^[A-Za-z0-9._+@-]{1,64}$/D';

/**
* Resolve the %LDAP:attr% placeholders of a config for a user.
*
* @return array<string, string>|null the token => value map, or null if a
* placeholder could not be resolved
*/
private function resolveLdapPlaceholders(Provisioning $config, IUser $user): ?array {
$attributes = $config->ldapAttributesInTemplates();
if ($attributes === []) {
return [];
}

if ($user->getBackendClassName() !== 'LDAP') {
// Expected in mixed setups, where a wildcard config also matches local users
$this->logger->debug('Provisioning config uses LDAP placeholders but user ' . $user->getUID() . ' is not an LDAP user');
return null;
}

if ($this->ldapProviderFactory->isAvailable() === false) {
$this->logger->warning('Provisioning config uses LDAP placeholders but LDAP is not available');
return null;
}

try {
$provider = $this->ldapProviderFactory->getLDAPProvider();
} catch (\Throwable $e) {
$this->logger->warning('LDAP provider unavailable for mail provisioning placeholders', ['exception' => $e]);
return null;
}

$fetched = [];
$values = [];
foreach ($attributes as $attribute) {
$key = strtolower($attribute);
if (!array_key_exists($key, $fetched)) {
try {
$fetched[$key] = $provider->getUserAttribute($user->getUID(), $attribute);
} catch (\Throwable $e) {
$this->logger->warning('Could not read LDAP attribute ' . $attribute . ' of user ' . $user->getUID() . ', skipping provisioning', ['exception' => $e]);
return null;
}
}
$value = $fetched[$key];
if ($value === null || $value === '') {
$this->logger->warning('LDAP attribute ' . $attribute . ' of user ' . $user->getUID() . ' is empty, skipping provisioning');
return null;
}
if (preg_match(self::LDAP_VALUE_REGEX, $value) !== 1) {
$this->logger->warning('LDAP attribute ' . $attribute . ' of user ' . $user->getUID() . ' contains unsupported characters or is too long, skipping provisioning');
return null;
}
$values['%LDAP:' . $attribute . '%'] = $value;
}
return $values;
}

/**
* @param array<string, string> $ldapValues
*/
private function updateAccount(IUser $user, MailAccount $account, Provisioning $config, array $ldapValues): MailAccount {
// Set the ID to make sure it reflects when the account switches from one config to another
$account->setProvisioningId($config->getId());

$account->setEmail($config->buildEmail($user));
$account->setEmail($config->buildEmail($user, $ldapValues));
$account->setName($this->userManager->getDisplayName($user->getUID()));
$account->setInboundUser($config->buildImapUser($user));
$account->setInboundUser($config->buildImapUser($user, $ldapValues));
$account->setInboundHost($config->getImapHost());
$account->setInboundPort($config->getImapPort());
$account->setInboundSslMode($config->getImapSslMode());
$account->setOutboundUser($config->buildSmtpUser($user));
$account->setOutboundUser($config->buildSmtpUser($user, $ldapValues));
$account->setOutboundHost($config->getSmtpHost());
$account->setOutboundPort($config->getSmtpPort());
$account->setOutboundSslMode($config->getSmtpSslMode());
$account->setSieveEnabled($config->getSieveEnabled());

if ($config->getSieveEnabled()) {
$account->setSieveUser($config->buildSieveUser($user));
$account->setSieveUser($config->buildSieveUser($user, $ldapValues));
$account->setSieveHost($config->getSieveHost());
$account->setSievePort($config->getSievePort());
$account->setSieveSslMode($config->getSieveSslMode());
Expand Down
6 changes: 3 additions & 3 deletions src/components/settings/ProvisioningSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</div>
<div class="group-inputs">
<br>
<label :for="'mail-provision-domain' + setting.id"> {{ t('mail', 'Provisioning domain') }}* </label>
<label :for="'mail-provision-domain' + setting.id"> {{ t('mail', 'Provisioning domain') }} </label>
<br>
<input
:id="'mail-provision-domain' + setting.id"
Expand Down Expand Up @@ -309,7 +309,7 @@
</div>
<div>
<label :for="'mail-provision-ldap-aliases-attribute' + setting.id">
{{ t('mail', 'LDAP attribute for aliases') }}*
{{ t('mail', 'LDAP attribute for aliases') }}
<br>
<input
:id="'mail-provision-ldap-aliases-attribute' + setting.id"
Expand Down Expand Up @@ -350,7 +350,7 @@
</ButtonVue>
<br>
<small>{{
t('mail', '* %USERID% and %EMAIL% will be replaced with the user\'s UID and email')
t('mail', '* %USERID% and %EMAIL% will be replaced with the user\'s UID and email. {ldapPlaceholder} will be replaced with the value of that LDAP attribute', { ldapPlaceholder: '%LDAP:sAMAccountName%' })
}}</small>
</div>
</div>
Expand Down
18 changes: 18 additions & 0 deletions tests/Integration/Db/ProvisioningMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,24 @@ public function testValidateException() {
$provisioning = $this->mapper->validate($data);
}

public function testValidateRejectsMalformedLdapPlaceholder() {
$data = $this->data;
$data['emailTemplate'] = '%ldap:uid%@heart-of-gold.com';

$this->expectException(ValidationException::class);

$this->mapper->validate($data);
}

public function testValidateAcceptsLdapPlaceholder() {
$data = $this->data;
$data['emailTemplate'] = '%LDAP:sAMAccountName%@heart-of-gold.com';

$provisioning = $this->mapper->validate($data);

$this->assertSame('%LDAP:sAMAccountName%@heart-of-gold.com', $provisioning->getEmailTemplate());
}

public function testGetNoResult() {
$db = $this->mapper->get(99999);
$this->assertNull($db);
Expand Down
Loading
Loading