diff --git a/_test/helper.test.php b/_test/helper.test.php index 1dfa807..db142e0 100644 --- a/_test/helper.test.php +++ b/_test/helper.test.php @@ -7,7 +7,24 @@ class helper_plugin_passpolicy_test extends DokuWikiTest { protected $pluginsEnabled = array('passpolicy'); - public function newPolicy($minl, $minp, $lower, $upper, $num, $special, $ucheck, $pron=true) { + /** + * + * @param int $minl + * @param int $minp + * @param boolean $lower + * @param boolean $upper + * @param boolean $num + * @param boolean $special + * @param boolean $ucheck + * @param boolean $pron + * @param int $oldpass + * @param int $expire_days + * @param int $expirewarn_days + * @param string $data_start date YYYY-MM-DD + * @return helper_plugin_passpolicy + */ + public function newPolicy($minl, $minp, $lower, $upper, $num, $special, $ucheck, $pron=true,$oldpass=0,$date_start='2030-01-01',$expire_days=0,$expirewarn_days=2) { + /* @var $policy helper_plugin_passpolicy */ $policy = plugin_load('helper', 'passpolicy'); $policy->min_pools = $minp; $policy->min_length = $minl; @@ -18,10 +35,25 @@ public function newPolicy($minl, $minp, $lower, $upper, $num, $special, $ucheck, 'special' => $special ); $policy->usernamecheck = $ucheck; - $policy->pronouncable = $pron; + $policy->pronouncable = $pron; + + $policy->oldpass = $oldpass; + $policy->conf['oldpass'] = $oldpass; + $policy->conf['expire'] = $expire_days; + $policy->conf['expirewarn'] = $expirewarn_days; + $policy->conf['date_start'] = $date_start; return $policy; } + + public function changePass($pass,$user = 'testuser') { + global $auth; + + return $auth->triggerUserMod('modify',array( + $user, + array('pass'=>$pass) + )); + } public function test_policies() { $policy = $this->newPolicy(6, 1, true, true, true, true, 0); @@ -173,5 +205,103 @@ public function test_selfcheck() { //echo "\n$pw1\n$pw2\n"; } + + public function test_passhistory() { + + $policy = $this->newPolicy(6, 4, true, true, true, true, 0, true,2); + $pw1 = $policy->generatePassword('testuser'); + $pw2 = $policy->generatePassword('testuser'); + $pw3 = $policy->generatePassword('testuser'); + + $this->assertTrue($this->changePass($pw1),'cannot change password'); + $this->assertNull($this->changePass($pw1),'last password can be used'); + $this->assertTrue($this->changePass($pw2),'cannot change password'); + $this->assertNull($this->changePass($pw1),'second last password can be used'); + $this->assertTrue($this->changePass($pw3),'cannot change password'); + $this->assertTrue($this->changePass($pw1),'third last password can be used'); + $this->assertNull($this->changePass($pw3),'second last password can be used'); + + //$policy = $this->newPolicy(18, 1, true, false, false, false, 0, false,0,0,2,date('Y-m-d',time()+3600*2)); + + } + + public function test_pass_expire() { + $policy = $this->newPolicy(6, 4, true, true, true, true, 0, true, + 0, //oldpass + '2010-01-01', //$date_start + 2,//expire_days=0 + 0//expirewarn_days + ); + + $userFile = $policy->passhistorydir .'testuser.txt'; + + TestUtils::rdelete($userFile); + $this->assertEquals(strtotime($policy->getConf('date_start')),$policy->checkPasswordExpired('testuser')); + $this->assertFalse($policy->checkPasswordExpireWarn('testuser')); + + touch($userFile,time()); + $this->assertFalse($policy->checkPasswordExpired('testuser')); + $this->assertFalse($policy->checkPasswordExpireWarn('testuser')); + TestUtils::rdelete($userFile); + + $changedTime = time() - 3600*48-1; + touch($userFile,$changedTime); + $this->assertEquals($changedTime+3600*48,$policy->checkPasswordExpired('testuser'),'password is not expired'); + $this->assertFalse($policy->checkPasswordExpireWarn('testuser')); + + } + + public function test_pass_expire_warn() { + $policy = $this->newPolicy(6, 4, true, true, true, true, 0, true, + 0, //oldpass + '2010-01-01', //$date_start + 14,//expire_days=0 + 2//expirewarn_days + ); + + $userFile = $policy->passhistorydir .'testuser.txt'; + + TestUtils::rdelete($userFile); + $this->assertEquals(strtotime($policy->getConf('date_start')),$policy->checkPasswordExpireWarn('testuser')); + + touch($userFile,time()); + $this->assertFalse($policy->checkPasswordExpired('testuser')); + $this->assertFalse($policy->checkPasswordExpireWarn('testuser')); + TestUtils::rdelete($userFile); + + $changedTime = time() - 3600*24*12-1; + touch($userFile,$changedTime); + $this->assertFalse($policy->checkPasswordExpired('testuser')); + $this->assertEquals($changedTime+3600*24*14,$policy->checkPasswordExpireWarn('testuser'),'we have to warn!'); + + } + + public function test_pass_date_start() { + $policy = $this->newPolicy(6, 4, true, true, true, true, 0, true, + 0, //oldpass + date('Y-m-d',time()+3600*24*5), //$date_start + 2,//expire_days=0 + 6//expirewarn_days + ); + + $userFile = $policy->passhistorydir .'testuser.txt'; + TestUtils::rdelete($userFile); + + $this->assertEquals(strtotime($policy->getConf('date_start')),$policy->checkPasswordExpireWarn('testuser'),'we have to warn!'); + $this->assertFalse($policy->checkPasswordExpired('testuser'),'date start is in future'); + + $policy = $this->newPolicy(6, 4, true, true, true, true, 0, true, + 0, //oldpass + date('Y-m-d',time()+3600*24*5), //$date_start + 2,//expire_days=0 + 4//expirewarn_days + ); + + $userFile = $policy->passhistorydir .'testuser.txt'; + + $this->assertFalse($policy->checkPasswordExpireWarn('testuser'),'its not time to warn'); + $this->assertFalse($policy->checkPasswordExpired('testuser'),'date start is in future'); + + } } diff --git a/action.php b/action.php index 71fe16e..3c16b84 100644 --- a/action.php +++ b/action.php @@ -24,12 +24,75 @@ public function register(Doku_Event_Handler &$controller) { $controller->register_hook('HTML_RESENDPWDFORM_OUTPUT', 'BEFORE', $this, 'handle_forms'); $controller->register_hook('AUTH_USER_CHANGE', 'BEFORE', $this, 'handle_passchange'); + $controller->register_hook('AUTH_USER_CHANGE', 'BEFORE', $this, 'save_pass'); $controller->register_hook('AUTH_PASSWORD_GENERATE', 'BEFORE', $this, 'handle_passgen'); $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, '_ajax_call'); + + $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'check_act'); + + } + /** + * Handles the warn message and redirects user to the profile page in case of password expired + * + * @param Doku_Event $event + * @param unknown $param + */ + function check_act(Doku_Event &$event,$param) { + if(!$_SERVER['REMOTE_USER']) return; + + if(in_array($event->data,array('login','logout','profile','admin'))) return; + + /* @var $passpolicy helper_plugin_passpolicy */ + $passpolicy = $this->loadHelper('passpolicy'); + + if($expireDate = $passpolicy->checkPasswordExpired()) { //password is expired + msg(sprintf($this->getLang('expired'), dformat($expireDate))); + $event->data = 'profile'; + } else if($expireDate = $passpolicy->checkPasswordExpireWarn()) { //show warn message + if(!isset($_COOKIE['passpolicy_msg_hide'])) { + msg(sprintf($this->getLang('expirewarn'), dformat($expireDate),tpl_action('profile',1,false,true))); + } + + } + + } + + /** + * Save the password to the pass history + * + * @param Doku_Event $event + * @param unknown $param + */ + function save_pass(Doku_Event &$event,$param) { + if($event->data['type'] == 'create') { + $user = $event->data['params'][0]; + $pass = $event->data['params'][1]; + } elseif($event->data['type'] == 'modify') { + $user = $event->data['params'][0]; + if(!isset($event->data['params'][1]['pass'])) { + return; //password is not changed, nothing to do + } + $pass = $event->data['params'][1]['pass']; + } else { + return; + } + + /* @var $passpolicy helper_plugin_passpolicy */ + $passpolicy = $this->loadHelper('passpolicy'); + + $passpolicy->savePassword2PassHistory($user,$pass); + } + + /** + * Check for password policy + * + * @param Doku_Event $event + * @param unknown $param + */ function _ajax_call(Doku_Event &$event,$param) { if ($event->data !== 'plugin_passpolicy') { return; @@ -42,10 +105,13 @@ function _ajax_call(Doku_Event &$event,$param) { /* @var $INPUT \Input */ global $INPUT; + $user = $INPUT->post->str('user',$_SERVER['REMOTE_USER']); $pass = $INPUT->post->str('pass'); + + $passpolicy = $this->loadHelper('passpolicy'); - if(!$passpolicy->checkPolicy($pass, $_SERVER['REMOTE_USER'])) { + if(!$passpolicy->checkPolicy($pass, $user)) { // passpolicy not matched, throw error echo '0'; } else { @@ -70,6 +136,7 @@ public function handle_forms(Doku_Event &$event, $param) { $passpolicy = plugin_load('helper', 'passpolicy'); $html = '

'.$passpolicy->explainPolicy().'

'; $event->data->insertElement(++$pos, $html); + } /** diff --git a/conf/default.php b/conf/default.php index 465d57d..e8889da 100644 --- a/conf/default.php +++ b/conf/default.php @@ -12,3 +12,9 @@ $conf['autotype'] = 'random'; $conf['autobits'] = 44; + + +$conf['oldpass'] = 0; +$conf['expire'] = 90; +$conf['expirewarn'] = 2; +$conf['date_start'] = '2030-01-01'; diff --git a/conf/metadata.php b/conf/metadata.php index ea4d347..83775a0 100644 --- a/conf/metadata.php +++ b/conf/metadata.php @@ -12,3 +12,10 @@ $meta['autotype'] = array('multichoice', '_choices' => array('random', 'phrase', 'pronouncable')); $meta['autobits'] = array('numeric', '_min' => 24); + +$meta['oldpass'] = array('numeric', '_min' => 0); +$meta['expire'] = array('numeric', '_min' => 0); //days +$meta['expirewarn'] = array('numeric', '_min' => 0); //days before expire +$meta['date_start'] = array('string', '_pattern' => '/20\d{2}-\d{2}-\d{2}/'); + + diff --git a/helper.php b/helper.php index 067eca5..c3a85be 100644 --- a/helper.php +++ b/helper.php @@ -32,6 +32,10 @@ class helper_plugin_passpolicy extends DokuWiki_Plugin { 'numeric' => true, 'special' => false ); + + /** @var string path to pass history dir */ + public $passhistorydir = null; + /** @var int number of consecutive letters that may not be in the username, 0 to disable */ public $usernamecheck = 0; @@ -54,6 +58,7 @@ class helper_plugin_passpolicy extends DokuWiki_Plugin { const LENGTH_VIOLATION = 1; const POOL_VIOLATION = 2; const USERNAME_VIOLATION = 4; + const OLDPASS_VIOLATION = 8; /** * Constructor @@ -61,12 +66,17 @@ class helper_plugin_passpolicy extends DokuWiki_Plugin { * Sets the policy from the DokuWiki config */ public function __construct() { + global $conf; + $this->min_length = $this->getConf('minlen'); $this->min_pools = $this->getConf('minpools'); $this->usernamecheck = $this->getConf('user'); $this->autotype = $this->getConf('autotype'); $this->autobits = $this->getConf('autobits'); + $this->oldpass = $this->getConf('oldpass'); + $this->passhistorydir = $conf['metadir'].'/_passhistory/'; + $opts = explode(',', $this->getConf('pools')); if(count($opts)) { // ignore empty pool setups $this->usepools = array(); @@ -129,13 +139,15 @@ public function explainPolicy() { $text = ''; if($this->min_length) - $text .= sprintf($this->getLang('length'), $this->min_length)."\n"; + $text .= sprintf($this->getLang('length'), $this->min_length)."
"; if($this->min_pools) - $text .= sprintf($this->getLang('pools'), $this->min_pools, join(', ', $pools))."\n"; + $text .= sprintf($this->getLang('pools'), $this->min_pools, join(', ', $pools))."
"; if($this->usernamecheck == 1) - $text .= $this->getLang('user1')."\n"; + $text .= $this->getLang('user1')."
"; if($this->usernamecheck > 1) - $text .= sprintf($this->getLang('user2'), $this->usernamecheck)."\n"; + $text .= sprintf($this->getLang('user2'), $this->usernamecheck)."
"; + if($this->oldpass > 0) + $text .= sprintf($this->getLang('oldpass'), $this->oldpass)."
"; return trim($text); } @@ -151,7 +163,7 @@ public function checkPolicy($pass, $username) { $this->error = 0; // check length first: - if(strlen($pass) < $this->min_length) { + if(utf8_strlen($pass) < $this->min_length) { $this->error = helper_plugin_passpolicy::LENGTH_VIOLATION; return false; } @@ -167,11 +179,11 @@ public function checkPolicy($pass, $username) { } if($this->usernamecheck && $username) { - $pass = utf8_strtolower($pass); - $username = utf8_strtolower($username); + $pass2 = utf8_strtolower($pass); + $username = utf8_strtolower($username); // simplest case first - if(utf8_stripspecials($pass, '', '\._\-:\*') == utf8_stripspecials($username, '', '\._\-:\*')) { + if(utf8_stripspecials($pass2, '', '\._\-:\*') == utf8_stripspecials($username, '', '\._\-:\*')) { $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION; return false; } @@ -179,8 +191,8 @@ public function checkPolicy($pass, $username) { // find possible chunks in the lenght defined in policy if($this->usernamecheck > 1) { $chunks = array(); - for($i = 0; $i < utf8_strlen($pass) - $this->usernamecheck + 1; $i++) { - $chunk = utf8_substr($pass, $i, $this->usernamecheck + 1); + for($i = 0; $i < utf8_strlen($pass2) - $this->usernamecheck + 1; $i++) { + $chunk = utf8_substr($pass2, $i, $this->usernamecheck + 1); if($chunk == utf8_stripspecials($chunk, '', '\._\-:\*')) { $chunks[] = $chunk; // only word chars are checked } @@ -196,6 +208,19 @@ public function checkPolicy($pass, $username) { } } } + + //dbg($pass); + if($this->oldpass > 0 && + $oldPasswords = $this->getUserPassHistory($username) + ) { + foreach($oldPasswords as $oldPassword) { + if(auth_verifyPassword($pass, $oldPassword)) { + $this->error = helper_plugin_passpolicy::OLDPASS_VIOLATION; + return false; + } + } + + } return true; } @@ -455,6 +480,129 @@ protected function loadwordlist() { $this->wordlist += file(dirname(__FILE__).'/words.txt', FILE_IGNORE_NEW_LINES); $this->wordlistlength = count($this->wordlist); } + + + /** + * check if password is expired + * + * @param string $user + * @return boolean|timestamp timestamp when password is expired with expireing day + */ + public function checkPasswordExpired($user=false) { + $dateStart = strtotime($this->getConf('date_start')); + + $datePassExpire = $this->getDatePassExpire($user); + + if($datePassExpire && + $datePassExpire >= $dateStart && + $datePassExpire < time() + ) { + return $datePassExpire; + } + + return false; + } + + /** + * check if we have to warn the user about an expireing password + * + * @param string $user + * @return boolean|timestamp false or timestamp when the password will expire. + */ + public function checkPasswordExpireWarn($user=false) { + $timespanWarn = intval($this->getConf('expirewarn')); + if(!$timespanWarn) return false; + + $dateStart = strtotime($this->getConf('date_start')); + + $datePassExpire = $this->getDatePassExpire($user); + + if($datePassExpire && + max(array($datePassExpire,$dateStart)) < (time() + $timespanWarn*3600*24) + ) { + return max(array($datePassExpire,$dateStart)); + } + return false; + + } + + /** + * returns the date when the current password will expire, wont respect dateStart to extend the time + * + * @param string $user + * @return false or timestamp + */ + protected function getDatePassExpire($user = false) { + $passChanged = $this->getPassHistoryChangeDate($user); + $dateStart = strtotime($this->getConf('date_start')); + + if($passChanged) { //user has already changed password since plugin installation + $expire_interval = $this->getConf('expire') * 3600*24; + if($expire_interval) {//next password change will be then + $expireDate = $passChanged + $expire_interval; + } else {//no need to change password + $expireDate = false; + } + } else { //user has not changed password since plugin installation, password will expire at start date + $expireDate = $dateStart; + } + + return $expireDate; + } + + /** + * returns the passHistory entry for a given user + * + * @param string $user + * @return NULL|boolean|array assotiative array 'date','pass'[] + */ + protected function getUserPassHistory($user=false) { + if(!$user && $_SERVER['REMOTE_USER']) $user = $_SERVER['REMOTE_USER']; + + $passhistory = io_readFile($this->passhistorydir . utf8_encodeFN($user).'.txt'); + if(!$passhistory) { + return false; + } + $passhistory = explode("\n", $passhistory); + + if(is_array($passhistory)) { + $passhistory = array_slice($passhistory, 0, $this->getConf('oldpass')); + return $passhistory; + } else { + return false; + } + + } + + /** + * returns the modification date of the passhistory file, which is the date when user changed password + * @param string $user + */ + protected function getPassHistoryChangeDate($user=false) { + if(!$user && $_SERVER['REMOTE_USER']) $user = $_SERVER['REMOTE_USER']; + + return @filemtime($this->passhistorydir . utf8_encodeFN($user) .'.txt'); + } + + /** + * saves the given password to the passHistory file + * + * @param string $user + * @param string $pass + */ + public function savePassword2PassHistory($user,$pass) { + $passhistory = $this->getUserPassHistory($user); + if(!$passhistory) { + $passhistory = auth_cryptPassword($pass); + } else { + array_unshift($passhistory, auth_cryptPassword($pass)); + $passhistory = array_slice($passhistory, 0, $this->getConf('oldpass')); + $passhistory = implode("\n", $passhistory); + } + + io_saveFile($this->passhistorydir .utf8_encodeFN($user).'.txt', $passhistory); + } + } /** diff --git a/lang/en/lang.php b/lang/en/lang.php index 783c8c4..47aa285 100644 --- a/lang/en/lang.php +++ b/lang/en/lang.php @@ -10,10 +10,14 @@ $lang['pools'] = 'Your password needs to use characters from at least %d of the following types: %s.'; $lang['user1'] = 'Your password may not contain your username.'; $lang['user2'] = 'Your password may only use %d or less consecutive characters that appear in your username.'; +$lang['oldpass'] = 'Your password may not be equal to the last %d password(s).'; $lang['js']['strength0'] = 'very weak'; $lang['js']['strength1'] = 'weak'; $lang['js']['strength2'] = 'decent'; $lang['js']['strength3'] = 'strong'; +$lang['expirewarn'] = 'Your password is going to be expired at %s. You can change your password here: %s. Or hide it for today'; +$lang['expired'] = 'Your password is expired since %s, you have to change your password! If you forgot your password, logout/login and use the reset password link.'; + //Setup VIM: ex: et ts=4 : diff --git a/lang/en/settings.php b/lang/en/settings.php index 2d88c0e..9d4d19a 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -22,4 +22,9 @@ $lang['pools_numeric'] = 'numbers'; $lang['pools_special'] = 'special chars (eg. !, $, #, %)'; +$lang['oldpass'] = 'Number of old passwords, which will be checked. 0 to disable checking of old passwords.'; +$lang['expire'] = 'Number in days of password interval. 0 to disable expiring passwords'; +$lang['expirewarn'] = 'Number in days the user will be informed before the password expires.'; +$lang['date_start'] = 'Set the beginning day when passwords will first expire after plugin installation, so that user are forced to change their password according to the passpolicy. This date is used to give the user a transition time to change their password after plugin installation. Setting the date to far future will disable expiring passwords. (Format YYYY-MM-DD)'; + //Setup VIM: ex: et ts=4 : diff --git a/script.js b/script.js index 8890c8b..6a6ec66 100644 --- a/script.js +++ b/script.js @@ -51,11 +51,14 @@ jQuery(function () { function checkpolicy($field,indicator) { var pass = $field.val(); + var user = $field.closest('form').find('input[name="userid"]').val(); + jQuery.post( DOKU_BASE+'lib/exe/ajax.php', { call:'plugin_passpolicy', - pass:pass + pass:pass, + user:user }, function(response){ if(response === '1') { @@ -96,7 +99,7 @@ jQuery(function () { */ $passfield.each(function(){ var $field = jQuery(this); - + var indicator = document.createElement('p'); indicator.className = 'passpolicy__indicator'; @@ -107,4 +110,11 @@ jQuery(function () { -}); \ No newline at end of file +}); + +jQuery(function(){ + jQuery('#passpolicy_msg_hide').click(function(){ + jQuery(this).closest('div').hide(200); + jQuery.cookie('passpolicy_msg_hide','hide',{expires:1}); + }); +});