diff --git a/appinfo/app.php b/appinfo/app.php index c65a0cd5..c66afec1 100755 --- a/appinfo/app.php +++ b/appinfo/app.php @@ -61,3 +61,16 @@ \OCP\IUser::class . '::firstLogin', [$handler, 'checkForcePasswordChangeOnFirstLogin'] ); + +$app = new \OCA\PasswordPolicy\AppInfo\Application(); +$app->registerNotifier(); + +// only load notification JS code in the logged in layout page (not public links not login page) +$request = \OC::$server->getRequest(); +if (\OC::$server->getUserSession() !== null && \OC::$server->getUserSession()->getUser() !== null + && substr($request->getScriptName(), 0 - strlen('/index.php')) === '/index.php' + && substr($request->getPathInfo(), 0, strlen('/s/')) !== '/s/' + && substr($request->getPathInfo(), 0, strlen('/login')) !== '/login') { + + \OCP\Util::addScript('password_policy', 'notification'); +} diff --git a/appinfo/info.xml b/appinfo/info.xml index e4f355b7..993a9a41 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -17,7 +17,7 @@ Administrators find the configuration options in the 'Security' section of the o https://doc.owncloud.com/server/10.0/admin_manual/configuration/server/security/password_policy.html - + PasswordPolicy @@ -30,6 +30,9 @@ Administrators find the configuration options in the 'Security' section of the o 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/js/notification.js b/js/notification.js new file mode 100644 index 00000000..d0748318 --- /dev/null +++ b/js/notification.js @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2018 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ +(function () { + + $(document).ready(function() { + // convert action URL to redirect + $('body').on('OCA.Notification.Action', function(e) { + if (e.notification.app === 'password_policy' + && (e.notification.object_type === 'about_to_expire' || e.notification.object_type === 'expired') + && e.action.type === 'GET' + ) { + OC.redirect(e.notification.link); + return false; + } + }); + }); +})(); + 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/PasswordController.php b/lib/Controller/PasswordController.php index 70690007..fdee56c3 100644 --- a/lib/Controller/PasswordController.php +++ b/lib/Controller/PasswordController.php @@ -141,7 +141,7 @@ public function update($current_password, $new_password, $confirm_password, $red if ($new_password !== $confirm_password) { return $this->createPasswordTemplateResponse( $redirect_url, - $this->l10n->t('New passwords are not the same.') + $this->l10n->t('Password confirmation does not match the password.') ); } @@ -150,7 +150,7 @@ public function update($current_password, $new_password, $confirm_password, $red if(!$this->userManager->checkPassword($user->getUID(), $current_password)) { return $this->createPasswordTemplateResponse( $redirect_url, - $this->l10n->t('Incorrect current password supplied.') + $this->l10n->t('The current password is incorrect.') ); } diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 26f0ee3f..86a3e212 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -26,6 +26,7 @@ use OCP\IRequest; use OCP\Settings\ISettings; use OCP\Template; +use OCA\PasswordPolicy\UserNotificationConfigHandler; class SettingsController extends Controller implements ISettings { @@ -49,6 +50,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' => UserNotificationConfigHandler::DEFAULT_EXPIRATION_FOR_NORMAL_NOTIFICATION, 'spv_user_password_force_change_on_first_login_checked' => false, 'spv_expiration_password_checked' => false, 'spv_expiration_password_value' => 7, @@ -56,6 +59,17 @@ class SettingsController extends Controller implements ISettings { 'spv_expiration_nopassword_value' => 7, ]; + /** + * functions to convert values between what is shown and what is stored + * these functions must be defined in this class, they're per config key + */ + const CONVERSIONS = [ + 'spv_user_password_expiration_notification_value' => [ + 'in' => 'daysToSeconds', + 'out' => 'secondsToDays', + ], + ]; + public function __construct($appName, IRequest $request, IConfig $config) { @@ -71,6 +85,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 +110,33 @@ 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; } + /** + * Convert the days to seconds + * @param int $days + * @return int the number of seconds + */ + private function daysToSeconds($days) { + return $days * 24 * 60 * 60; + } + + /** + * Convert seconds to days. The value will always be rounded up, + * so 1 second will be converted to 1 day + * @param int $seconds the number of seconds to be converted + * @return int the number of days in those seconds, rounded up + */ + 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..2a20bce6 100644 --- a/lib/Db/OldPasswordMapper.php +++ b/lib/Db/OldPasswordMapper.php @@ -63,4 +63,36 @@ public function getLatestPassword($uid) { } return $passwords[0]; } -} \ No newline at end of file + + /** + * Get the passwords that are about to expire or already expired. + * Last passwords which have been changed before the timestamp are the ones + * selectable. Previous stored passwords won't be included + * In addition, passwords from multiple users are expected + * @param int $maxTimestamp timestamp marker, last passwords changed before + * the timestamp will be selected + * @return Generator to traverse the selected passwords + */ + public function getPasswordsAboutToExpire($maxTimestamp) { + $query = "SELECT `f`.`id`, `f`.`uid`, `f`.`password`, `f`.`change_time` 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 passwords that are about to expire. Error: {$info}"; + \OCP\Util::writeLog('password_policy', $message, \OCP\Util::ERROR); + return; + } + + while ($row = $stmt->fetch()) { + yield OldPassword::fromRow($row); + } + $stmt->closeCursor(); + } +} diff --git a/lib/HooksHandler.php b/lib/HooksHandler.php index c79237b3..dd421f8b 100644 --- a/lib/HooksHandler.php +++ b/lib/HooksHandler.php @@ -26,12 +26,14 @@ 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; use OCP\ISession; use OCP\IUser; use OCP\Security\IHasher; +use OCP\Notification\IManager; use Symfony\Component\EventDispatcher\GenericEvent; class HooksHandler { @@ -60,6 +62,12 @@ class HooksHandler { /** @var ISession */ private $session; + /** @var IManager */ + private $notificationManager; + + /** @var UserNotificationConfigHandler */ + private $userNotificationConfigHandler; + public function __construct( IConfig $config = null, Engine $engine = null, @@ -68,7 +76,9 @@ public function __construct( IL10N $l10n = null, PasswordExpired $passwordExpiredRule = null, OldPasswordMapper $oldPasswordMapper = null, - ISession $session = null + ISession $session = null, + IManager $notificationManager = null, + UserNotificationConfigHandler $userNotificationConfigHandler = null ) { $this->config = $config; $this->engine = $engine; @@ -78,6 +88,8 @@ public function __construct( $this->passwordExpiredRule = $passwordExpiredRule; $this->oldPasswordMapper = $oldPasswordMapper; $this->session = $session; + $this->notificationManager = $notificationManager; + $this->userNotificationConfigHandler = $userNotificationConfigHandler; } private function fixDI() { @@ -103,6 +115,8 @@ private function fixDI() { $this->hasher ); $this->session = \OC::$server->getSession(); + $this->notificationManager = \OC::$server->getNotificationManager(); + $this->userNotificationConfigHandler = new UserNotificationConfigHandler($this->config); } } @@ -196,11 +210,34 @@ public function saveOldPassword(GenericEvent $event) { $user = $this->getUser($event); $password = $event->getArgument('password'); + $userId = $user->getUID(); + $oldPassword = new OldPassword(); - $oldPassword->setUid($user->getUID()); + $oldPassword->setUid($userId); $oldPassword->setPassword($this->hasher->hash($password)); $oldPassword->setChangeTime($this->timeFactory->getTime()); $this->oldPasswordMapper->insert($oldPassword); + + // get previous marks + $aboutToExpireMark = $this->userNotificationConfigHandler->getMarkAboutToExpireNotificationSentFor($userId); + $expiredMark = $this->userNotificationConfigHandler->getMarkExpiredNotificationSentFor($userId); + + $this->userNotificationConfigHandler->resetExpirationMarks($userId); + + if ($aboutToExpireMark !== null) { + $notification = $this->notificationManager->createNotification(); + $notification->setApp('password_policy') + ->setUser($userId) + ->setObject('about_to_expire', $aboutToExpireMark); + $this->notificationManager->markProcessed($notification); + } + if ($expiredMark !== null) { + $notification = $this->notificationManager->createNotification(); + $notification->setApp('password_policy') + ->setUser($userId) + ->setObject('expired', $expiredMark); + $this->notificationManager->markProcessed($notification); + } } public function savePasswordForCreatedUser(GenericEvent $event) { @@ -214,6 +251,7 @@ public function savePasswordForCreatedUser(GenericEvent $event) { $oldPassword->setPassword($this->hasher->hash($password)); $oldPassword->setChangeTime($this->timeFactory->getTime()); $this->oldPasswordMapper->insert($oldPassword); + $this->userNotificationConfigHandler->resetExpirationMarks($userid); } /** diff --git a/lib/Jobs/PasswordExpirationNotifierJob.php b/lib/Jobs/PasswordExpirationNotifierJob.php new file mode 100644 index 00000000..bec4110e --- /dev/null +++ b/lib/Jobs/PasswordExpirationNotifierJob.php @@ -0,0 +1,188 @@ + + * @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\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 UserNotificationConfigHandler */ + private $unConfigHandler; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var ILogger */ + private $logger; + + public function __construct( + OldPasswordMapper $mapper, + IManager $manager, + UserNotificationConfigHandler $unConfigHandler, + ITimeFactory $timeFactory, + IURLGenerator $urlGenerator, + ILogger $logger + ) { + $this->mapper = $mapper; + $this->manager = $manager; + $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 + } + + $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); + } + } + } + + /** + * Send an "about to expire" notification using the password information + * in $passInfo. The password should expire after $expirationTime (90 days + * by default). This information will also be used in the notification + * @param OldPassword $passInfo the password information used to send the + * notification + * @param int $expirationTime the time to expire the password in seconds + * (for example, 90 days - in seconds) + */ + private function sendAboutToExpireNotification(OldPassword $passInfo, $expirationTime) { + if ($this->unConfigHandler->isSentAboutToExpireNotification($passInfo)) { + return; // notification already sent + } + + $notificationTimestamp = $this->timeFactory->getTime(); + + // we'll use the id of the passInfo as object id and marker + $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()); + + $linkAction = $notification->createAction(); + $linkAction->setLabel('Change password') + ->setLink($this->getNotificationLink(), 'GET'); + $notification->addAction($linkAction); + + $this->manager->notify($notification); + + $this->unConfigHandler->markAboutToExpireNotificationSentFor($passInfo); + } + + /** + * Send an "expired" notification using the password information + * in $passInfo. The password should expire after $expirationTime (90 days + * by default). This information will also be used in the notification + * @param OldPassword $passInfo the password information used to send the + * notification + * @param int $expirationTime the time to expire the password in seconds + * (for example, 90 days - in seconds) + */ + private function sendPassExpiredNotification(OldPassword $passInfo, $expirationTime) { + if ($this->unConfigHandler->isSentExpiredNotification($passInfo)) { + return; // notification already sent + } + + $notificationTimestamp = $this->timeFactory->getTime(); + + // we'll use the id of the passInfo as object id and marker + $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()); + + $linkAction = $notification->createAction(); + $linkAction->setLabel('Change password') + ->setLink($this->getNotificationLink(), 'GET'); + $notification->addAction($linkAction); + + $this->manager->notify($notification); + + $this->unConfigHandler->markExpiredNotificationSentFor($passInfo); + } + + private function getNotificationLink() { + return $this->urlGenerator->linkToRouteAbsolute( + 'settings.SettingsPage.getPersonal', + ['sectionid' => 'general'] + ); + } +} diff --git a/lib/Notifier.php b/lib/Notifier.php new file mode 100644 index 00000000..d21aa6d1 --- /dev/null +++ b/lib/Notifier.php @@ -0,0 +1,132 @@ + + * @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\AppFramework\Utility\ITimeFactory; +use OCP\L10N\IFactory; +use OCP\IL10N; + +class Notifier implements INotifier { + /** @var IFactory */ + protected $factory; + + /** @var ITimeFactory */ + protected $timeFactory; + /** + * @param \OCP\L10N\IFactory $factory + */ + public function __construct( + IFactory $factory, + ITimeFactory $timeFactory + ) { + $this->factory = $factory; + $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, IL10N $l) { + $params = $notification->getSubjectParameters(); + $notification->setParsedSubject( + (string) $l->t('Password expiration notice', $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 = $currentDateTime->diff($expirationDateTime); + + if ($interval->invert) { + $notification->setParsedMessage( + (string) $l->t('Your password expired %1$s days ago', [$interval->days]) + ); + } else { + $notification->setParsedMessage( + (string) $l->t('You have %1$s days to change your password', [$interval->days]) + ); + } + + foreach ($notification->getActions() as $action) { + switch ($action->getLabel()) { + case 'Change password': + $action->setParsedLabel( + (string) $l->t('Change Password') + ); + break; + } + + $notification->addParsedAction($action); + } + + return $notification; + } + + private function formatExpired(INotification $notification, IL10N $l) { + $params = $notification->getSubjectParameters(); + $notification->setParsedSubject( + (string) $l->t('Your password has expired', $params) + ); + + $messageParams = $notification->getMessageParameters(); + + $notification->setParsedMessage( + (string) $l->t('Please change your password to gain back access to your account', $messageParams) + ); + + foreach ($notification->getActions() as $action) { + switch ($action->getLabel()) { + case 'Change password': + $action->setParsedLabel( + (string) $l->t('Change Password') + ); + break; + } + + $notification->addParsedAction($action); + } + + return $notification; + } +} diff --git a/lib/Rules/PasswordHistory.php b/lib/Rules/PasswordHistory.php index c135d276..67f3d19b 100644 --- a/lib/Rules/PasswordHistory.php +++ b/lib/Rules/PasswordHistory.php @@ -65,7 +65,7 @@ public function verify($password, $val, $uid) { foreach($oldPasswords as $oldPassword) { if ($this->hasher->verify($password, $oldPassword->getPassword())) { throw new PolicyException( - $this->l10n->t('The password must be different to your previous %d passwords.', [$val])); + $this->l10n->t('The password must be different than your previous %d passwords.', [$val])); } } } diff --git a/lib/UserNotificationConfigHandler.php b/lib/UserNotificationConfigHandler.php new file mode 100644 index 00000000..368e4742 --- /dev/null +++ b/lib/UserNotificationConfigHandler.php @@ -0,0 +1,183 @@ + + * @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; + } + + /** + * Return the number of seconds until the passwords should expire or + * null if it isn't set (or disabled) or has a non parseable value + * @return int|null seconds + */ + 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) || $expirationTime < 0) { + return null; // passwords don't expire or have weird value + } + // the expiration time is currently stored in days, so we need to convert + // it to seconds. + return \intval($expirationTime) * 24 * 60 * 60; + } + + /** + * Return the number of seconds until a user should receive a notification + * that their password is about to expire. This _should_ be less than the value + * returned by the getExpirationTime function (you'll need to verify it outside) + * It will return null if the value isn't set (or disabled) or it has a + * non-parseable value + * @return int|null seconds + */ + 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) || $expirationTime < 0) { + return null; // passwords don't expire or have weird value + } + return \intval($expirationTime); + } + + /** + * Mark that a "password about to expire" notification has been sent. + * Note that we're using the id of the passInfo as marker, but this might change + * @param OldPassword $passInfo the information about the password. It has + * to include the userid owning the password and an id for the password + */ + public function markAboutToExpireNotificationSentFor(OldPassword $passInfo) { + $this->config->setUserValue($passInfo->getUid(), 'password_policy', 'aboutToExpireSent', $passInfo->getId()); + } + + /** + * Mark that a "password expired" notification has been sent. + * Note that we're using the id of the passInfo as marker, but this might change + * @param OldPassword $passInfo the information about the password. It has + * to include the userid owning the password and an id for the password + */ + public function markExpiredNotificationSentFor(OldPassword $passInfo) { + $this->config->setUserValue($passInfo->getUid(), 'password_policy', 'expiredSent', $passInfo->getId()); + } + + /** + * Get the mark set with markAboutToExpireNotificationSentFor for the specified user + * @param string $userid the user id to get the mark from + * @return string|null the mark or null if there is no mark + */ + public function getMarkAboutToExpireNotificationSentFor($userid) { + return $this->config->getUserValue($userid, 'password_policy', 'aboutToExpireSent', null); + } + + /** + * Get the mark set with markExpiredNotificationSentFor for the specified user + * @param string $userid the user id to get the mark from + * @return string|null the mark or null if there is no mark + */ + public function getMarkExpiredNotificationSentFor($userid) { + return $this->config->getUserValue($userid, 'password_policy', 'expiredSent', null); + } + + /** + * Check if a "password about to expire" notification has been sent for that + * password + * @param OldPassword $passInfo the password information to be checked + * @return bool true if the notification has been sent already, false otherwise. + * Note that we'll check only with the last password id sent + */ + 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 password id doesn't match the one stored, the notification hasn't been sent + return false; + } + return true; + } + + /** + * Check if a "password expired" notification has been sent for that + * password + * @param OldPassword $passInfo the password information to be checked + * @return bool true if the notification has been sent already, false otherwise. + * Note that we'll check only with the last password id sent + */ + 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 password id doesn't match the one stored, the notification hasn't been sent + return false; + } + return true; + } + + /** + * Reset the marks created with markAboutToExpireNotificationSentFor and + * markExpiredNotificationSentFor functions. This function should be call + * once the password for the user has been changed + * @param string $uid the id if the user that has changed his password + */ + public function resetExpirationMarks($uid) { + $targetKeys = [ + 'aboutToExpireSent', + 'expiredSent', + ]; + foreach ($targetKeys as $targetKey) { + $this->config->deleteUserValue($uid, 'password_policy', $targetKey); + } + } +} diff --git a/templates/admin.php b/templates/admin.php index 24e24263..51341e3f 100644 --- a/templates/admin.php +++ b/templates/admin.php @@ -80,7 +80,7 @@

- t('User Password policies:'));?> + t('User password policies:'));?>

  • @@ -90,10 +90,17 @@ t('days until user password expires'));?>
  • +
  • + +
diff --git a/templates/password.php b/templates/password.php index 1f865e92..5f5780e5 100644 --- a/templates/password.php +++ b/templates/password.php @@ -33,23 +33,24 @@

- t('Password changed required'));?> +
t('Your password has expired.')); ?>
+
t('Please choose a new password.')); ?>

- - + +
- - + +
- - + +
diff --git a/tests/Controller/PasswordControllerTest.php b/tests/Controller/PasswordControllerTest.php index 1b26402c..75f60261 100644 --- a/tests/Controller/PasswordControllerTest.php +++ b/tests/Controller/PasswordControllerTest.php @@ -135,7 +135,7 @@ public function testUpdatePasswordsMismatch() { $redirect_url = 'redirect/target'; $this->c->expects($this->once()) ->method('createPasswordTemplateResponse') - ->with($redirect_url, 'New passwords are not the same.'); + ->with($redirect_url, 'Password confirmation does not match the password.'); $this->userSession ->expects($this->never()) @@ -170,7 +170,7 @@ public function testUpdateWrongPassword() { $redirect_url = 'redirect/target'; $this->c->expects($this->once()) ->method('createPasswordTemplateResponse') - ->with($redirect_url, 'Incorrect current password supplied.'); + ->with($redirect_url, 'The current password is incorrect.'); $user ->expects($this->never()) diff --git a/tests/Db/OldPasswordMapperTest.php b/tests/Db/OldPasswordMapperTest.php index 7f306ab3..f86b9878 100644 --- a/tests/Db/OldPasswordMapperTest.php +++ b/tests/Db/OldPasswordMapperTest.php @@ -26,6 +26,7 @@ use OCA\PasswordPolicy\Db\OldPassword; use OCA\PasswordPolicy\Db\OldPasswordMapper; use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; use Test\TestCase; /** @@ -38,11 +39,12 @@ class OldPasswordMapperTest extends TestCase { private $mapper; /** @var string */ private $testUID = 'test1'; + private $testUIDs = ['test1', 'test2', 'test3']; private function resetDB() { $qb = $this->db->getQueryBuilder(); $qb->delete($this->mapper->getTableName()) - ->where($qb->expr()->eq('uid', $qb->createNamedParameter($this->testUID))); + ->where($qb->expr()->in('uid', $qb->createNamedParameter($this->testUIDs, IQueryBuilder::PARAM_STR_ARRAY))); $qb->execute(); } protected function setUp() { @@ -57,46 +59,112 @@ protected function tearDown() { $this->resetDB(); } - public function addInitialTestEntries() { + public function addInitialTestEntries($baseTime) { //add an initial entries - $oldPassword = new OldPassword(); - $oldPassword->setUid($this->testUID); - $oldPassword->setPassword('testpass1'); - $oldPassword->setChangeTime(\OC::$server->getTimeFactory()->getTime()); - $this->mapper->insert($oldPassword); - - $oldPassword = new OldPassword(); - $oldPassword->setUid($this->testUID); - $oldPassword->setPassword('testpass2'); - $oldPassword->setChangeTime(\OC::$server->getTimeFactory()->getTime()+1); - $this->mapper->insert($oldPassword); - - $oldPassword = new OldPassword(); - $oldPassword->setUid($this->testUID); - $oldPassword->setPassword('testpass3'); - $oldPassword->setChangeTime(\OC::$server->getTimeFactory()->getTime()+2); - $this->mapper->insert($oldPassword); + foreach ($this->testUIDs as $index => $uid) { + $oldPassword = new OldPassword(); + $oldPassword->setUid($uid); + $oldPassword->setPassword("{$uid}testpass1"); + $oldPassword->setChangeTime($baseTime); + $this->mapper->insert($oldPassword); + + $oldPassword = new OldPassword(); + $oldPassword->setUid($uid); + $oldPassword->setPassword("{$uid}testpass2"); + $oldPassword->setChangeTime($baseTime + (10 * ($index + 1))); + $this->mapper->insert($oldPassword); + + $oldPassword = new OldPassword(); + $oldPassword->setUid($uid); + $oldPassword->setPassword("{$uid}testpass3"); + $oldPassword->setChangeTime($baseTime + (20 * ($index + 1))); + $this->mapper->insert($oldPassword); + } } public function testGetOldPasswords() { - $this->assertCount(0, $this->mapper->getOldPasswords($this->testUID,3)); - $this->addInitialTestEntries(); + $uid = $this->testUIDs[0]; + $this->assertCount(0, $this->mapper->getOldPasswords($uid, 3)); + $this->addInitialTestEntries(\time()); - $oldPasswords = $this->mapper->getOldPasswords($this->testUID,2); + $oldPasswords = $this->mapper->getOldPasswords($uid, 2); $this->assertCount(2, $oldPasswords); - $this->assertNotSame("testpass1", $oldPasswords[0]->getPassword()); - $this->assertNotSame("testpass1", $oldPasswords[1]->getPassword()); + $this->assertNotSame("{$uid}testpass1", $oldPasswords[0]->getPassword()); + $this->assertNotSame("{$uid}testpass1", $oldPasswords[1]->getPassword()); - $this->assertCount(3, $this->mapper->getOldPasswords($this->testUID,3)); + $this->assertCount(3, $this->mapper->getOldPasswords($uid, 3)); } public function testLatestPassword() { - $this->assertNull($this->mapper->getLatestPassword($this->testUID)); - $this->addInitialTestEntries(); + $uid = $this->testUIDs[0]; + $this->assertNull($this->mapper->getLatestPassword($uid)); + $this->addInitialTestEntries(\time()); + + $latestPassword = $this->mapper->getLatestPassword($uid); + + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + } + + public function testGetPasswordsAboutToExpireAllOk() { + $baseTime = \time(); + $this->addInitialTestEntries($baseTime); + + + $passwordList = $this->mapper->getPasswordsAboutToExpire($baseTime + 14); + // last password change after the timestamp, so we shouldn't get any result + $this->assertCount(0, $passwordList); + } + + public function testGetPasswordsAboutToExpireSomePassMarked() { + $baseTime = \time(); + $this->addInitialTestEntries($baseTime); + + + $passwordList = $this->mapper->getPasswordsAboutToExpire($baseTime + 44); + $passwordList = \iterator_to_array($passwordList); // convert to array + // last password change before the timestamp + $this->assertCount(2, $passwordList); + + $uid = $this->testUIDs[0]; + $latestPassword = $passwordList[0]; + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + $this->assertSame($uid, $latestPassword->getUid()); + $this->assertLessThan($baseTime + 44, $latestPassword->getChangeTime()); + + $uid = $this->testUIDs[1]; + $latestPassword = $passwordList[1]; + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + $this->assertSame($uid, $latestPassword->getUid()); + $this->assertLessThan($baseTime + 44, $latestPassword->getChangeTime()); + } + + public function testGetPasswordsAboutToExpireAllMarked() { + $baseTime = \time(); + $this->addInitialTestEntries($baseTime); + + + $passwordList = $this->mapper->getPasswordsAboutToExpire($baseTime + 130); + $passwordList = \iterator_to_array($passwordList); // convert to array + // last password change before the timestamp + $this->assertCount(3, $passwordList); + + $uid = $this->testUIDs[0]; + $latestPassword = $passwordList[0]; + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + $this->assertSame($uid, $latestPassword->getUid()); + $this->assertLessThan($baseTime + 130, $latestPassword->getChangeTime()); - $latestPassword = $this->mapper->getLatestPassword($this->testUID); + $uid = $this->testUIDs[1]; + $latestPassword = $passwordList[1]; + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + $this->assertSame($uid, $latestPassword->getUid()); + $this->assertLessThan($baseTime +130, $latestPassword->getChangeTime()); - $this->assertSame("testpass3", $latestPassword->getPassword()); + $uid = $this->testUIDs[2]; + $latestPassword = $passwordList[2]; + $this->assertSame("{$uid}testpass3", $latestPassword->getPassword()); + $this->assertSame($uid, $latestPassword->getUid()); + $this->assertLessThan($baseTime + 130, $latestPassword->getChangeTime()); } -} \ No newline at end of file +} diff --git a/tests/HooksHandlerTest.php b/tests/HooksHandlerTest.php index b1700ccc..ebcfb51d 100644 --- a/tests/HooksHandlerTest.php +++ b/tests/HooksHandlerTest.php @@ -26,6 +26,7 @@ use OCA\PasswordPolicy\Db\OldPasswordMapper; use OCA\PasswordPolicy\Engine; use OCA\PasswordPolicy\HooksHandler; +use OCA\PasswordPolicy\UserNotificationConfigHandler; use OCA\PasswordPolicy\Rules\PasswordExpired; use OCA\PasswordPolicy\Rules\PolicyException; use OCP\AppFramework\Utility\ITimeFactory; @@ -34,6 +35,8 @@ use OCP\ISession; use OCP\IUser; use OCP\Security\IHasher; +use OCP\Notification\IManager; +use OCP\Notification\INotification; use Symfony\Component\EventDispatcher\GenericEvent; use Test\TestCase; @@ -57,7 +60,10 @@ class HooksHandlerTest extends TestCase { protected $session; /** @var HooksHandler | \PHPUnit_Framework_MockObject_MockObject */ protected $handler; - + /** @var UserNotificationConfigHandler | \PHPUnit_Framework_MockObject_MockObject */ + protected $unConfigHandler; + /** @var IManager | \PHPUnit_Framework_MockObject_MockObject */ + protected $manager; protected function setUp() { parent::setUp(); @@ -75,6 +81,8 @@ protected function setUp() { $this->passwordExpiredRule = $this->createMock(PasswordExpired::class); $this->oldPasswordMapper = $this->createMock(OldPasswordMapper::class); $this->session = $this->createMock(ISession::class); + $this->manager = $this->createMock(IManager::class); + $this->unConfigHandler = $this->createMock(UserNotificationConfigHandler::class); $this->handler = new HooksHandler( $this->config, @@ -84,8 +92,41 @@ protected function setUp() { $this->l10n, $this->passwordExpiredRule, $this->oldPasswordMapper, - $this->session + $this->session, + $this->manager, + $this->unConfigHandler ); + + $this->manager->method('createNotification') + ->will($this->returnCallback(function() { + $holder = []; + $mock = $this->createMock(INotification::class); + $mock->method('setApp')->will($this->returnCallback(function($app) use (&$holder, $mock) { + $holder['app'] = $app; + return $mock; + })); + $mock->method('setUser')->will($this->returnCallback(function($user) use (&$holder, $mock) { + $holder['user'] = $user; + return $mock; + })); + $mock->method('setObject')->will($this->returnCallback(function($obj, $id) use (&$holder, $mock) { + $holder['object'] = [$obj, $id]; + return $mock; + })); + $mock->method('getApp')->will($this->returnCallback(function() use (&$holder) { + return $holder['app']; + })); + $mock->method('getUser')->will($this->returnCallback(function() use (&$holder) { + return $holder['user']; + })); + $mock->method('getObjectType')->will($this->returnCallback(function() use (&$holder) { + return $holder['object'][0]; + })); + $mock->method('getObjectId')->will($this->returnCallback(function() use (&$holder) { + return $holder['object'][1]; + })); + return $mock; + })); } public function testGeneratePassword() { @@ -193,6 +234,9 @@ public function testSaveOldPassword() { $oldPassword->getChangeTime() === 12345; })); + $this->unConfigHandler->expects($this->once()) + ->method('resetExpirationMarks'); + $event = new GenericEvent(null, [ 'user' => $user, 'password' => 'secret' @@ -201,7 +245,6 @@ public function testSaveOldPassword() { } public function testSavePasswordForCreatedUser() { - $this->hasher->expects($this->once()) ->method('hash') ->with('secret') @@ -226,6 +269,64 @@ public function testSavePasswordForCreatedUser() { $this->handler->savePasswordForCreatedUser($event); } + public function testSaveOldPasswordClearingNotifications() { + /** @var IUser | \PHPUnit_Framework_MockObject_MockObject $user */ + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuid'); + + $this->hasher->expects($this->once()) + ->method('hash') + ->with('secret') + ->willReturn('somehash'); + + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn(12345); + + $this->oldPasswordMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(function(OldPassword $oldPassword){ + return $oldPassword->getPassword() === 'somehash' && + $oldPassword->getUid() === 'testuid' && + $oldPassword->getChangeTime() === 12345; + })); + + $this->unConfigHandler->method('getMarkAboutToExpireNotificationSentFor') + ->willReturn('222'); + $this->unConfigHandler->method('getMarkExpiredNotificationSentFor') + ->willReturn('333'); + + $this->unConfigHandler->expects($this->once()) + ->method('resetExpirationMarks'); + + $this->manager->expects($this->exactly(2)) + ->method('markProcessed') + ->withConsecutive( + [ + $this->callback(function($notif) { + return $notif->getApp() === 'password_policy' && + $notif->getUser() === 'testuid' && + $notif->getObjectType() === 'about_to_expire' && + $notif->getObjectId() === '222'; + }) + ], + [ + $this->callback(function($notif) { + return $notif->getApp() === 'password_policy' && + $notif->getUser() === 'testuid' && + $notif->getObjectType() === 'expired' && + $notif->getObjectId() === '333'; + }) + ] + ); + + $event = new GenericEvent(null, [ + 'user' => $user, + 'password' => 'secret' + ]); + $this->handler->saveOldPassword($event); + } + /** * @expectedException \UnexpectedValueException */ diff --git a/tests/Jobs/PasswordExpirationNotifierJobTest.php b/tests/Jobs/PasswordExpirationNotifierJobTest.php new file mode 100644 index 00000000..6bb67ecc --- /dev/null +++ b/tests/Jobs/PasswordExpirationNotifierJobTest.php @@ -0,0 +1,321 @@ + + * @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\Tests\Jobs; + +use OCP\Notification\IManager; +use OCP\Notification\INotification; +use OCP\Notification\IAction; +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; +use OCA\PasswordPolicy\Jobs\PasswordExpirationNotifierJob; +use Test\TestCase; + +class PasswordExpirationNotifierJobTest extends TestCase { + /** @var OldPasswordMapper */ + private $mapper; + + /** @var $manager */ + private $manager; + + /** @var UserNotificationConfigHandler */ + private $unConfigHandler; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var ILogger */ + private $logger; + + /** @var PasswordExpirationNotifierJob */ + private $job; + + protected function setUp() { + parent::setUp(); + $this->mapper = $this->getMockBuilder(OldPasswordMapper::class) + ->disableOriginalConstructor() + ->getMock(); + $this->manager = $this->getMockBuilder(IManager::class) + ->disableOriginalConstructor() + ->getMock(); + $this->unConfigHandler = $this->getMockBuilder(UserNotificationConfigHandler::class) + ->disableOriginalConstructor() + ->getMock(); + $this->timeFactory = $this->getMockBuilder(ITimeFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->urlGenerator = $this->getMockBuilder(IURLGenerator::class) + ->disableOriginalConstructor() + ->getMock(); + $this->logger = $this->getMockBuilder(ILogger::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->job = new PasswordExpirationNotifierJob( + $this->mapper, + $this->manager, + $this->unConfigHandler, + $this->timeFactory, + $this->urlGenerator, + $this->logger + ); + + $this->manager->method('createNotification') + ->will($this->returnCallback(function() { + $holder = []; + $mock = $this->createMock(INotification::class); + $mock->method('setApp')->will($this->returnCallback(function($app) use (&$holder, $mock) { + $holder['app'] = $app; + return $mock; + })); + $mock->method('setUser')->will($this->returnCallback(function($user) use (&$holder, $mock) { + $holder['user'] = $user; + return $mock; + })); + $mock->method('setObject')->will($this->returnCallback(function($obj, $id) use (&$holder, $mock) { + $holder['object'] = [$obj, $id]; + return $mock; + })); + $mock->method('setDateTime')->will($this->returnCallback(function($time) use (&$holder, $mock) { + $holder['datetime'] = $time; + return $mock; + })); + $mock->method('setSubject')->will($this->returnCallback(function($subject) use (&$holder, $mock) { + $holder['subject'] = $subject; + return $mock; + })); + $mock->method('setMessage')->will($this->returnCallback(function($message) use (&$holder, $mock) { + $holder['message'] = $message; + return $mock; + })); + $mock->method('setLink')->will($this->returnCallback(function($link) use (&$holder, $mock) { + $holder['link'] = $link; + return $mock; + })); + $mock->method('getApp')->will($this->returnCallback(function() use (&$holder) { + return $holder['app']; + })); + $mock->method('getUser')->will($this->returnCallback(function() use (&$holder) { + return $holder['user']; + })); + $mock->method('getObjectType')->will($this->returnCallback(function() use (&$holder) { + return $holder['object'][0]; + })); + $mock->method('getObjectId')->will($this->returnCallback(function() use (&$holder) { + return $holder['object'][1]; + })); + $mock->method('createAction')->will($this->returnCallback(function() { + $actionMock = $this->createMock(IAction::class); + $actionMock->method('setLabel')->will($this->returnSelf()); + $actionMock->method('setLink')->will($this->returnSelf()); + return $actionMock; + })); + return $mock; + })); + } + + public function testRunNoExpiration() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(null); + + $this->mapper->expects($this->never()) + ->method('getPasswordsAboutToExpire'); + $this->manager->expects($this->never()) + ->method('notify'); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunWrongRange() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(120); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $this->mapper->expects($this->never()) + ->method('getPasswordsAboutToExpire'); + $this->manager->expects($this->never()) + ->method('notify'); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunEmptyInfo() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 20); + + $this->mapper->expects($this->once()) + ->method('getPasswordsAboutToExpire') + ->willReturn([]); + $this->manager->expects($this->never()) + ->method('notify'); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + private function getOldPassword($id, $userid, $baseTime) { + $data = [ + 'id' => $id, + 'uid' => $userid, + 'password' => 'password', + 'changeTime' => $baseTime + ]; + return OldPassword::fromRow($data); + } + + public function testRunAboutToExpireAlreadySent() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 150); + + $returnedOldPassword = $this->getOldPassword('22', 'usertest', $baseTime); + $this->mapper->method('getPasswordsAboutToExpire') + ->willReturn([$returnedOldPassword]); + + $this->unConfigHandler->method('isSentAboutToExpireNotification') + ->willReturn(true); + + $this->manager->expects($this->never()) + ->method('notify'); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunExpiredAlreadySent() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 250); + + $returnedOldPassword = $this->getOldPassword('22', 'usertest', $baseTime); + $this->mapper->method('getPasswordsAboutToExpire') + ->willReturn([$returnedOldPassword]); + + $this->unConfigHandler->method('isSentExpiredNotification') + ->willReturn(true); + + $this->manager->expects($this->never()) + ->method('notify'); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunAboutToExpire() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 150); + + $returnedOldPassword = $this->getOldPassword('22', 'usertest', $baseTime); + $this->mapper->method('getPasswordsAboutToExpire') + ->willReturn([$returnedOldPassword]); + + $this->unConfigHandler->method('isSentAboutToExpireNotification') + ->willReturn(false); + + $this->manager->expects($this->once()) + ->method('notify'); + $this->unConfigHandler->expects($this->once()) + ->method('markAboutToExpireNotificationSentFor') + ->with($returnedOldPassword); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunExpired() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(120); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 250); + + $returnedOldPassword = $this->getOldPassword('22', 'usertest', $baseTime); + $this->mapper->method('getPasswordsAboutToExpire') + ->willReturn([$returnedOldPassword]); + + $this->unConfigHandler->method('isSentExpiredNotification') + ->willReturn(false); + + $this->manager->expects($this->once()) + ->method('notify'); + $this->unConfigHandler->expects($this->once()) + ->method('markExpiredNotificationSentFor') + ->with($returnedOldPassword); + + $this->invokePrivate($this->job, 'run', [[]]); + } + + public function testRunAboutToExpireNotConfigured() { + $this->unConfigHandler->method('getExpirationTime') + ->willReturn(180); + $this->unConfigHandler->method('getExpirationTimeForNormalNotification') + ->willReturn(null); + + $baseTime = 1531232050; + $this->timeFactory->method('getTime') + ->willReturn($baseTime + 150); + + $returnedOldPassword = $this->getOldPassword('22', 'usertest', $baseTime); + $this->mapper->method('getPasswordsAboutToExpire') + ->willReturn([$returnedOldPassword]); + + $this->unConfigHandler->method('isSentAboutToExpireNotification') + ->willReturn(false); + + $this->manager->expects($this->never()) + ->method('notify'); + $this->unConfigHandler->expects($this->never()) + ->method('markAboutToExpireNotificationSentFor') + ->with($returnedOldPassword); + + $this->invokePrivate($this->job, 'run', [[]]); + } +} \ No newline at end of file diff --git a/tests/NotifierTest.php b/tests/NotifierTest.php new file mode 100644 index 00000000..9c7c5590 --- /dev/null +++ b/tests/NotifierTest.php @@ -0,0 +1,206 @@ + + * @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\Tests; + +use OCA\PasswordPolicy\Notifier; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\L10N\IFactory; +use OCP\Notification\INotification; +use OCP\Notification\IAction; +use OC\L10N\L10N; +use Test\TestCase; + +class NotifierTest extends TestCase { + /** @var IFactory */ + private $factory; + + /** @var ITimeFactory */ + private $timeFactory; + + /** @var Notifier */ + private $notifier; + + protected function setUp() { + parent::setUp(); + $this->factory = $this->getMockBuilder(IFactory::class) + ->disableOriginalConstructor() + ->getMock(); + $this->timeFactory = $this->getMockBuilder(ITimeFactory::class) + ->disableOriginalConstructor() + ->getMock(); + + $l10n = $this->getMockBuilder(L10N::class) + ->disableOriginalConstructor() + ->getMock(); + $l10n->method('t') + ->will($this->returnCallback(function ($text, $parameters = []) { + return \vsprintf($text, $parameters); + })); + + $this->factory->method('get')->willReturn($l10n); + + $this->notifier = new Notifier($this->factory, $this->timeFactory); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testPrepareInvalidApp() { + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('another'); + $notification->method('getObjectType')->willReturn('local_share'); + $this->notifier->prepare($notification, 'en_US'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testPrepareInvalidUnknownObjectType() { + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('password_policy'); + $notification->method('getObjectType')->willReturn('local_share'); + $this->notifier->prepare($notification, 'en_US'); + } + + public function testPrepareAboutToExpire() { + $initialTime = 1531232050; + $expireIn = 10 * 24 * 60 * 60; // 10 days; + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('password_policy'); + $notification->method('getObjectType')->willReturn('about_to_expire'); + $notification->method('getSubjectParameters')->willReturn([$initialTime, $expireIn]); + // first parameter is the password change time and the second is the + // expiration time starting from the password change time + $notification->method('getMessageParameters')->willReturn([$initialTime, $expireIn]); + + $notification->method('getActions')->willReturn([]); // FIXME: to recheck if we include actions + + $this->timeFactory->method('getTime')->willReturn($initialTime + (7 * 24 * 60 * 60)); + + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with('Password expiration notice'); + + $notification->expects($this->once()) + ->method('setParsedMessage') + ->with('You have 3 days to change your password'); + + $this->notifier->prepare($notification, 'en_US'); + } + + public function testPrepareAboutToExpireWithAction() { + $initialTime = 1531232050; + $expireIn = 10 * 24 * 60 * 60; // 10 days; + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('password_policy'); + $notification->method('getObjectType')->willReturn('about_to_expire'); + $notification->method('getSubjectParameters')->willReturn([$initialTime, $expireIn]); + // first parameter is the password change time and the second is the + // expiration time starting from the password change time + $notification->method('getMessageParameters')->willReturn([$initialTime, $expireIn]); + + $action = $this->createMock(IAction::class); + $action->method('getLabel')->willReturn('Change password'); + $action->method('getLink')->willReturn('http://my.server/link/link'); + $action->method('getRequestType')->willReturn('GET'); + + $notification->method('getActions')->willReturn([$action]); + + $this->timeFactory->method('getTime')->willReturn($initialTime + (7 * 24 * 60 * 60)); + + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with('Password expiration notice'); + + $notification->expects($this->once()) + ->method('setParsedMessage') + ->with('You have 3 days to change your password'); + + $notification->expects($this->once()) + ->method('addParsedAction') + ->with($action); + + $this->notifier->prepare($notification, 'en_US'); + } + + public function testPrepareAboutToExpirePassDate() { + $initialTime = 1531232050; + $expireIn = 10 * 24 * 60 * 60; // 10 days; + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('password_policy'); + $notification->method('getObjectType')->willReturn('about_to_expire'); + $notification->method('getSubjectParameters')->willReturn([$initialTime, $expireIn]); + // first parameter is the password change time and the second is the + // expiration time starting from the password change time + $notification->method('getMessageParameters')->willReturn([$initialTime, $expireIn]); + + $notification->method('getActions')->willReturn([]); // FIXME: to recheck if we include actions + + $this->timeFactory->method('getTime')->willReturn($initialTime + (12 * 24 * 60 * 60)); + + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with('Password expiration notice'); + + $notification->expects($this->once()) + ->method('setParsedMessage') + ->with('Your password expired 2 days ago'); + + $this->notifier->prepare($notification, 'en_US'); + } + + public function testPrepareExpired() { + $initialTime = 1531232050; + $expireIn = 10 * 24 * 60 * 60; // 10 days; + $notification = $this->createMock(INotification::class); + $notification->method('getApp')->willReturn('password_policy'); + $notification->method('getObjectType')->willReturn('expired'); + $notification->method('getSubjectParameters')->willReturn([$initialTime, $expireIn]); + // first parameter is the password change time and the second is the + // expiration time starting from the password change time + $notification->method('getMessageParameters')->willReturn([$initialTime, $expireIn]); + + $action = $this->createMock(IAction::class); + $action->method('getLabel')->willReturn('Change password'); + $action->method('getLink')->willReturn('http://my.server/link/link'); + $action->method('getRequestType')->willReturn('GET'); + + $notification->method('getActions')->willReturn([$action]); // FIXME: to recheck if we include actions + + $this->timeFactory->method('getTime')->willReturn($initialTime + (12 * 24 * 60 * 60)); + + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with('Your password has expired'); + + $notification->expects($this->once()) + ->method('setParsedMessage') + ->with('Please change your password to gain back access to your account'); + + $notification->expects($this->once()) + ->method('addParsedAction') + ->with($action); + + $this->notifier->prepare($notification, 'en_US'); + } +} diff --git a/tests/Rules/PasswordHistoryTest.php b/tests/Rules/PasswordHistoryTest.php index 909d477d..d2c0454f 100644 --- a/tests/Rules/PasswordHistoryTest.php +++ b/tests/Rules/PasswordHistoryTest.php @@ -74,7 +74,7 @@ public function setUp() { * @dataProvider failDataProvider * @param string $password * @expectedException \OCA\PasswordPolicy\Rules\PolicyException - * @expectedExceptionMessage The password must be different to your previous 2 passwords. + * @expectedExceptionMessage The password must be different than your previous 2 passwords. */ public function testWithOldPassword($password) { $this->r->verify($password, 2, 'testuser'); diff --git a/tests/UserNotificationConfigHandlerTest.php b/tests/UserNotificationConfigHandlerTest.php new file mode 100644 index 00000000..1594b5bf --- /dev/null +++ b/tests/UserNotificationConfigHandlerTest.php @@ -0,0 +1,269 @@ + + * @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\Tests; + +use OCP\IConfig; +use OCA\PasswordPolicy\UserNotificationConfigHandler; +use OCA\PasswordPolicy\Db\OldPassword; +use Test\TestCase; + +class UserNotificationConfigHandlerTest extends TestCase { + /** @var IConfig */ + private $config; + + /** @var UserNotificationConfigHandler */ + private $unConfigHandler; + + protected function setUp() { + parent::setUp(); + $this->config = $this->getMockBuilder(IConfig::class) + ->disableOriginalConstructor() + ->getMock(); + + $this->unConfigHandler = new UserNotificationConfigHandler($this->config); + } + + public function falseyValueProvider() { + return [ + [false], + ['off'], + ['no'], + ['0'], + ['false'], + ]; + } + + /** + * @dataProvider falseyValueProvider + */ + public function testExpirationTimeNotChecked($falseyValue) { + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_checked', false, $falseyValue] + ])); + $this->assertNull($this->unConfigHandler->getExpirationTime()); + } + + public function testExpirationTimeWeirdValue() { + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_checked', false, true], + ['password_policy', 'spv_user_password_expiration_value', null, 'wwwww'] + ])); + $this->assertNull($this->unConfigHandler->getExpirationTime()); + } + + public function testExpirationTime() { + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_checked', false, true], + ['password_policy', 'spv_user_password_expiration_value', null, '90'] + ])); + // TODO: expiration time is currently stored in days, + // but the function returns seconds + $this->assertEquals(90*24*60*60, $this->unConfigHandler->getExpirationTime()); + } + + /** + * @dataProvider falseyValueProvider + */ + public function testExpirationTimeForNotificationNotChecked($falseyValue) { + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_notification_checked', false, $falseyValue] + ])); + $this->assertNull($this->unConfigHandler->getExpirationTimeForNormalNotification()); + } + + public function testExpirationTimeForNotificationWeirdValue() { + $expectedDefault = UserNotificationConfigHandler::DEFAULT_EXPIRATION_FOR_NORMAL_NOTIFICATION; + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_notification_checked', false, true], + ['password_policy', 'spv_user_password_expiration_notification_value', $expectedDefault, 'wwwww'] + ])); + $this->assertNull($this->unConfigHandler->getExpirationTimeForNormalNotification()); + } + + public function testExpirationTimeForNotification() { + $expectedDefault = UserNotificationConfigHandler::DEFAULT_EXPIRATION_FOR_NORMAL_NOTIFICATION; + $this->config->method('getAppValue') + ->will($this->returnValueMap([ + ['password_policy', 'spv_user_password_expiration_notification_checked', false, true], + ['password_policy', 'spv_user_password_expiration_notification_value', $expectedDefault, '12234'] + ])); + $this->assertEquals(12234, $this->unConfigHandler->getExpirationTimeForNormalNotification()); + } + + public function testMarkAboutToExpireNotificationSentFor() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('usertest1', 'password_policy', 'aboutToExpireSent', 34); + + $this->unConfigHandler->markAboutToExpireNotificationSentFor($oldPass); + } + + public function testMarkExpiredNotificationSentFor() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + + $this->config->expects($this->once()) + ->method('setUserValue') + ->with('usertest1', 'password_policy', 'expiredSent', 34); + + $this->unConfigHandler->markExpiredNotificationSentFor($oldPass); + } + + public function testGetMarkAboutToExpireNotificationSentFor() { + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('usertest1', 'password_policy', 'aboutToExpireSent', null) + ->willReturn('333'); + $this->assertEquals('333', $this->unConfigHandler->getMarkAboutToExpireNotificationSentFor('usertest1')); + } + + public function testGetMarkExpiredNotificationSentFor() { + $this->config->expects($this->once()) + ->method('getUserValue') + ->with('usertest1', 'password_policy', 'expiredSent', null) + ->willReturn('4444'); + $this->assertEquals('4444', $this->unConfigHandler->getMarkExpiredNotificationSentFor('usertest1')); + } + + public function testIsSentAboutToExpireNotificationNotSent() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'aboutToExpireSent', null, null], + ])); + $this->assertFalse($this->unConfigHandler->isSentAboutToExpireNotification($oldPass)); + } + + public function testIsSentAboutToExpireNotificationDifferentId() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'aboutToExpireSent', null, 20], + ])); + $this->assertFalse($this->unConfigHandler->isSentAboutToExpireNotification($oldPass)); + } + + public function testIsSentAboutToExpireNotificationAlreadySent() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'aboutToExpireSent', null, 34], + ])); + $this->assertTrue($this->unConfigHandler->isSentAboutToExpireNotification($oldPass)); + } + + public function testIsSentExpiredNotificationNotSent() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'expiredSent', null, null], + ])); + $this->assertFalse($this->unConfigHandler->isSentExpiredNotification($oldPass)); + } + + public function testIsSentExpiredNotificationDifferentId() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'expiredSent', null, 20], + ])); + $this->assertFalse($this->unConfigHandler->isSentExpiredNotification($oldPass)); + } + + public function testIsSentExpiredNotificationAlreadySent() { + $passData = [ + 'id' => 34, + 'uid' => 'usertest1', + 'password' => 'password', + 'changeTime' => 1002030, + ]; + $oldPass = OldPassword::fromRow($passData); + $this->config->method('getUserValue') + ->will($this->returnValueMap([ + ['usertest1', 'password_policy', 'expiredSent', null, 34], + ])); + $this->assertTrue($this->unConfigHandler->isSentExpiredNotification($oldPass)); + } + + public function testResetExpirationMarks() { + $targetKeys = [ + 'aboutToExpireSent', + 'expiredSent', + ]; + $this->config->expects($this->exactly(\count($targetKeys))) + ->method('deleteUserValue') + ->withConsecutive( + [$this->equalTo('usertest1'), $this->equalTo('password_policy'), $this->equalTo($targetKeys[0])], + [$this->equalTo('usertest1'), $this->equalTo('password_policy'), $this->equalTo($targetKeys[1])] + ); + $this->unConfigHandler->resetExpirationMarks('usertest1'); + } +} \ No newline at end of file