1<?php 2 3use dokuwiki\Extension\Plugin; 4use dokuwiki\HTTP\DokuHTTPClient; 5use dokuwiki\Search\Indexer; 6 7use dokuwiki\Utf8\PhpString; 8use dokuwiki\Utf8\Clean; 9 10/** 11 * DokuWiki Plugin passpolicy (Helper Component) 12 * 13 * Check password policies and generate random passwords accordingly 14 * 15 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 16 * @author Andreas Gohr <andi@splitbrain.org> 17 */ 18class helper_plugin_passpolicy extends Plugin 19{ 20 /** @var int number of character pools that have to be used at least */ 21 public $min_pools = 1; 22 23 /** @var int minimum length of the password (bytes) */ 24 public $min_length = 6; 25 26 /** @var string what type of password generation to use? */ 27 public $autotype = 'random'; 28 29 /** @var int minimum bit strength auto generated passwords should have */ 30 public $autobits = 64; 31 32 /** @var bool disallow common passwords */ 33 public $nocommon = true; 34 35 /** @var bool disallow leaked passwords */ 36 public $noleaked = true; 37 38 /** @var array allowed character pools */ 39 public $usepools = [ 40 'lower' => true, 41 'upper' => false, 42 'numeric' => true, 43 'special' => false 44 ]; 45 46 /** @var int number of consecutive letters that may not be in the username, 0 to disable */ 47 public $usernamecheck = 0; 48 49 /** @var int policy violation error */ 50 public $error = 0; 51 52 /** @var array the different pools to use when generating passwords */ 53 public $pools = [ 54 'lower' => 'abcdefghijklmnopqrstuvwxyz', 55 'upper' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 56 'numeric' => '0123456789', 57 'special' => '!"$%&/()=?{[]}\\*+~\'#,;.:-_<>|@' 58 ]; 59 60 protected $wordlist = []; 61 protected $wordlistlength = 0; 62 protected $msgshown = false; 63 64 const LENGTH_VIOLATION = 1; 65 const POOL_VIOLATION = 2; 66 const USERNAME_VIOLATION = 4; 67 const COMMON_VIOLATION = 8; 68 const LEAK_VIOLATION = 16; 69 70 /** 71 * Constructor 72 * 73 * Sets the policy from the DokuWiki config 74 */ 75 public function __construct() 76 { 77 $this->min_length = $this->getConf('minlen'); 78 $this->min_pools = $this->getConf('minpools'); 79 $this->usernamecheck = $this->getConf('user'); 80 $this->autotype = $this->getConf('autotype'); 81 $this->autobits = $this->getConf('autobits'); 82 $this->nocommon = $this->getConf('nocommon'); 83 $this->noleaked = $this->getConf('noleaked'); 84 85 $opts = explode(',', $this->getConf('pools')); 86 if ($opts !== []) { // ignore empty pool setups 87 $this->usepools = []; 88 foreach ($opts as $pool) { 89 $this->usepools[$pool] = true; 90 } 91 } 92 if ($this->min_pools > count($this->usepools)) $this->min_pools = $this->usepools; 93 } 94 95 /** 96 * Generates a random password according to the backend settings 97 * 98 * @param string $username 99 * @param int $try internal variable, do not set! 100 * @return bool|string 101 * @throws Exception when the generator fails to create a policy compliant password 102 */ 103 public function generatePassword($username, $try = 0) 104 { 105 if ($this->autotype == 'pronouncable') { 106 $pw = $this->pronouncablePassword(); 107 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 108 } 109 110 if ($this->autotype == 'phrase') { 111 $pw = $this->randomPassphrase(); 112 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 113 } 114 115 $pw = $this->randomPassword(); 116 if ($pw && $this->checkPolicy($pw, $username)) return $pw; 117 118 // still here? we might have clashed with the user name by accident 119 if ($try < 3) return $this->generatePassword($username, $try + 1); 120 121 // still here? now we have a real problem 122 throw new Exception('can\'t create a random password matching the password policy'); 123 } 124 125 /** 126 * Gives a human readable explanation of the current policy as plain text. 127 * 128 * @return string 129 */ 130 public function explainPolicy() 131 { 132 // we need access to the settings.php translations for the pool names 133 // FIXME core should provide a way to access them 134 global $conf; 135 $lang = []; 136 $path = __DIR__; 137 @include($path . '/lang/en/settings.php'); 138 if ($conf['lang'] != 'en') @include($path . '/lang/' . $conf['lang'] . '/settings.php'); 139 140 // load pool names 141 $pools = []; 142 foreach ($this->usepools as $pool => $on) { 143 if ($on) $pools[] = $lang['pools_' . $pool]; 144 } 145 146 $text = ''; 147 if ($this->min_length) { 148 $text .= sprintf($this->getLang('length'), $this->min_length) . "\n"; 149 } 150 if ($this->min_pools) { 151 $text .= sprintf($this->getLang('pools'), $this->min_pools, implode(', ', $pools)) . "\n"; 152 } 153 if ($this->usernamecheck == 1) { 154 $text .= $this->getLang('user1') . "\n"; 155 } 156 if ($this->usernamecheck > 1) { 157 $text .= sprintf($this->getLang('user2'), $this->usernamecheck) . "\n"; 158 } 159 if ($this->nocommon) { 160 $text .= $this->getLang('nocommon'); 161 } 162 if ($this->noleaked) { 163 $text .= $this->getLang('noleaked'); 164 } 165 166 return trim($text); 167 } 168 169 /** 170 * Checks a given password for policy violation 171 * 172 * @param string $pass true if the password validates against the policy 173 * @param string $username 174 * @return bool 175 */ 176 public function checkPolicy($pass, $username) 177 { 178 $this->error = 0; 179 180 // check length first: 181 if (strlen($pass) < $this->min_length) { 182 $this->error = helper_plugin_passpolicy::LENGTH_VIOLATION; 183 return false; 184 } 185 186 $matched_pools = 0; 187 if (!empty($this->usepools['lower'])) $matched_pools += (int)preg_match('/[a-z]/', $pass); 188 if (!empty($this->usepools['upper'])) $matched_pools += (int)preg_match('/[A-Z]/', $pass); 189 if (!empty($this->usepools['numeric'])) $matched_pools += (int)preg_match('/\d/', $pass); 190 if (!empty($this->usepools['special'])) { 191 $matched_pools += (int)preg_match( 192 '/[^A-Za-z0-9]/', 193 $pass 194 ); 195 } // we consider everything else special 196 if ($matched_pools < $this->min_pools) { 197 $this->error = helper_plugin_passpolicy::POOL_VIOLATION; 198 return false; 199 } 200 201 $pass = PhpString::strtolower($pass); 202 $username = PhpString::strtolower($username); 203 204 if ($this->usernamecheck && $username) { 205 // simplest case first 206 if (Clean::stripspecials($pass, '', '\._\-:\*') == Clean::stripspecials($username, '', '\._\-:\*')) { 207 $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION; 208 return false; 209 } 210 211 // find possible chunks in the lenght defined in policy 212 if ($this->usernamecheck > 1) { 213 $chunks = []; 214 for ($i = 0; $i < PhpString::strlen($pass) - $this->usernamecheck + 1; $i++) { 215 $chunk = PhpString::substr($pass, $i, $this->usernamecheck + 1); 216 if ($chunk == Clean::stripspecials($chunk, '', '\._\-:\*')) { 217 $chunks[] = $chunk; // only word chars are checked 218 } 219 } 220 221 // check chunks against user name 222 $chunks = array_map('preg_quote_cb', $chunks); 223 $re = implode('|', $chunks); 224 225 if (preg_match("/($re)/", $username)) { 226 $this->error = helper_plugin_passpolicy::USERNAME_VIOLATION; 227 return false; 228 } 229 } 230 } 231 232 if ($this->nocommon) { 233 $commons = file(__DIR__ . '/10k-common-passwords.txt'); 234 if (in_array("$pass\n", $commons)) { 235 $this->error = helper_plugin_passpolicy::COMMON_VIOLATION; 236 return false; 237 } 238 } 239 240 if ($this->noleaked && $this->isLeaked($pass)) { 241 $this->error = helper_plugin_passpolicy::LEAK_VIOLATION; 242 return false; 243 } 244 245 return true; 246 } 247 248 /** 249 * Creates a completely random password 250 * 251 * @return string 252 */ 253 protected function randomPassword() 254 { 255 $num_bits = $this->autobits; 256 $output = ''; 257 $characters = ''; 258 259 // always use these pools 260 foreach (['lower', 'upper', 'numeric'] as $pool) { 261 $pool_len = strlen($this->pools[$pool]); 262 $output .= $this->pools[$pool][$this->rand(0, $pool_len - 1)]; // add one char already 263 $characters .= $this->pools[$pool]; // add to full pool 264 $num_bits -= $this->bits($pool_len); 265 } 266 267 // if specials are wanted, limit them to a sane amount of 3 268 if (!empty($this->usepools['special'])) { 269 $pool_len = strlen($this->pools['special']); 270 $poolbits = $this->bits($pool_len); 271 272 $sane = ceil($this->autobits / 25); 273 for ($i = 0; $i < $sane; $i++) { 274 $output .= $this->pools['special'][$this->rand(0, $pool_len - 1)]; 275 $num_bits -= $poolbits; 276 } 277 } 278 279 // now prepare the full pool 280 $pool_len = strlen($characters); 281 $poolbits = $this->bits($pool_len); 282 283 // add random chars 284 do { 285 $output .= $characters[$this->rand(0, $pool_len - 1)]; 286 $num_bits -= $poolbits; 287 } while ($num_bits > 0 || strlen($output) < $this->min_length); 288 289 // shuffle to make sure our intial chars are not necessarily at the start 290 return str_shuffle($output); 291 } 292 293 /** 294 * Creates a pronouncable password 295 * 296 * @return bool|string the new password, false on error 297 */ 298 protected function pronouncablePassword() 299 { 300 $num_bits = $this->autobits; 301 302 // prepare speakable char classes 303 $consonants = 'bcdfghjklmnprstvwz'; //consonants except hard to speak ones 304 $vowels = 'aeiou'; 305 $all = $consonants . $vowels; 306 307 // prepare lengths 308 $c_len = strlen($consonants); 309 $v_len = strlen($vowels); 310 $a_len = $c_len + $v_len; 311 312 // prepare bitcounts 313 $c_bits = $this->bits($c_len); 314 $v_bits = $this->bits($v_len); 315 $a_bits = $this->bits($a_len); 316 317 // prepare policy compliant postfix 318 $postfix = ''; 319 if ($this->usepools['numeric']) { 320 $postfix .= $this->rand(10, 99); 321 $num_bits -= $this->bits(99 - 10); 322 } 323 if ($this->usepools['special']) { 324 $spec_len = strlen($this->pools['special']); 325 $postfix .= $this->pools['special'][random_int(0, $spec_len - 1)]; 326 $num_bits -= $this->bits($spec_len); 327 } 328 329 // create words 330 $output = ''; 331 do { 332 $output .= $consonants[$this->rand(0, $c_len - 1)]; 333 $output .= $vowels[$this->rand(0, $v_len - 1)]; 334 $output .= $all[$this->rand(0, $a_len - 1)]; 335 336 $num_bits -= $c_bits; 337 $num_bits -= $v_bits; 338 $num_bits -= $a_bits; 339 } while ($num_bits > 0 || strlen($output) < $this->min_length); 340 341 // now ensure policy compliance by uppercasing and postfixing 342 if ($this->usepools['upper']) $output = ucfirst($output); 343 if ($postfix) $output .= $postfix; 344 345 return $output; 346 } 347 348 /** 349 * Creates a passphrase from random words 350 * 351 * @return string 352 * @author Solar Designer 353 * @author Michael Samuel 354 */ 355 protected function randomPassphrase() 356 { 357 $num_bits = $this->autobits; 358 359 // prepare policy compliant prefix 360 $prefix = ''; 361 if ($this->usepools['numeric']) { 362 $prefix .= $this->rand(0, 999); 363 $num_bits -= $this->bits(999); 364 } 365 if ($this->usepools['special']) { 366 $spec_len = strlen($this->pools['special']); 367 $prefix .= $this->pools['special'][random_int(0, $spec_len - 1)]; 368 $num_bits -= $this->bits($spec_len); 369 } 370 371 // load the words to use 372 $this->loadwordlist(); 373 $wordbits = $this->bits($this->wordlistlength); 374 375 // generate simple all lowercase word phrase 376 $output = ''; 377 do { 378 $output .= $this->wordlist[$this->rand(0, $this->wordlistlength - 1)] . ' '; 379 $num_bits -= $wordbits; 380 } while ($num_bits > 0 || strlen($output) < $this->min_length); 381 382 // now ensure policy compliance by uppercasing and prefixing 383 if ($this->usepools['upper']) $output = ucwords($output); 384 if ($prefix) $output = $prefix . ' ' . $output; 385 386 return trim($output); 387 } 388 389 /** 390 * Return the number of bits in an integer 391 * 392 * @param int $number 393 * @return int 394 * @author Michael Samuel 395 */ 396 protected function bits($number) 397 { 398 $bits = 0; 399 400 while ($number > 0) { 401 $number >>= 1; 402 $bits += 1; 403 } 404 405 return $bits; 406 } 407 408 /** 409 * Random number generator using the best available source 410 * 411 * @param int $min 412 * @param int $max 413 * @return int 414 * @author Michael Samuel 415 */ 416 public function rand($min, $max) 417 { 418 $real_max = $max - $min; 419 $mask = (1 << $this->bits($real_max)) - 1; 420 421 try { 422 do { 423 $bytes = $this->trueRandomBytes(4); 424 $unpack = unpack("lnum", $bytes); 425 $integer = $unpack["num"] & $mask; 426 } while ($integer > $real_max); 427 } catch (Exception $e) { 428 if (!$this->msgshown) { 429 msg('No secure random generator available, falling back to less secure mt_rand()', -1); 430 $this->msgshown = true; 431 } 432 return mt_rand($min, $max); 433 } 434 435 return $integer + $min; 436 } 437 438 /** 439 * Return truly (pseudo) random bytes 440 * 441 * @param int $bytes number of bytes to get 442 * @return string binary random strings 443 * @throws Exception when no usable random generator is found 444 * @link http://www.php.net/manual/de/function.mt-rand.php#83655 445 * @author Mark Seecof 446 */ 447 protected function trueRandomBytes($bytes) 448 { 449 $strong = false; 450 $rbytes = false; 451 452 if (function_exists('openssl_random_pseudo_bytes')) { 453 $rbytes = openssl_random_pseudo_bytes($bytes, $strong); 454 } 455 456 // If no strong SSL randoms available, try OS the specific ways 457 if (!$strong) { 458 // Unix/Linux platform 459 $fp = @fopen('/dev/urandom', 'rb'); 460 if ($fp !== false) { 461 $rbytes = fread($fp, $bytes); 462 fclose($fp); 463 } 464 465 // MS-Windows platform 466 if (class_exists('COM')) { 467 // http://msdn.microsoft.com/en-us/library/aa388176(VS.85).aspx 468 try { 469 $CAPI_Util = new COM('CAPICOM.Utilities.1'); 470 $rbytes = $CAPI_Util->GetRandom($bytes, 0); 471 472 // if we ask for binary data PHP munges it, so we 473 // request base64 return value. We squeeze out the 474 // redundancy and useless ==CRLF by hashing... 475 if ($rbytes) $rbytes = md5($rbytes, true); 476 } catch (Exception $ex) { 477 // fail 478 } 479 } 480 } 481 if (strlen($rbytes) < $bytes) $rbytes = false; 482 483 if ($rbytes === false) throw new Exception('No true random generator available'); 484 485 return $rbytes; 486 } 487 488 /** 489 * loads the word list for phrase generation 490 * 491 * Words are taken from the wiki's own search index and are complemented with a 492 * list of 4096 English words. This list comes from a passphrase generator 493 * mentioned on sci.crypt, religious and possibly offensive words have been 494 * replaced with less conflict laden words 495 */ 496 protected function loadwordlist() 497 { 498 if ($this->wordlistlength) return; //list already loaded 499 500 // load one of the local word index files 501 $indexer = new helper_plugin_passpolicy__index(); 502 $this->wordlist = $indexer->getIndex('w', $this->rand(4, 6)); 503 $this->wordlist = array_filter( 504 $this->wordlist, 505 'utf8_isASCII' 506 ); //only ASCII, users might have trouble typing other things 507 508 // add our own word list to fill up 509 $this->wordlist += file(__DIR__ . '/words.txt', FILE_IGNORE_NEW_LINES); 510 $this->wordlistlength = count($this->wordlist); 511 } 512 513 /** 514 * Check if the given password has been leaked 515 * 516 * Uses k-anonymity 517 * 518 * @param string $password 519 * @return bool 520 */ 521 protected function isLeaked($password) 522 { 523 $sha1 = sha1($password); 524 $prefix = substr($sha1, 0, 5); 525 $url = "https://api.pwnedpasswords.com/range/$prefix"; 526 $http = new DokuHTTPClient(); 527 $http->timeout = 5; 528 $list = $http->get($url); 529 if (!$list) return false; // we didn't get a proper response, assume the password is okay 530 531 $results = explode("\n", $list); 532 foreach ($results as $result) { 533 [$result, ] = explode(':', $result); // strip off the number 534 $result = $prefix . strtolower($result); 535 if ($sha1 == $result) return true; // leak found 536 } 537 538 return false; 539 } 540} 541 542/** 543 * Class helper_plugin_passpolicy__index 544 * 545 * just to access a protected function 546 */ 547class helper_plugin_passpolicy__index extends Indexer 548{ 549 /** @inheritDoc */ 550 public function getIndex($idx, $suffix) 551 { 552 return parent::getIndex($idx, $suffix); 553 } 554} 555