diff --git a/lam/HISTORY b/lam/HISTORY index 033852ef..84599e41 100644 --- a/lam/HISTORY +++ b/lam/HISTORY @@ -4,6 +4,8 @@ September 2020 - Show password prompt when a user with expired password logs into LAM admin interface (requires PHP 7.2) - Better error messages on login when account is expired/deactivated/... - Windows users: group display format can be configured (cn/dn) + - LAM Pro: + -> Windows: new cron job to send users a summary of their managed groups 01.05.2020 7.2 - Unix: allow to create group with same name during user creation diff --git a/lam/docs/manual-sources/chapter-configuration.xml b/lam/docs/manual-sources/chapter-configuration.xml index 6b89e4cf..d8a43229 100644 --- a/lam/docs/manual-sources/chapter-configuration.xml +++ b/lam/docs/manual-sources/chapter-configuration.xml @@ -1141,6 +1141,11 @@ mysql> GRANT ALL PRIVILEGES ON lam_cron.* TO 'lam_cron'@'localhost'; move expired accounts + + Windows: Notify + users about their managed groups + + FreeRadius: Delete or move expired accounts @@ -1829,6 +1834,95 @@ mysql> GRANT ALL PRIVILEGES ON lam_cron.* TO 'lam_cron'@'localhost'; +
+ Windows: Notify users about their managed groups + + This will send your users an email with the groups they + manage. This also includes a list of users in these groups. The + users and groups are searched using the user+group account types + that are specified in server profile. + + You need to activate the Windows module for users to be able + to add this job. The job can be added multiple times. + + + + + + + Options + + + + + Option + + Description + + + + From address + + The email address to set as FROM. + + + + Reply-to address + + Optional Reply-to address for email. + + + + CC address + + Optional CC mail address. + + + + BCC address + + Optional BCC mail address. + + + + Subject + + The email subject line. Supports wildcards, see + below. + + + + HTML format + + Send email as HTML instead of plain text. + + + + Text + + The email body text. Supports wildcards, see + below. + + + + Period + + Defines how often the mail is sent (e.g. + quarterly). + + + +
Wildcards:
+ + You can enter LDAP attributes as wildcards in the form + @@ATTRIBUTE_NAME@@. E.g. to add the user's common name use "@@cn@@". + For the common name it would be "@@cn@@". + + Use the wildcard "@@LAM_MANAGED_GROUPS@@" to insert the group + listing. This wildcard is mandatory. +
+
FreeRadius: Delete or move expired accounts diff --git a/lam/docs/manual-sources/images/jobs_windowsNotifyGroups.png b/lam/docs/manual-sources/images/jobs_windowsNotifyGroups.png new file mode 100644 index 00000000..9f635975 Binary files /dev/null and b/lam/docs/manual-sources/images/jobs_windowsNotifyGroups.png differ diff --git a/lam/help/help.inc b/lam/help/help.inc index 8c6162d5..955cad60 100644 --- a/lam/help/help.inc +++ b/lam/help/help.inc @@ -430,6 +430,16 @@ $helpArray = array ( "Headline" => _('Target DN'), "Text" => _('The expired accounts will be moved to this DN.') ), + '810' => array( + "Headline" => _('Text'), + "Text" => _('The mail text of all mails.') . + _('You can use wildcards for LDAP attributes in the form @@attribute@@ (e.g. @@uid@@ for the user name).') + . ' ' . _('The managed groups need to be added with @@LAM_MANAGED_GROUPS@@.') + ), + '811' => array( + "Headline" => _('Period'), + "Text" => _('This defines how often the email is sent (e.g. each month).') + ), ); /* This is a sample help entry. Just copy this line an modify the values between the [] brackets. diff --git a/lam/lib/modules/windowsUser.inc b/lam/lib/modules/windowsUser.inc index 353bc200..ade505f0 100644 --- a/lam/lib/modules/windowsUser.inc +++ b/lam/lib/modules/windowsUser.inc @@ -3915,7 +3915,8 @@ class windowsUser extends baseModule implements passwordService { return array( new WindowsPasswordNotifyJob(), new WindowsAccountExpirationCleanupJob(), - new WindowsAccountExpirationNotifyJob() + new WindowsAccountExpirationNotifyJob(), + new WindowsManagedGroupsNotifyJob() ); } @@ -4147,6 +4148,299 @@ if (interface_exists('\LAM\JOB\Job', false)) { } + /** + * Job to notify users about their managed groups. + * + * @package jobs + */ + class WindowsManagedGroupsNotifyJob extends \LAM\JOB\PasswordExpirationJob { + + const MANAGED_GROUPS = 'LAM_MANAGED_GROUPS'; + const PERIOD_MONTHLY = 'MONTHLY'; + const PERIOD_QUARTERLY = 'QUARTERLY'; + const PERIOD_HALF_YEARLY = 'HALF_YEARLY'; + const PERIOD_YEARLY = 'YEARLY'; + + /** + * Returns the alias name of the job. + * + * @return String name + */ + public function getAlias() { + return _('Windows') . ': ' . _('Notify users about their managed groups'); + } + + /** + * @inheritDoc + */ + public function getDescription() { + return _('This will send each user a summary of the managed groups and their members.'); + } + + /** + * @inheritDoc + */ + public function getConfigOptions($jobID) { + $prefix = $this->getConfigPrefix(); + $container = new htmlResponsiveRow(); + $container->add(new htmlResponsiveInputField(_('From address'), $prefix . '_mailFrom' . $jobID, null, '800', true), 12); + $container->add(new htmlResponsiveInputField(_('Reply-to address'), $prefix . '_mailReplyTo' . $jobID, null, '801'), 12); + $container->add(new htmlResponsiveInputField(_('CC address'), $prefix . '_mailCC' . $jobID, null, '805'), 12); + $container->add(new htmlResponsiveInputField(_('BCC address'), $prefix . '_mailBCC' . $jobID, null, '806'), 12); + $container->add(new htmlResponsiveInputField(_('Subject'), $prefix . '_mailSubject' . $jobID, null, '802'), 12); + $container->add(new htmlResponsiveInputCheckbox($prefix . '_mailIsHTML' . $jobID, false, _('HTML format'), '553'), 12); + $container->add(new htmlResponsiveInputTextarea($prefix . '_mailtext' . $jobID, '', 50, 4, _('Text'), '810'), 12); + $periodOptions = array( + _('Monthly') => self::PERIOD_MONTHLY, + _('Quarterly') => self::PERIOD_QUARTERLY, + _('Half-yearly') => self::PERIOD_HALF_YEARLY, + _('Yearly') => self::PERIOD_YEARLY, + ); + $periodSelect = new htmlResponsiveSelect($prefix . '_period' . $jobID, $periodOptions, array(), _('Period'), '811'); + $periodSelect->setHasDescriptiveElements(true); + $periodSelect->setSortElements(false); + $container->add($periodSelect, 12); + return $container; + } + + /** + * @inheritDoc + */ + public function checkConfigOptions($jobID, $options) { + $prefix = $this->getConfigPrefix(); + $errors = array(); + // from address + if (empty($options[$prefix . '_mailFrom' . $jobID][0]) + || !(get_preg($options[$prefix . '_mailFrom' . $jobID][0], 'email') + || get_preg($options[$prefix . '_mailFrom' . $jobID][0], 'emailWithName'))) { + $errors[] = array('ERROR', _('Please enter a valid email address!'), _('From address')); + } + // reply-to + if (!empty($options[$prefix . '_mailReplyTo' . $jobID][0]) + && !get_preg($options[$prefix . '_mailReplyTo' . $jobID][0], 'email') + && !get_preg($options[$prefix . '_mailReplyTo' . $jobID][0], 'emailWithName')) { + $errors[] = array('ERROR', _('Please enter a valid email address!'), _('Reply-to address')); + } + // CC address + if (!empty($options[$prefix . '_mailCC' . $jobID][0]) + && !get_preg($options[$prefix . '_mailCC' . $jobID][0], 'email') + && !get_preg($options[$prefix . '_mailCC' . $jobID][0], 'emailWithName')) { + $errors[] = array('ERROR', _('Please enter a valid email address!'), _('CC address')); + } + // BCC address + if (!empty($options[$prefix . '_mailBCC' . $jobID][0]) + && !get_preg($options[$prefix . '_mailBCC' . $jobID][0], 'email') + && !get_preg($options[$prefix . '_mailBCC' . $jobID][0], 'emailWithName')) { + $errors[] = array('ERROR', _('Please enter a valid email address!'), _('BCC address')); + } + // text + $mailText = implode('', $options[$prefix . '_mailtext' . $jobID]); + if (empty($mailText)) { + $errors[] = array('ERROR', _('Please set a email text.')); + } + if (strpos($mailText, '@@' . self::MANAGED_GROUPS . '@@') === false) { + $errors[] = array('ERROR', _('Please add the wildcard for the list of managed groups.'), '@@' . self::MANAGED_GROUPS . '@@'); + } + return $errors; + } + + /** + * @inheritDoc + */ + protected function getPolicyOptions() { + return array(); + } + + /** + * Searches for users in LDAP. + * + * @param String $jobID unique job identifier + * @param array $options config options (name => value) + * @return array list of user attributes + */ + protected function findUsers($jobID, $options) { + // read users + $sysAttrs = array('managedObjects', 'mail'); + $attrs = $this->getAttrWildcards($jobID, $options); + $attrs = array_values(array_unique(array_merge($attrs, $sysAttrs))); + $users = searchLDAPByFilter('(&(mail=*)(managedObjects=*))', $attrs, array('user')); + $groups = searchLDAPByFilter('(managedBy=*)', array('cn', 'member'), array('group')); + $groupByDn = array(); + foreach ($groups as $group) { + $groupByDn[$group['dn']] = $group; + } + $groups = null; + foreach ($users as $index => $user) { + $managedObjectDns = $user['managedobjects']; + $managedGroups = array(); + foreach ($managedObjectDns as $managedObjectDn) { + if (array_key_exists($managedObjectDn, $groupByDn)) { + $managedGroups[] = $groupByDn[$managedObjectDn]; + } + } + $users[$index][strtolower(self::MANAGED_GROUPS)] = $managedGroups; + } + return $users; + } + + /** + * @inheritDoc + */ + public function execute($jobID, $options, &$pdo, $isDryRun, &$resultLog) { + $this->jobResultLog = &$resultLog; + $this->jobResultLog->logDebug("Configuration options:"); + foreach ($options as $key => $value) { + if (strpos($key, $jobID) === false) { + continue; + } + $this->jobResultLog->logDebug($key . ': ' . implode(', ', $value)); + } + $now = new DateTime(null, getTimeZone()); + $baseDate = $this->getBaseDate($now); + $monthInterval = $this->getMonthInterval($options, $jobID); + if (!$this->shouldRun($pdo, $options, $jobID, $baseDate, $monthInterval)) { + $this->jobResultLog->logDebug('No run needed yet'); + return; + } + $userResults = $this->findUsers($jobID, $options); + $this->jobResultLog->logDebug("Found " . sizeof($userResults) . " users to send an email."); + $isHTML = (!empty($options[$this->getConfigPrefix() . '_mailIsHTML' . $jobID][0]) && ($options[$this->getConfigPrefix() . '_mailIsHTML' . $jobID][0] == 'true')); + foreach ($userResults as $user) { + if (empty($user[strtolower(self::MANAGED_GROUPS)])) { + continue; + } + $user[strtolower(self::MANAGED_GROUPS)][0] = $this->formatGroups($user[strtolower(self::MANAGED_GROUPS)], $isHTML); + if ($isDryRun) { + // no action for dry run + $this->jobResultLog->logInfo("Managed groups text for " . $user['dn'] . ":\n" . $user[strtolower(self::MANAGED_GROUPS)][0]); + $this->jobResultLog->logInfo('Not sending email to ' . $user['dn'] . ' because of dry run.'); + continue; + } + // send email + $this->sendMail($options, $jobID, $user, null); + } + if (!$isDryRun) { + $this->setDBLastPwdChangeTime($jobID, $pdo, $jobID, self::getLastEffectiveExecutionDate($baseDate, $monthInterval, $this->jobResultLog)->format('Y-m-d')); + } + } + + /** + * Returns if the job should run. + * + * @param $pdo PDO + * @param $options job options + * @param $jobId job id + * @param DateTime $baseDate base date + * @param int $monthInterval month interval + * @return bool should run + */ + private function shouldRun(&$pdo, $options, $jobId, $baseDate, $monthInterval) { + $dbLastChange = $this->getDBLastPwdChangeTime($jobId, $pdo, $jobId); + if (empty($dbLastChange)) { + return true; + } + $this->jobResultLog->logDebug('Base date: ' . $baseDate->format('Y-m-d')); + $effectiveDate = self::getLastEffectiveExecutionDate($baseDate, $monthInterval, $this->jobResultLog); + $dbLastChangeDate = DateTime::createFromFormat('Y-m-d', $dbLastChange, getTimeZone()); + $this->jobResultLog->logDebug('Last run date: ' . $dbLastChangeDate->format('Y-m-d')); + return $effectiveDate > $dbLastChangeDate; + } + + /** + * Returns the month interval. + * + * @param arry $options config options + * @param $jobId job id + * @return int interval + */ + private function getMonthInterval($options, $jobId) { + $monthInterval = 12; + switch ($options[$this->getConfigPrefix() . '_period' . $jobId][0]) { + case self::PERIOD_HALF_YEARLY: + $monthInterval = 6; + break; + case self::PERIOD_QUARTERLY: + $monthInterval = 3; + break; + case self::PERIOD_MONTHLY: + $monthInterval = 1; + break; + } + return $monthInterval; + } + + /** + * Returns the base date (first of month) for the current date. + * + * @param DateTime $currentDate current date + * @return DateTime base date + */ + private function getBaseDate($currentDate) { + $baseDateText = $currentDate->format('Y-m-') . '1'; + return DateTime::createFromFormat('Y-m-d', $baseDateText, getTimeZone()); + } + + /** + * Returns the last effective execution date. + * + * @param DateTime $baseDate base date + * @param int $monthInterval number of months in interval + * @param \LAM\JOB\JobResultLog $resultLog result log + */ + public static function getLastEffectiveExecutionDate($baseDate, $monthInterval, $resultLog) { + $month = $baseDate->format('m'); + $monthIndex = $month - 1; + while (($monthIndex % $monthInterval) !== 0) { + $monthIndex--; + } + $month = $monthIndex + 1; + $effectiveDateString = $baseDate->format('Y-') . $month . '-1'; + $effectiveDate = DateTime::createFromFormat('Y-m-d', $effectiveDateString, getTimeZone()); + $resultLog->logDebug("Effective date: " . $effectiveDate->format('Y-m-d')); + return $effectiveDate; + } + + /** + * @inheritDoc + */ + protected function checkSingleUser($jobID, $options, &$pdo, $now, $policyOptions, $user, $isDryRun) { + // not used + } + + /** + * Formats the managed groups. + * + * @param $managedGroups managed groups + * @param bool $isHTML HTML email + * @return string formatted text + */ + private function formatGroups($managedGroups, bool $isHTML) { + $text = ''; + foreach ($managedGroups as $managedGroup) { + if ($isHTML) { + $text .= '
' . $managedGroup['cn'][0] . '
'; + } + else { + $text .= "\r\n" . $managedGroup['cn'][0] . "\r\n\r\n"; + } + if (empty($managedGroup['member'])) { + continue; + } + foreach ($managedGroup['member'] as $member) { + $member = getAbstractDN($member); + if ($isHTML) { + $text .= '  ' . $member . '
'; + } + else { + $text .= " " . $member . "\r\n"; + } + } + } + return $text; + } + + } + /** * Job to notify users about account expiration. * diff --git a/lam/tests/lib/modules/windowsUserTest.php b/lam/tests/lib/modules/windowsUserTest.php index e5534204..4d109ae3 100644 --- a/lam/tests/lib/modules/windowsUserTest.php +++ b/lam/tests/lib/modules/windowsUserTest.php @@ -21,9 +21,9 @@ use PHPUnit\Framework\TestCase; */ - include_once 'lam/lib/baseModule.inc'; - include_once 'lam/lib/modules.inc'; - include_once 'lam/lib/modules/windowsUser.inc'; + include_once __DIR__ . '/../../../lib/baseModule.inc'; + include_once __DIR__ . '/../../../lib/modules.inc'; + include_once __DIR__ . '/../../../lib/modules/windowsUser.inc'; /** * Checks the windowsUser class. @@ -78,6 +78,33 @@ use PHPUnit\Framework\TestCase; return $seconds . '0000000'; } + public function testWindowsManagedGroupsNotifyJob_getLastEffectiveExecutionDate() { + if (!interface_exists('\LAM\JOB\Job', false)) { + return; + } + $resultLog = new \LAM\JOB\JobResultLog(); + $baseDate = DateTime::createFromFormat('Y-m-d', '2020-08-21', getTimeZone()); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 12, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-07-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 6, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-07-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 3, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-08-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 1, $resultLog)->format('Y-m-d')); + $baseDate = DateTime::createFromFormat('Y-m-d', '2020-12-31', getTimeZone()); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 12, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-07-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 6, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-10-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 3, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-12-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 1, $resultLog)->format('Y-m-d')); + $baseDate = DateTime::createFromFormat('Y-m-d', '2020-01-01', getTimeZone()); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 12, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 6, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 3, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 1, $resultLog)->format('Y-m-d')); + $baseDate = DateTime::createFromFormat('Y-m-d', '2020-06-05', getTimeZone()); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 12, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-01-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 6, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-04-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 3, $resultLog)->format('Y-m-d')); + $this->assertEquals('2020-06-01', WindowsManagedGroupsNotifyJob::getLastEffectiveExecutionDate($baseDate, 1, $resultLog)->format('Y-m-d')); + } + } ?>