1<?php 2/** 3 * Copyright 1999-2017 Horde LLC (http://www.horde.org/) 4 * 5 * See the enclosed file COPYING for license information (LGPL). If you did 6 * not receive this file, see http://www.horde.org/licenses/lgpl21. 7 * 8 * @author Chuck Hagenbuch <chuck@horde.org> 9 * @author Michael Slusarz <slusarz@horde.org> 10 * @category Horde 11 * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 12 * @package Auth 13 */ 14 15/** 16 * The Horde_Auth class provides some useful authentication-related utilities 17 * and constants for the Auth package. 18 * 19 * @author Chuck Hagenbuch <chuck@horde.org> 20 * @author Michael Slusarz <slusarz@horde.org> 21 * @category Horde 22 * @copyright 1999-2017 Horde LLC 23 * @license http://www.horde.org/licenses/lgpl21 LGPL-2.1 24 * @package Auth 25 */ 26class Horde_Auth 27{ 28 /** 29 * Authentication failure reason: Bad username and/or password 30 */ 31 const REASON_BADLOGIN = 1; 32 33 /** 34 * Authentication failure reason: Login failed 35 */ 36 const REASON_FAILED = 2; 37 38 /** 39 * Authentication failure reason: Password has expired 40 */ 41 const REASON_EXPIRED = 3; 42 43 /** 44 * Authentication failure reason: Logout due to user request 45 */ 46 const REASON_LOGOUT = 4; 47 48 /** 49 * Authentication failure reason: Logout with custom message 50 */ 51 const REASON_MESSAGE = 5; 52 53 /** 54 * Authentication failure reason: Logout due to session expiration 55 */ 56 const REASON_SESSION = 6; 57 58 /** 59 * Authentication failure reason: User is locked 60 */ 61 const REASON_LOCKED = 7; 62 63 /** 64 * 64 characters that are valid for APRMD5 passwords. 65 */ 66 const APRMD5_VALID = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 67 68 /** 69 * Characters used when generating a password: vowels 70 */ 71 const VOWELS = 'aeiouy'; 72 73 /** 74 * Characters used when generating a password: consonants 75 */ 76 const CONSONANTS = 'bcdfghjklmnpqrstvwxz'; 77 78 /** 79 * Characters used when generating a password: numbers 80 */ 81 const NUMBERS = '0123456789'; 82 83 /** 84 * Attempts to return a concrete Horde_Auth_Base instance based on 85 * $driver. 86 * 87 * @deprecated 88 * 89 * @param string $driver Either a driver name, or the full class name to 90 * use (class must extend Horde_Auth_Base). 91 * @param array $params A hash containing any additional configuration 92 * or parameters a subclass might need. 93 * 94 * @return Horde_Auth_Base The newly created concrete instance. 95 * @throws Horde_Auth_Exception 96 */ 97 public static function factory($driver, $params = null) 98 { 99 /* Base drivers (in Auth/ directory). */ 100 $class = __CLASS__ . '_' . Horde_String::ucfirst($driver); 101 if (@class_exists($class)) { 102 return new $class($params); 103 } 104 105 /* Explicit class name, */ 106 $class = $driver; 107 if (@class_exists($class)) { 108 return new $class($params); 109 } 110 111 throw new Horde_Auth_Exception(__CLASS__ . ': Class definition of ' . $driver . ' not found.'); 112 } 113 114 /** 115 * Formats a password using the current encryption. 116 * 117 * @param string $plaintext The plaintext password to encrypt. 118 * @param string $salt The salt to use to encrypt the password. 119 * If not present, a new salt will be 120 * generated. 121 * @param string $encryption The kind of pasword encryption to use. 122 * Defaults to md5-hex. 123 * @param boolean $show_encrypt Some password systems prepend the kind of 124 * encryption to the crypted password ({SHA}, 125 * etc). Defaults to false. 126 * 127 * @return string The encrypted password. 128 */ 129 public static function getCryptedPassword( 130 $plaintext, $salt = '', $encryption = 'md5-hex', $show_encrypt = false 131 ) 132 { 133 /* Get the salt to use. */ 134 $salt = self::getSalt($encryption, $salt, $plaintext); 135 136 /* Encrypt the password. */ 137 switch ($encryption) { 138 case 'aprmd5': 139 $length = strlen($plaintext); 140 $context = $plaintext . '$apr1$' . $salt; 141 $binary = pack('H*', hash('md5', $plaintext . $salt . $plaintext)); 142 143 for ($i = $length; $i > 0; $i -= 16) { 144 $context .= substr($binary, 0, ($i > 16 ? 16 : $i)); 145 } 146 for ($i = $length; $i > 0; $i >>= 1) { 147 $context .= ($i & 1) ? chr(0) : $plaintext[0]; 148 } 149 150 $binary = pack('H*', hash('md5', $context)); 151 152 for ($i = 0; $i < 1000; ++$i) { 153 $new = ($i & 1) ? $plaintext : substr($binary, 0, 16); 154 if ($i % 3) { 155 $new .= $salt; 156 } 157 if ($i % 7) { 158 $new .= $plaintext; 159 } 160 $new .= ($i & 1) ? substr($binary, 0, 16) : $plaintext; 161 $binary = pack('H*', hash('md5', $new)); 162 } 163 164 $p = array(); 165 for ($i = 0; $i < 5; $i++) { 166 $k = $i + 6; 167 $j = $i + 12; 168 if ($j == 16) { 169 $j = 5; 170 } 171 $p[] = self::_toAPRMD5((ord($binary[$i]) << 16) | 172 (ord($binary[$k]) << 8) | 173 (ord($binary[$j])), 174 5); 175 } 176 177 return '$apr1$' . $salt . '$' . implode('', $p) . self::_toAPRMD5(ord($binary[11]), 3); 178 179 case 'crypt': 180 case 'crypt-des': 181 case 'crypt-md5': 182 case 'crypt-sha256': 183 case 'crypt-sha512': 184 case 'crypt-blowfish': 185 return ($show_encrypt ? '{crypt}' : '') . crypt($plaintext, $salt); 186 187 case 'joomla-md5': 188 return md5($plaintext . $salt) . ':' . $salt; 189 190 case 'md5-base64': 191 $encrypted = base64_encode(pack('H*', hash('md5', $plaintext))); 192 return $show_encrypt ? '{MD5}' . $encrypted : $encrypted; 193 194 case 'msad': 195 return Horde_String::convertCharset('"' . $plaintext . '"', 'ISO-8859-1', 'UTF-16LE'); 196 197 case 'mysql': 198 $encrypted = '*' . Horde_String::upper(sha1(sha1($plaintext, true), false)); 199 return $show_encrypt ? '{MYSQL}' . $encrypted : $encrypted; 200 201 case 'plain': 202 return $plaintext; 203 204 case 'sha': 205 case 'sha1': 206 $encrypted = base64_encode(pack('H*', hash('sha1', $plaintext))); 207 return $show_encrypt ? '{SHA}' . $encrypted : $encrypted; 208 209 case 'sha256': 210 case 'ssha256': 211 $encrypted = base64_encode(pack('H*', hash('sha256', $plaintext . $salt)) . $salt); 212 return $show_encrypt ? '{SSHA256}' . $encrypted : $encrypted; 213 214 case 'smd5': 215 $encrypted = base64_encode(pack('H*', hash('md5', $plaintext . $salt)) . $salt); 216 return $show_encrypt ? '{SMD5}' . $encrypted : $encrypted; 217 218 case 'ssha': 219 $encrypted = base64_encode(pack('H*', hash('sha1', $plaintext . $salt)) . $salt); 220 return $show_encrypt ? '{SSHA}' . $encrypted : $encrypted; 221 222 case 'md5-hex': 223 default: 224 return ($show_encrypt) ? '{MD5}' . hash('md5', $plaintext) : hash('md5', $plaintext); 225 } 226 } 227 228 /** 229 * Returns a salt for the appropriate kind of password encryption. 230 * 231 * Optionally takes a seed and a plaintext password, to extract the seed 232 * of an existing password, or for encryption types that use the plaintext 233 * in the generation of the salt. 234 * 235 * @param string $encryption The kind of pasword encryption to use. 236 * Defaults to md5-hex. 237 * @param string $seed The seed to get the salt from (probably a 238 * previously generated password). Defaults to 239 * generating a new seed. 240 * @param string $plaintext The plaintext password that we're generating 241 * a salt for. Defaults to none. 242 * 243 * @return string The generated or extracted salt. 244 */ 245 public static function getSalt( 246 $encryption = 'md5-hex', $seed = '', $plaintext = '' 247 ) 248 { 249 switch ($encryption) { 250 case 'aprmd5': 251 if ($seed) { 252 return substr(preg_replace('/^\$apr1\$(.{8}).*/', '\\1', $seed), 0, 8); 253 } else { 254 $salt = ''; 255 $valid = self::APRMD5_VALID; 256 for ($i = 0; $i < 8; ++$i) { 257 $salt .= $valid[mt_rand(0, 63)]; 258 } 259 return $salt; 260 } 261 262 case 'crypt': 263 case 'crypt-des': 264 return $seed 265 ? substr(preg_replace('|^{crypt}|i', '', $seed), 0, 2) 266 : substr(base64_encode(hash('md5', mt_rand(), true)), 0, 2); 267 268 case 'crypt-blowfish': 269 return $seed 270 ? preg_replace('|^(?:{crypt})?(\$2.?\$(?:\d\d\$)?[0-9A-Za-z./]{22}).*|i', '$1', $seed) 271 : '$2$' . substr(base64_encode(hash('md5', sprintf('%08X%08X%08X', mt_rand(), mt_rand(), mt_rand()), true)), 0, 21) . '$'; 272 273 case 'crypt-md5': 274 return $seed 275 ? substr(preg_replace('|^{crypt}|i', '', $seed), 0, 12) 276 : '$1$' . base64_encode(hash('md5', sprintf('%08X%08X', mt_rand(), mt_rand()), true)) . '$'; 277 278 case 'crypt-sha256': 279 return $seed 280 ? substr(preg_replace('|^{crypt}|i', '', $seed), 0, strrpos($seed, '$')) 281 : '$5$' . base64_encode(hash('md5', sprintf('%08X%08X%08X', mt_rand(), mt_rand(), mt_rand()), true)) . '$'; 282 283 case 'crypt-sha512': 284 return $seed 285 ? substr(preg_replace('|^{crypt}|i', '', $seed), 0, strrpos($seed, '$')) 286 : '$6$' . base64_encode(hash('md5', sprintf('%08X%08X%08X', mt_rand(), mt_rand(), mt_rand()), true)) . '$'; 287 288 case 'joomla-md5': 289 $split = preg_split('/:/', $seed ); 290 return $split ? $split[1] : ''; 291 292 case 'sha256': 293 case 'ssha256': 294 return $seed 295 ? substr(base64_decode(preg_replace('|^{SSHA256}|i', '', $seed)), 32) 296 : substr(pack('H*', hash('sha256', substr(pack('h*', hash('md5', mt_rand())), 0, 8) . $plaintext)), 0, 4); 297 298 case 'smd5': 299 return $seed 300 ? substr(base64_decode(preg_replace('|^{SMD5}|i', '', $seed)), 16) 301 : substr(pack('H*', hash('md5', substr(pack('h*', hash('md5', mt_rand())), 0, 8) . $plaintext)), 0, 4); 302 303 case 'ssha': 304 return $seed 305 ? substr(base64_decode(preg_replace('|^{SSHA}|i', '', $seed)), 20) 306 : substr(pack('H*', hash('sha1', substr(pack('h*', hash('md5', mt_rand())), 0, 8) . $plaintext)), 0, 4); 307 308 default: 309 return ''; 310 } 311 } 312 313 /** 314 * Converts to allowed 64 characters for APRMD5 passwords. 315 * 316 * @param string $value The value to convert 317 * @param integer $count The number of iterations 318 * 319 * @return string $value converted to the 64 MD5 characters. 320 */ 321 protected static function _toAPRMD5($value, $count) 322 { 323 $aprmd5 = ''; 324 $count = abs($count); 325 $valid = self::APRMD5_VALID; 326 327 while (--$count) { 328 $aprmd5 .= $valid[$value & 0x3f]; 329 $value >>= 6; 330 } 331 332 return $aprmd5; 333 } 334 335 /** 336 * Generates a random, hopefully pronounceable, password. 337 * 338 * This can be used when resetting automatically a user's password. 339 * 340 * @return string A random password 341 */ 342 public static function genRandomPassword() 343 { 344 /* Alternate consonant and vowel random chars with two random numbers 345 * at the end. This should produce a fairly pronounceable password. */ 346 return substr(self::CONSONANTS, mt_rand(0, strlen(self::CONSONANTS) - 1), 1) . 347 substr(self::VOWELS, mt_rand(0, strlen(self::VOWELS) - 1), 1) . 348 substr(self::CONSONANTS, mt_rand(0, strlen(self::CONSONANTS) - 1), 1) . 349 substr(self::VOWELS, mt_rand(0, strlen(self::VOWELS) - 1), 1) . 350 substr(self::CONSONANTS, mt_rand(0, strlen(self::CONSONANTS) - 1), 1) . 351 substr(self::NUMBERS, mt_rand(0, strlen(self::NUMBERS) - 1), 1) . 352 substr(self::NUMBERS, mt_rand(0, strlen(self::NUMBERS) - 1), 1); 353 } 354 355 /** 356 * Checks whether a password matches some expected policy. 357 * 358 * @param string $password A password. 359 * @param array $policy A configuration with policy rules. Supported 360 * rules: 361 * 362 * - minLength: Minimum length of the password 363 * - maxLength: Maximum length of the password 364 * - maxSpace: Maximum number of white space characters 365 * 366 * The following are the types of characters required in a password. 367 * Either specific characters, character classes, or both can be 368 * required. Specific types are: 369 * 370 * - minUpper: Minimum number of uppercase characters 371 * - minLower: Minimum number of lowercase characters 372 * - minNumeric: Minimum number of numeric characters (0-9) 373 * - minAlphaNum: Minimum number of alphanumeric characters 374 * - minAlpha: Minimum number of alphabetic characters 375 * - minSymbol: Minimum number of punctuation / symbol characters 376 * - minNonAlpha: Minimum number of non-alphabetic characters 377 * 378 * Alternatively (or in addition to), the minimum number of character 379 * classes can be configured by setting the following. The valid range 380 * is 0 through 4 character classes may be required for a password. The 381 * classes are: 'upper', 'lower', 'number', and 'symbol'. For example: A 382 * password of 'p@ssw0rd' satisfies three classes ('number', 'lower', and 383 * 'symbol'), while 'passw0rd' only satisfies two classes ('lower' and 384 * 'number'). 385 * 386 * - minClasses: Minimum number (0 through 4) of character 387 * classes. 388 * 389 * @throws Horde_Auth_Exception if the password does not match the policy. 390 */ 391 public static function checkPasswordPolicy($password, array $policy) 392 { 393 // Check max/min lengths if specified in the policy. 394 if (isset($policy['minLength']) && 395 strlen($password) < $policy['minLength']) { 396 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::t("The password must be at least %d characters long!"), $policy['minLength'])); 397 } 398 if (isset($policy['maxLength']) && 399 strlen($password) > $policy['maxLength']) { 400 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::t("The password is too long; passwords may not be more than %d characters long!"), $policy['maxLength'])); 401 } 402 403 // Dissect the password in a localized way. 404 $classes = array(); 405 $alpha = $nonalpha = $alnum = $num = $upper = $lower = $space = $symbol = 0; 406 for ($i = 0; $i < strlen($password); $i++) { 407 $char = substr($password, $i, 1); 408 if (ctype_lower($char)) { 409 $lower++; $alpha++; $alnum++; $classes['lower'] = 1; 410 } elseif (ctype_upper($char)) { 411 $upper++; $alpha++; $alnum++; $classes['upper'] = 1; 412 } elseif (ctype_digit($char)) { 413 $num++; $nonalpha++; $alnum++; $classes['number'] = 1; 414 } elseif (ctype_punct($char)) { 415 $symbol++; $nonalpha++; $classes['symbol'] = 1; 416 } elseif (ctype_space($char)) { 417 $space++; $classes['symbol'] = 1; 418 } 419 } 420 421 // Check reamaining password policy options. 422 if (isset($policy['minUpper']) && $policy['minUpper'] > $upper) { 423 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d uppercase character.", "The password must contain at least %d uppercase characters.", $policy['minUpper']), $policy['minUpper'])); 424 } 425 if (isset($policy['minLower']) && $policy['minLower'] > $lower) { 426 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d lowercase character.", "The password must contain at least %d lowercase characters.", $policy['minLower']), $policy['minLower'])); 427 } 428 if (isset($policy['minNumeric']) && $policy['minNumeric'] > $num) { 429 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d numeric character.", "The password must contain at least %d numeric characters.", $policy['minNumeric']), $policy['minNumeric'])); 430 } 431 if (isset($policy['minAlpha']) && $policy['minAlpha'] > $alpha) { 432 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d alphabetic character.", "The password must contain at least %d alphabetic characters.", $policy['minAlpha']), $policy['minAlpha'])); 433 } 434 if (isset($policy['minAlphaNum']) && $policy['minAlphaNum'] > $alnum) { 435 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d alphanumeric character.", "The password must contain at least %d alphanumeric characters.", $policy['minAlphaNum']), $policy['minAlphaNum'])); 436 } 437 if (isset($policy['minNonAlpha']) && $policy['minNonAlpha'] > $nonalpha) { 438 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d numeric or special character.", "The password must contain at least %d numeric or special characters.", $policy['minNonAlpha']), $policy['minNonAlpha'])); 439 } 440 if (isset($policy['minClasses']) && $policy['minClasses'] > array_sum($classes)) { 441 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::t("The password must contain at least %d different types of characters. The types are: lower, upper, numeric, and symbols."), $policy['minClasses'])); 442 } 443 if (isset($policy['maxSpace']) && $policy['maxSpace'] < $space) { 444 if ($policy['maxSpace'] > 0) { 445 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::t("The password must contain less than %d whitespace characters."), $policy['maxSpace'] + 1)); 446 } 447 throw new Horde_Auth_Exception(Horde_Auth_Translation::t("The password must not contain whitespace characters.")); 448 } 449 if (isset($policy['minSymbol']) && $policy['minSymbol'] > $symbol) { 450 throw new Horde_Auth_Exception(sprintf(Horde_Auth_Translation::ngettext("The password must contain at least %d symbol character.", "The password must contain at least %d symbol characters.", $policy['minSymbol']), $policy['minSymbol'])); 451 } 452 } 453 454 /** 455 * Checks whether a password is too similar to a dictionary of strings. 456 * 457 * @param string $password A password. 458 * @param array $dict A dictionary to check for similarity, for 459 * example the user name or an old password. 460 * @param float $max The maximum allowed similarity in percent. 461 * 462 * @throws Horde_Auth_Exception if the password is too similar. 463 */ 464 public static function checkPasswordSimilarity( 465 $password, array $dict, $max = 80 466 ) 467 { 468 // Check for pass == dict, simple reverse strings, etc. 469 foreach ($dict as $test) { 470 if ((strcasecmp($password, $test) == 0) || 471 (strcasecmp($password, strrev($test)) == 0)) { 472 throw new Horde_Auth_Exception(Horde_Auth_Translation::t("The password is too simple to guess.")); 473 } 474 } 475 476 // Check for percentages similarity also. This will catch very simple 477 // Things like "password" -> "password2" or "xpasssword"... 478 // Also, don't allow simple changing of capitalization to pass 479 foreach ($dict as $test) { 480 similar_text(Horde_String::lower($password), Horde_String::lower($test), $percent); 481 if ($percent > $max) { 482 throw new Horde_Auth_Exception(Horde_Auth_Translation::t("The password is too simple to guess.")); 483 } 484 } 485 } 486} 487