From d7885c8882f5aada8d2a63be29902317ba88103a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Fri, 6 Jul 2018 15:45:36 +0200 Subject: [PATCH] send notification when passwords are about to expire --- appinfo/app.php | 3 + appinfo/info.xml | 5 +- lib/AppInfo/Application.php | 13 ++ lib/Controller/SettingsController.php | 28 +++- lib/Db/OldPasswordMapper.php | 26 ++++ lib/HooksHandler.php | 10 +- lib/Jobs/PasswordExpirationNotifierJob.php | 165 +++++++++++++++++++++ lib/Notifier.php | 116 +++++++++++++++ lib/UserNotificationConfigHandler.php | 118 +++++++++++++++ templates/admin.php | 7 + 10 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 lib/Jobs/PasswordExpirationNotifierJob.php create mode 100644 lib/Notifier.php create mode 100644 lib/UserNotificationConfigHandler.php diff --git a/appinfo/app.php b/appinfo/app.php index 821f92ff..79e58417 100755 --- a/appinfo/app.php +++ b/appinfo/app.php @@ -57,3 +57,6 @@ \OCP\IUser::class . '::firstLogin', [$handler, 'checkForcePasswordChangeOnFirstLogin'] ); + +$app = new \OCA\PasswordPolicy\AppInfo\Application(); +$app->registerNotifier(); \ No newline at end of file diff --git a/appinfo/info.xml b/appinfo/info.xml index 75b94c3a..6db1c795 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -9,7 +9,7 @@ The Password Policy application enables ownCloud administrators to define password requirements like minimum characters, numbers, capital letters and more for all kinds of password endpoints like user passwords or public link sharing passwords. To add another layer of security, the administrator can enforce expiration dates for public link shares. As soon as the expiration date is reached the share will be removed automatically. Administrators find the configuration options in the 'Security' section of the ownCloud administration settings panel. The definition of certain password rules support administrators in the task of ensuring a minimum level of password security throughout the enterprise. It minimizes the risk of weak user passwords and therefore adds an additional security aspect to the ownCloud infrastructure. The expiration date for public link shares allows more control while e.g. working with external parties. Instead of having to manually remove a public link share when the work is done, the expiration date functionality will remove the link share after a defined period of time without further user interaction. - 2.0.0 + 2.0.1 https://doc.owncloud.com/server/10.0/admin_manual/configuration/server/security/password_policy.html @@ -27,6 +27,9 @@ The definition of certain password rules support administrators in the task of e OCA\PasswordPolicy\Authentication\AccountModule + + OCA\PasswordPolicy\Jobs\PasswordExpirationNotifierJob + https://raw.githubusercontent.com/owncloud/screenshots/master/password_policy/owncloud-app-password_policy.jpg https://raw.githubusercontent.com/owncloud/screenshots/master/password_policy/owncloud-app-password_policy2.jpg diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2afdb2e0..c47a56bc 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,6 +21,8 @@ namespace OCA\PasswordPolicy\AppInfo; use OCP\AppFramework\App; +use OCP\Notification\Events\RegisterNotifierEvent; +use OCA\PasswordPolicy\Notifier; class Application extends App { @@ -28,4 +30,15 @@ public function __construct (array $urlParams = []) { parent::__construct('password_policy', $urlParams); } + /** + * Registers the notifier + */ + public function registerNotifier() { + $container = $this->getContainer(); + $dispatcher = $container->getServer()->getEventDispatcher(); + $dispatcher->addListener(RegisterNotifierEvent::NAME, function (RegisterNotifierEvent $event) use ($container) { + $l10n = $container->getServer()->getL10N('password_policy'); + $event->registerNotifier($container->query(Notifier::class), 'password_policy', $l10n->t('Password Policy')); + }); + } } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 26f0ee3f..aed19ce7 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -49,6 +49,8 @@ class SettingsController extends Controller implements ISettings { 'spv_password_history_value' => 3, 'spv_user_password_expiration_checked' => false, 'spv_user_password_expiration_value' => 90, + 'spv_user_password_expiration_notification_checked' => false, + 'spv_user_password_expiration_notification_value' => 30, 'spv_user_password_force_change_on_first_login_checked' => false, 'spv_expiration_password_checked' => false, 'spv_expiration_password_value' => 7, @@ -56,6 +58,13 @@ class SettingsController extends Controller implements ISettings { 'spv_expiration_nopassword_value' => 7, ]; + const CONVERSIONS = [ + 'spv_user_password_expiration_notification_value' => [ + 'in' => 'daysToSeconds', + 'out' => 'secondsToDays', + ], + ]; + public function __construct($appName, IRequest $request, IConfig $config) { @@ -71,6 +80,10 @@ public function updatePolicy() { if ($this->request->getParam($key) !== null) { if ($key !== 'spv_def_special_chars_value' && \substr($key, -6) === '_value') { $value = \min(\max(0, (int)$this->request->getParam($key)), 255); + if (isset(self::CONVERSIONS[$key]['in'])) { + $convertFuncName = self::CONVERSIONS[$key]['in']; + $value = $this->$convertFuncName($value); + } $this->config->setAppValue('password_policy', $key, $value); } else { $this->config->setAppValue('password_policy', $key, \strip_tags($this->request->getParam($key))); @@ -92,9 +105,22 @@ public function getPriority() { public function getPanel() { $template = new Template('password_policy', 'admin'); foreach(self::DEFAULTS as $key => $default) { - $template->assign($key, $this->config->getAppValue('password_policy', $key, $default)); + $value = $this->config->getAppValue('password_policy', $key, $default); + if (isset(self::CONVERSIONS[$key]['out'])) { + $convertFuncName = self::CONVERSIONS[$key]['out']; + $value = $this->$convertFuncName($value); + } + $template->assign($key, $value); } return $template; } + private function daysToSeconds($days) { + return $days * 24 * 60 * 60; + } + + private function secondsToDays($seconds) { + $floatDays = $seconds / (24 * 60 * 60); + return \intval(\ceil($floatDays)); + } } diff --git a/lib/Db/OldPasswordMapper.php b/lib/Db/OldPasswordMapper.php index 219a9c76..d2c33e3f 100644 --- a/lib/Db/OldPasswordMapper.php +++ b/lib/Db/OldPasswordMapper.php @@ -63,4 +63,30 @@ public function getLatestPassword($uid) { } return $passwords[0]; } + + public function getPasswordsAboutToExpire($maxTimestamp) { + $oldPasswords = []; + + $query = "select `f`.`*` from ("; + $query .= "select `uid`, max(`change_time`) as `maxtime` from `*PREFIX*user_password_history` group by `uid`"; + $query .= ") as `x` inner join `*PREFIX*user_password_history` as `f` on `f`.`uid` = `x`.`uid` and `f`.`change_time` = `x`.`maxtime`"; + $query .= " where `f`.`change_time` < ?"; + + $stmt = $this->db->prepare($query); + $stmt->bindValue(1, $maxTimestamp); + $result = $stmt->execute(); + + if ($result === false) { + $info = \json_encode($stmt->erroInfo()); + $message = "Cannot get the password that are going to be expired: error: {$info}"; + \OCP\Util::writeLog('password_policy', $message, \OCP\Util::ERROR); + return false; + } + + while ($row = $stmt->fetch()) { + $oldPasswords[] = OldPassword::fromRow($row); + } + $stmt->closeCursor(); + return $oldPasswords; + } } \ No newline at end of file diff --git a/lib/HooksHandler.php b/lib/HooksHandler.php index 1ef75024..f0ee79c4 100644 --- a/lib/HooksHandler.php +++ b/lib/HooksHandler.php @@ -26,6 +26,7 @@ use OCA\PasswordPolicy\Db\OldPasswordMapper; use OCA\PasswordPolicy\Rules\PasswordExpired; use OCA\PasswordPolicy\Rules\PolicyException; +use OCA\PasswordPolicy\UserNotificationConfigHandler; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IL10N; @@ -60,6 +61,9 @@ class HooksHandler { /** @var ISession */ private $session; + /** @var UserNotificationConfigHandler */ + private $userNotificationConfigHandler; + public function __construct( IConfig $config = null, Engine $engine = null, @@ -68,7 +72,8 @@ public function __construct( IL10N $l10n = null, PasswordExpired $passwordExpiredRule = null, OldPasswordMapper $oldPasswordMapper = null, - ISession $session = null + ISession $session = null, + UserNotificationConfigHandler $userNotificationConfigHandler = null ) { $this->config = $config; $this->engine = $engine; @@ -78,6 +83,7 @@ public function __construct( $this->passwordExpiredRule = $passwordExpiredRule; $this->oldPasswordMapper = $oldPasswordMapper; $this->session = $session; + $this->userNotificationConfigHandler = $userNotificationConfigHandler; } private function fixDI() { @@ -103,6 +109,7 @@ private function fixDI() { $this->hasher ); $this->session = \OC::$server->getSession(); + $this->userNotificationConfigHandler = new UserNotificationConfigHandler($this->config); } } @@ -185,6 +192,7 @@ public function saveOldPassword(GenericEvent $event) { $oldPassword->setPassword($this->hasher->hash($password)); $oldPassword->setChangeTime($this->timeFactory->getTime()); $this->oldPasswordMapper->insert($oldPassword); + $this->userNotificationConfigHandler->resetExpirationMarks($user->getUID()); } /** diff --git a/lib/Jobs/PasswordExpirationNotifierJob.php b/lib/Jobs/PasswordExpirationNotifierJob.php new file mode 100644 index 00000000..45c3251d --- /dev/null +++ b/lib/Jobs/PasswordExpirationNotifierJob.php @@ -0,0 +1,165 @@ + + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license GPL-2.0 + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\PasswordPolicy\Jobs; + +use OC\BackgroundJob\TimedJob; +use OCP\Notification\IManager; +use OCP\IUserManager; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\ILogger; +use OCP\AppFramework\Utility\ITimeFactory; +use OCA\PasswordPolicy\Db\OldPasswordMapper; +use OCA\PasswordPolicy\Db\OldPassword; +use OCA\PasswordPolicy\UserNotificationConfigHandler; + +class PasswordExpirationNotifierJob extends TimedJob { + /** @var OldPasswordMapper */ + private $mapper; + + /** @var $manager */ + private $manager; + + /** @var IUserManager */ + private $userManager; + + /** @var UserNotificationConfigHandler */ + private $unConfigHandler; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var ILogger */ + private $logger; + + public function __construct( + OldPasswordMapper $mapper, + IManager $manager, + IUserManager $userManager, + UserNotificationConfigHandler $unConfigHandler, + ITimeFactory $timeFactory, + IURLGenerator $urlGenerator, + ILogger $logger + ) { + $this->mapper = $mapper; + $this->manager = $manager; + $this->userManager = $userManager; + $this->unConfigHandler = $unConfigHandler; + $this->timeFactory = $timeFactory; + $this->urlGenerator = $urlGenerator; + $this->logger = $logger; + + $this->setInterval(12 * 60 * 60); + } + + protected function run($arguments) { + $expirationTime = $this->unConfigHandler->getExpirationTime(); + if ($expirationTime === null) { + return; // expiration not configured + } + + $expirationTime = $expirationTime * 24 * 60 * 60; // convert days to seconds + $expirationTimeNotification = $this->unConfigHandler->getExpirationTimeForNormalNotification(); + if ($expirationTimeNotification === null) { + $expirationTimeNotification = 0; + } + + // ensure ranges are correct + if ($expirationTime <= $expirationTimeNotification) { + $message = "wrong expiration range: normal ($expirationTimeNotification) < expired ($expirationTime)"; + $this->logger->warning($message, ['app' => 'password_policy']); + return; + } + + $notifyAfter = $expirationTime - $expirationTimeNotification; + + $currentTime = $this->timeFactory->getTime(); + + $maxTimestamp = $currentTime - $notifyAfter; + // passwords changed before $maxTimestamp are expired or about to be expired + // according to the expiration time range + + $oldPasswordsAboutToExpire = $this->mapper->getPasswordsAboutToExpire($maxTimestamp); + foreach ($oldPasswordsAboutToExpire as $passInfo) { + $elapsedTime = $currentTime - $passInfo->getChangeTime(); + if ($elapsedTime >= $expirationTime) { + $this->logger->debug("password timestamp for {$passInfo->getUid()}: {$passInfo->getChangeTime()}; elapsed time: {$elapsedTime} -> EXPIRED", ['app' => 'password_policy']); + $this->sendPassExpiredNotification($passInfo, $expirationTime); + } elseif ($elapsedTime >= $notifyAfter) { + $this->logger->debug("password timestamp for {$passInfo->getUid()}: {$passInfo->getChangeTime()}; elapsed time: {$elapsedTime} -> NOTIFY", ['app' => 'password_policy']); + $this->sendAboutToExpireNotification($passInfo, $expirationTime); + } + } + } + + private function sendAboutToExpireNotification(OldPassword $passInfo, $expirationTime) { + if ($this->unConfigHandler->isSentAboutToExpireNotification($passInfo)) { + return; // notification already sent + } + + $notificationTimestamp = $this->timeFactory->getTime(); + + $notification = $this->manager->createNotification(); + $notification->setApp('password_policy') + ->setUser($passInfo->getUid()) + ->setDateTime(new \DateTime("@{$notificationTimestamp}")) + ->setObject('about_to_expire', $passInfo->getId()) + ->setSubject('about_to_expire', [$passInfo->getChangeTime(), $expirationTime]) + ->setMessage('about_to_expire', [$passInfo->getChangeTime(), $expirationTime]) + ->setLink($this->getNotificationLink()); + $this->manager->notify($notification); + + $this->unConfigHandler->markAboutToExpireNotificationSentFor($passInfo); + } + + private function sendPassExpiredNotification(OldPassword $passInfo, $expirationTime) { + if ($this->unConfigHandler->isSentExpiredNotification($passInfo)) { + return; // notification already sent + } + + $notificationTimestamp = $this->timeFactory->getTime(); + + $notification = $this->manager->createNotification(); + $notification->setApp('password_policy') + ->setUser($passInfo->getUid()) + ->setDateTime(new \DateTime("@{$notificationTimestamp}")) + ->setObject('expired', $passInfo->getId()) + ->setSubject('expired', [$passInfo->getChangeTime(), $expirationTime]) + ->setMessage('expired', [$passInfo->getChangeTime(), $expirationTime]) + ->setLink($this->getNotificationLink()); + $this->manager->notify($notification); + + $this->unConfigHandler->markExpiredNotificationSentFor($passInfo); + } + + private function getNotificationLink() { + $link = $this->urlGenerator->linkToRouteAbsolute( + 'password_policy.password.show', + ['redirect_url' => ''] + ); + return $link; + } +} diff --git a/lib/Notifier.php b/lib/Notifier.php new file mode 100644 index 00000000..27282279 --- /dev/null +++ b/lib/Notifier.php @@ -0,0 +1,116 @@ + + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license GPL-2.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\PasswordPolicy; + +use OCP\Notification\INotification; +use OCP\Notification\INotifier; +use OCP\Notification\IManager as INotificationManager; +use OCP\IUserManager; +use OCP\IConfig; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\L10N\IFactory; +use OC\L10N\L10N; + +class Notifier implements INotifier { + /** @var IFactory */ + protected $factory; + + /** @var IUserManager */ + protected $userManager; + + /** @var ITimeFactory */ + protected $timeFactory; + /** + * @param \OCP\L10N\IFactory $factory + */ + public function __construct( + IFactory $factory, + IUserManager $userManager, + ITimeFactory $timeFactory + ) { + $this->factory = $factory; + $this->userManager = $userManager; + $this->timeFactory = $timeFactory; + } + /** + * @param INotification $notification + * @param string $languageCode The code of the language that should be used to prepare the notification + * @return INotification + */ + public function prepare(INotification $notification, $languageCode) { + if ($notification->getApp() !== 'password_policy') { + throw new \InvalidArgumentException(); + } + // Read the language from the notification + $l = $this->factory->get('password_policy', $languageCode); + switch ($notification->getObjectType()) { + case 'about_to_expire': + return $this->formatAboutToExpire($notification, $l); + case 'expired': + return $this->formatExpired($notification, $l); + default: + throw new \InvalidArgumentException(); + } + } + + private function formatAboutToExpire(INotification $notification, L10N $l) { + $params = $notification->getSubjectParameters(); + $notification->setParsedSubject( + (string) $l->t('Your password is about to expire!', $params) + ); + + $messageParams = $notification->getMessageParameters(); + $currentTime = $this->timeFactory->getTime(); + $currentDateTime = new \DateTime("@{$currentTime}"); + $passwordTime = $messageParams[0]; + $expirationTime = $messageParams[1]; + $targetExpirationTime = $passwordTime + $expirationTime; + $expirationDateTime = new \DateTime("@{$targetExpirationTime}"); + $interval = $expirationDateTime->diff($currentDateTime); + + $notification->setParsedMessage( + (string) $l->t('You have %1$s days to change your password', [$interval->days]) + ); + + return $notification; + } + + private function formatExpired(INotification $notification, L10N $l) { + $params = $notification->getSubjectParameters(); + $notification->setParsedSubject( + (string) $l->t('Your password has expired', $params) + ); + + $messageParams = $notification->getMessageParameters(); + $currentTime = $this->timeFactory->getTime(); + $currentDateTime = new \DateTime("@{$currentTime}"); + $passwordTime = $messageParams[0]; + $expirationTime = $messageParams[1]; + $targetExpirationTime = $passwordTime + $expirationTime; + $expirationDateTime = new \DateTime("@{$targetExpirationTime}"); + $interval = $expirationDateTime->diff($currentDateTime); + + $notification->setParsedMessage( + (string) $l->t('You have to change your password before you can access again', [$interval->days]) + ); + + return $notification; + } +} \ No newline at end of file diff --git a/lib/UserNotificationConfigHandler.php b/lib/UserNotificationConfigHandler.php new file mode 100644 index 00000000..83effc35 --- /dev/null +++ b/lib/UserNotificationConfigHandler.php @@ -0,0 +1,118 @@ + + * @copyright Copyright (c) 2018, ownCloud GmbH + * @license GPL-2.0 + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\PasswordPolicy; + +use OCP\IConfig; +use OCA\PasswordPolicy\Db\OldPassword; + +class UserNotificationConfigHandler { + const DEFAULT_EXPIRATION_FOR_NORMAL_NOTIFICATION = 30 * 24 * 60 * 60; // 30 days + + /** @var IConfig */ + private $config; + + public function __construct(IConfig $config) { + $this->config = $config; + } + + public function getExpirationTime() { + $isChecked = $this->config->getAppValue( + 'password_policy', + 'spv_user_password_expiration_checked', + false + ); + if (!\filter_var($isChecked, FILTER_VALIDATE_BOOLEAN)) { + return null; + } + + $expirationTime = $this->config->getAppValue( + 'password_policy', + 'spv_user_password_expiration_value', + null + ); + if ($expirationTime === null || !\is_numeric($expirationTime)) { + return null; // passwords don't expire or have weird value + } + return \intval($expirationTime); + } + + public function getExpirationTimeForNormalNotification() { + $isChecked = $this->config->getAppValue( + 'password_policy', + 'spv_user_password_expiration_notification_checked', + false + ); + if (!\filter_var($isChecked, FILTER_VALIDATE_BOOLEAN)) { + return null; + } + + $expirationTime = $this->config->getAppValue( + 'password_policy', + 'spv_user_password_expiration_notification_value', + self::DEFAULT_EXPIRATION_FOR_NORMAL_NOTIFICATION); + if ($expirationTime === null || !\is_numeric($expirationTime)) { + return null; // passwords don't expire or have weird value + } + return \intval($expirationTime); + } + + public function markAboutToExpireNotificationSentFor(OldPassword $passInfo) { + $this->config->setUserValue($passInfo->getUid(), 'password_policy', 'aboutToExpireSent', $passInfo->getId()); + } + + public function markExpiredNotificationSentFor(OldPassword $passInfo) { + $this->config->setUserValue($passInfo->getUid(), 'password_policy', 'expiredSent', $passInfo->getId()); + } + + public function isSentAboutToExpireNotification(OldPassword $passInfo) { + $storedId = $this->config->getUserValue($passInfo->getUid(), 'password_policy', 'aboutToExpireSent', null); + if ($storedId === null) { + return false; // notification not sent + } elseif (\intval($storedId) !== $passInfo->getId()) { + // if the pasword id doesn't match the one stored, the notification hasn't been sent + return false; + } + return true; + } + + public function isSentExpiredNotification(OldPassword $passInfo) { + $storedId = $this->config->getUserValue($passInfo->getUid(), 'password_policy', 'expiredSent', null); + if ($storedId === null) { + return false; // notification not sent + } elseif (\intval($storedId) !== $passInfo->getId()) { + // if the pasword id doesn't match the one stored, the notification hasn't been sent + return false; + } + return true; + } + + public function resetExpirationMarks($uid) { + $targetKeys = [ + 'aboutToExpireSent', + 'expiredSent', + ]; + foreach ($targetKeys as $targetKey) { + $this->config->deleteUserValue($uid, 'password_policy', $targetKey); + } + } +} \ No newline at end of file diff --git a/templates/admin.php b/templates/admin.php index 24e24263..0b733075 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -90,6 +90,13 @@ t('days until user password expires'));?> +
  • + +