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