forked from the-djmaze/snappymail
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathLdapMailAccounts.php
532 lines (453 loc) · 17.5 KB
/
LdapMailAccounts.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
<?php
use RainLoop\Enumerations\Capa;
use MailSo\Log\Logger;
use RainLoop\Actions;
use RainLoop\Model\AdditionalAccount;
use RainLoop\Model\MainAccount;
use RainLoop\Providers\Storage\Enumerations\StorageType;
class LdapMailAccounts
{
/** @var resource */
private $ldap;
/** @var bool */
private $ldapAvailable = true;
/** @var bool */
private $ldapConnected = false;
/** @var bool */
private $ldapBound = false;
/** @var LdapMailAccountsConfig */
private $config;
/** @var Logger */
private $logger;
private const LOG_KEY = "LDAP MAIL ACCOUNTS PLUGIN";
/**
* LdapMailAccount constructor.
*
* @param LdapMailAccountsConfig $config LdapMailAccountsConfig object containing the admin configuration for this plugin
* @param Logger $logger Used to write to the logfile
*/
public function __construct(LdapMailAccountsConfig $config, Logger $logger)
{
$this->config = $config;
$this->logger = $logger;
// Check if LDAP is available
if (!extension_loaded('ldap') || !function_exists('ldap_connect')) {
$this->ldapAvailable = false;
$logger->Write("The LDAP extension is not available!", \LOG_WARNING, self::LOG_KEY);
return;
}
$this->Connect();
}
/**
* @inheritDoc
*
* Overwrite the MainAccount mail address by looking up the new one in the ldap directory
*
* The ldap search string has to be configured in the plugin configuration of the extension (in the SnappyMail Admin Panel)
*
* @param string &$sEmail
*/
public function overwriteEmail(&$sEmail)
{
try {
$this->EnsureBound();
} catch (LdapMailAccountsException $e) {
return false; // exceptions are only thrown from the handle error function that does logging already
}
// Try to get account information. IncLogin() returns the username of the user
// and removes the domainname if this was configured inside the domain config.
$username = $sEmail;
$oActions = \RainLoop\Api::Actions();
$oDomain = $oActions->DomainProvider()->Load(\MailSo\Base\Utils::getEmailAddressDomain($sEmail), true);
if ($oDomain->ImapSettings()->shortLogin){
$username = @ldap_escape($this->RemoveEventualDomainPart($sEmail), "", LDAP_ESCAPE_FILTER);
}
$searchString = $this->config->search_string;
// Replace placeholders inside the ldap search string with actual values
$searchString = str_replace("#USERNAME#", $username, $searchString);
$searchString = str_replace("#BASE_DN#", $this->config->base, $searchString);
$this->logger->Write("ldap search string after replacement of placeholders: $searchString", \LOG_NOTICE, self::LOG_KEY);
try {
$mailAddressResults = $this->FindLdapResults(
$this->config->field_search,
$searchString,
$this->config->base,
$this->config->objectclass,
$this->config->field_name,
$this->config->field_username,
$this->config->field_domain,
true,
$this->config->field_mail_address_main_account,
);
}
catch (LdapMailAccountsException $e) {
return false; // exceptions are only thrown from the handle error function that does logging already
}
if (count($mailAddressResults) < 1) {
$this->logger->Write("Could not find user $username in LDAP! Overwriting of main mail address not possible.", \LOG_NOTICE, self::LOG_KEY);
return false;
}
foreach($mailAddressResults as $mailAddressResult)
{
if($mailAddressResult->username === $username) {
//$sImapUser and $sSmtpUser are already set to be the same as $sEmail by function "resolveLoginCredentials" in /RainLoop/Actions/UserAuth.php
//that called this hook, so we just have to overwrite the mail address
$sEmail = $mailAddressResult->mailMainAccount;
}
}
}
/**
* @inheritDoc
*
* Add additional mail accounts to the given primary account by looking up the ldap directory
*
* The ldap search string has to be configured in the plugin configuration of the extension (in the SnappyMail Admin Panel)
*
* @param MainAccount $oAccount
* @return bool true if additional accounts have been added or no additional accounts where found in ldap. false if an error occured
*/
public function AddLdapMailAccounts(MainAccount $oAccount): bool
{
try {
$this->EnsureBound();
} catch (LdapMailAccountsException $e) {
return false; // exceptions are only thrown from the handle error function that does logging already
}
//Basing on https://github.com/the-djmaze/snappymail/issues/616
$oActions = \RainLoop\Api::Actions();
//Check if SnappyMail is configured to allow additional accounts
if (!$oActions->GetCapa(Capa::ADDITIONAL_ACCOUNTS)) {
return $oActions->FalseResponse(__FUNCTION__);
}
// Try to get account information. ImapUser() returns the username of the user
// and removes the domainname if this was configured inside the domain config.
$username = @ldap_escape($oAccount->ImapUser(), "", LDAP_ESCAPE_FILTER);
$searchString = $this->config->search_string;
// Replace placeholders inside the ldap search string with actual values
$searchString = str_replace("#USERNAME#", $username, $searchString);
$searchString = str_replace("#BASE_DN#", $this->config->base, $searchString);
$this->logger->Write("ldap search string after replacement of placeholders: $searchString", \LOG_NOTICE, self::LOG_KEY);
try {
$mailAddressResults = $this->FindLdapResults(
$this->config->field_search,
$searchString,
$this->config->base,
$this->config->objectclass,
$this->config->field_name,
$this->config->field_username,
$this->config->field_domain,
false,
$this->config->field_mail_address_additional_account
);
}
catch (LdapMailAccountsException $e) {
return false; // exceptions are only thrown from the handle error function that does logging already
}
if (count($mailAddressResults) < 1) {
$this->logger->Write("Could not find user $username", \LOG_NOTICE, self::LOG_KEY);
return false;
}
$aAccounts = $oActions->GetAccounts($oAccount);
//Search for accounts with suffix " (LDAP)" at the end of the name that were created by this plugin and initially remove them from the
//account array. This only removes the visibility but does not delete the config done by the user. So if a user looses access to a
//mailbox the user will not see the account anymore but the configuration can be restored when the user regains access to it
foreach($aAccounts as $key => $aAccount)
{
if (preg_match("/\s\(LDAP\)$/", $aAccount['name']))
{
unset($aAccounts[$key]);
}
}
//SnappyMail saves the passwords of the additional accounts by encrypting them using a cryptkey that is saved in the file .cryptkey
//When the password of the main account changes, SnappyMail asks the user for the old password to reencrypt the keys with the new userpassword.
//On a password change using ldap (or when the password has been forgotten by the user) this makes us some problems. Therefore overwrite
//the .cryptkey file in order to always accept the actual ldap password of the user. This has side effects on pgp keys!
//See https://github.com/the-djmaze/snappymail/issues/1570#issuecomment-2085528061
if ($this->config->bool_overwrite_cryptkey) {
if (!$oActions->StorageProvider()->Put($oAccount, StorageType::ROOT, '.cryptkey', "")) {
$this->logger->Write("Could not overwrite the .cryptkey file!", \LOG_WARNING, self::LOG_KEY);
return $oActions->FalseResponse(__FUNCTION__);
}
}
if (count($mailAddressResults) == 1) {
$this->logger->Write("Found only one match for user $username, no additional mail adresses found", \LOG_NOTICE, self::LOG_KEY);
//Write back the accounts even if no additional account was found. This ensures, that previous additional accounts are always removed
$oActions->SetAccounts($oAccount, $aAccounts);
return true;
}
foreach($mailAddressResults as $mailAddressResult)
{
$sUsername = $mailAddressResult->username;
$sDomain = $mailAddressResult->domain;
$sName = $mailAddressResult->name;
$sEmail = $mailAddressResult->mailAdditionalAccount;
//Check if the domain of the found mail address is in the list of configured domains
if ($oActions->DomainProvider()->Load($sDomain, true))
{
//only execute if the found account isn't already in the list of additional accounts
//and if the found account is different from the main account.
//The check if the address is different from the one of the main account when using the Nextcloud integration needs
//to be done twice: directly on the mail address (when Nextcloud is configured to log the user in by mail address)
//or on "$sUsername@$sDomain" for the case Nextcloud logs the user in to SnappyMail by his username and a default domain.
if (!isset($aAccounts[$sEmail]) && $oAccount->Email() !== $sEmail && $oAccount->Email() !== "$sUsername@$sDomain")
{
//Try to login the user with the same password as the primary account has
//if this fails the user will see the new mail addresses but will be asked for the correct password
$sPass = new \SnappyMail\SensitiveString($oAccount->IncPassword());
//After creating the accounts here $sUsername is used as username to login to the IMAP server (see Account.php)
//$oNewAccount = RainLoop\Model\AdditionalAccount::NewInstanceFromCredentials($oActions, $sEmail, $sUsername, $sPass);
$oDomain = $oActions->DomainProvider()->Load($sDomain, false);
$oNewAccount = new AdditionalAccount;
$oNewAccount->setCredentials(
$oDomain,
$sEmail,
$sUsername,
$sPass,
$sUsername
);
$aAccounts[$sEmail] = $oNewAccount->asTokenArray($oAccount);
}
//Always inject/update the found mailbox names into the array (also if the mailbox already existed)
if (isset($aAccounts[$sEmail]))
{
$aAccounts[$sEmail]['name'] = $sName . " (LDAP)";
}
}
else {
$this->logger->Write("Domain $sDomain is not part of configured domains in SnappyMail Admin Panel - mail address $sEmail will not be added.", \LOG_NOTICE, self::LOG_KEY);
}
}
if ($aAccounts)
{
$oActions->SetAccounts($oAccount, $aAccounts);
return true;
}
return false;
}
/**
* Checks if a connection to the LDAP was possible
*
* @throws LdapMailAccountsException
*
* */
private function EnsureConnected(): void
{
if ($this->ldapConnected) return;
$res = $this->Connect();
if (!$res)
$this->HandleLdapError("Connect");
}
/**
* Connect to the LDAP using the server address and protocol version defined inside the configuration of the plugin
*/
private function Connect(): bool
{
// Set up connection
$ldap = @ldap_connect($this->config->server);
if ($ldap === false) {
$this->ldapAvailable = false;
return false;
}
// Set protocol version
$option = @ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, $this->config->protocol);
if (!$option) {
$this->ldapAvailable = false;
return false;
}
$this->ldap = $ldap;
$this->ldapConnected = true;
return true;
}
/**
* Ensures the plugin has been authenticated at the LDAP
*
* @throws LdapMailAccountsException
*
* */
private function EnsureBound(): void
{
if ($this->ldapBound) return;
$this->EnsureConnected();
$res = $this->Bind();
if (!$res)
$this->HandleLdapError("Bind");
}
/**
* Authenticates the plugin at the LDAP using the username and password defined inside the configuration of the plugin
*
* @return bool true if authentication was successful
*/
private function Bind(): bool
{
// Bind to LDAP here
$bindResult = @ldap_bind($this->ldap, $this->config->bind_user, $this->config->bind_password);
if (!$bindResult) {
$this->ldapAvailable = false;
return false;
}
$this->ldapBound = true;
return true;
}
/**
* Handles and logs an eventual LDAP error
*
* @param string $op
* @throws LdapMailAccountsException
*/
private function HandleLdapError(string $op = ""): void
{
// Obtain LDAP error and write logs
$errorNo = @ldap_errno($this->ldap);
$errorMsg = @ldap_error($this->ldap);
$message = empty($op) ? "LDAP Error: {$errorMsg} ({$errorNo})" : "LDAP Error during {$op}: {$errorMsg} ({$errorNo})";
$this->logger->Write($message, \LOG_ERR, self::LOG_KEY);
throw new LdapMailAccountsException($message, $errorNo);
}
/**
* Looks up the LDAP for additional mail accounts
*
* The search for additional mail accounts is done by a ldap search using the defined fields inside the configuration of the plugin (SnappyMail Admin Panel)
*
* @param string $searchField
* @param string $searchString
* @param string $searchBase
* @param string $objectClass
* @param string $nameField
* @param string $usernameField
* @param string $domainField
* @param bool $overwriteMailMainAccount true if the mail address of the main account should be looked up for overwriting. false if additional mail accounts should be searched
* @param string $mailAddressField The field containing the mail address (of main account or additional mail account)
* @return LdapMailAccountResult[]
* @throws LdapMailAccountsException
*/
private function FindLdapResults(
string $searchField,
string $searchString,
string $searchBase,
string $objectClass,
string $nameField,
string $usernameField,
string $domainField,
bool $overwriteMailMainAccount,
string $mailAddressField): array
{
$this->EnsureBound();
$nameField = strtolower($nameField);
$usernameField = strtolower($usernameField);
$domainField = strtolower($domainField);
$filter = "(&(objectclass=$objectClass)($searchField=$searchString))";
$this->logger->Write("Used ldap filter to search for mail account(s): $filter", \LOG_NOTICE, self::LOG_KEY);
//Set together the attributes to search inside the LDAP
$ldapAttributes = ['dn', $usernameField, $nameField, $domainField, $mailAddressField];
$ldapResult = @ldap_search($this->ldap, $searchBase, $filter, $ldapAttributes);
if (!$ldapResult) {
$this->HandleLdapError("Fetch $objectClass");
return [];
}
$entries = @ldap_get_entries($this->ldap, $ldapResult);
if (!$entries) {
$this->HandleLdapError("Fetch $objectClass");
return [];
}
// Save the found ldap entries into a LdapMailAccountResult object and return them
$results = [];
for ($i = 0; $i < $entries["count"]; $i++) {
$entry = $entries[$i];
$result = new LdapMailAccountResult();
$result->dn = $entry["dn"];
$result->name = $this->LdapGetAttribute($entry, $nameField, true, true);
$result->username = $this->LdapGetAttribute($entry, $usernameField, true, true);
$result->username = $this->RemoveEventualDomainPart($result->username);
$result->domain = $this->LdapGetAttribute($entry, $domainField, true, true);
$result->domain = $this->RemoveEventualLocalPart($result->domain);
if($overwriteMailMainAccount) {
$result->mailMainAccount = $this->LdapGetAttribute($entry, $mailAddressField, true, true);
}
else {
$result->mailAdditionalAccount = $this->LdapGetAttribute($entry, $mailAddressField, true, true);
}
$results[] = $result;
}
return $results;
}
/**
* Removes an eventually found domain-part of an email address
*
* If the input string contains an '@' character the function returns the local-part before the '@'\
* If no '@' character can be found the input string is returned.
*
* @param string $sInput
* @return string
*/
public static function RemoveEventualDomainPart(string $sInput) : string
{
// Copy of \MailSo\Base\Utils::getEmailAddressLocalPart to make sure that also after eventual future
// updates the input string gets returned when no '@' is found (getEmailAddressDomain already doesn't do this)
$sResult = '';
if (\strlen($sInput))
{
$iPos = \strrpos($sInput, '@');
$sResult = (false === $iPos) ? $sInput : \substr($sInput, 0, $iPos);
}
return $sResult;
}
/**
* Removes an eventually found local-part of an email address
*
* If the input string contains an '@' character the function returns the domain-part behind the '@'\
* If no '@' character can be found the input string is returned.
*
* @param string $sInput
* @return string
*/
public static function RemoveEventualLocalPart(string $sInput) : string
{
$sResult = '';
if (\strlen($sInput))
{
$iPos = \strrpos($sInput, '@');
$sResult = (false === $iPos) ? $sInput : \substr($sInput, $iPos + 1);
}
return $sResult;
}
/**
* Gets LDAP attributes out of the input array
*
* @param array $entry Array containing the result of a ldap search
* @param string $attribute The name of the attribute to return
* @param bool $single If true the function checks if exact one value for this attribute is inside the input array. If false an array is returned. Default true.
* @param bool $required If true the attribute has to exist inside the input array. Default false.
* @return string|string[]
*/
private function LdapGetAttribute(array $entry, string $attribute, bool $single = true, bool $required = false)
{
if (!isset($entry[$attribute])) {
if ($required)
$this->logger->Write("Attribute $attribute not found on object {$entry['dn']} while required", \LOG_NOTICE, self::LOG_KEY);
return $single ? "" : [];
}
if ($single) {
if ($entry[$attribute]["count"] > 1)
$this->logger->Write("Attribute $attribute is multivalues while only a single value is expected", \LOG_NOTICE, self::LOG_KEY);
return $entry[$attribute][0];
}
$result = $entry[$attribute];
unset($result["count"]);
return array_values($result);
}
}
class LdapMailAccountResult
{
/** @var string */
public $dn;
/** @var string */
public $name;
/** @var string */
public $username;
/** @var string */
public $domain;
/** @var string */
public $mailMainAccount;
/** @var string */
public $mailAdditionalAccount;
}