. * * @license GPL * @package LibreNMS * @link http://librenms.org * @subpackage Authentication * @author f0o * @copyright 2014 f0o, LibreNMS * @copyright 2017 Tony Murray */ namespace LibreNMS\Authentication; use LibreNMS\Config; use LibreNMS\Exceptions\AuthenticationException; class TwoFactor { /** * Key Interval in seconds. * Set to 30s due to Google-Authenticator limitation. * Sadly Google-Auth is the most used Non-Physical OTP app. */ const KEY_INTERVAL = 30; /** * Size of the OTP. * Set to 6 for the same reasons as above. */ const OTP_SIZE = 6; /** * Window to honour whilest verifying OTP. */ const OTP_WINDOW = 4; /** * Base32 Decoding dictionary */ private static $base32 = array( "A" => 0, "B" => 1, "C" => 2, "D" => 3, "E" => 4, "F" => 5, "G" => 6, "H" => 7, "I" => 8, "J" => 9, "K" => 10, "L" => 11, "M" => 12, "N" => 13, "O" => 14, "P" => 15, "Q" => 16, "R" => 17, "S" => 18, "T" => 19, "U" => 20, "V" => 21, "W" => 22, "X" => 23, "Y" => 24, "Z" => 25, "2" => 26, "3" => 27, "4" => 28, "5" => 29, "6" => 30, "7" => 31 ); /** * Base32 Encoding dictionary */ private static $base32_enc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; /** * Generate Secret Key * @return string */ public static function genKey() { // RFC 4226 recommends 160bits Secret Keys, that's 20 Bytes for the lazy ones. $crypto = false; $raw = ""; $x = -1; while ($crypto == false || ++$x < 10) { $raw = openssl_random_pseudo_bytes(20, $crypto); } // RFC 4648 Base32 Encoding without padding $len = strlen($raw); $bin = ""; $x = -1; while (++$x < $len) { $bin .= str_pad(base_convert(ord($raw[$x]), 10, 2), 8, '0', STR_PAD_LEFT); } $bin = str_split($bin, 5); $ret = ""; $x = -1; while (++$x < sizeof($bin)) { $ret .= self::$base32_enc[base_convert(str_pad($bin[$x], 5, '0'), 2, 10)]; } return $ret; } /** * Return the HTML for the TwoFactor Input-Form * @param boolean $form_tags Include FORM-tags * @return string */ public static function getForm($form_tags = true) { $ret = ''; if ($form_tags) { $ret .= '

'; } $ret .= '
'; $ret .= ''; if ($form_tags) { $ret .= ''; } return $ret; } /** * Authenticate with two factor * Will set $twofactorform if the token hasn't been requested yet (page will redirect to the logon page) * * @return bool returns false if the form is not needed * @throws AuthenticationException */ public static function showForm() { global $twofactorform; $twofactor = get_user_pref('twofactor'); // no need to show the form, user doesn't have a token if (empty($twofactor)) { $_SESSION['twofactor'] = true; return false; } // lockout the user if there are too many failures if ($twofactor['fails'] >= 3) { if (!Config::get('twofactor_lock')) { throw new AuthenticationException('Too many two-factor failures, please contact administrator.'); } elseif ((time() - $twofactor['last']) < Config::get('twofactor_lock')) { $msg = "Too many two-factor failures, please wait " . Config::get('twofactor_lock') . " seconds"; throw new AuthenticationException($msg); } } // set $twofactorform to show the form in logon.inc.php $twofactorform = true; return true; } /** * Check a 2fa token this will be stored in $_POST['twofactor'] by the form * If valid, $_SESSION['twofactor'] = true will be set and this will return true * * @param string $token The 2fa token, stored in $_POST['twofactor'] by the form * @return bool If the token was valid * @throws AuthenticationException Thrown if the token was invalid */ public static function authenticate($token) { if (!$token) { throw new AuthenticationException("No Two-Factor Token entered."); } $twofactor = get_user_pref('twofactor'); if (empty($twofactor)) { throw new AuthenticationException('No Two-Factor settings, how did you get here?'); } if (($server_c = self::verifyHOTP($twofactor['key'], $_POST['twofactor'], $twofactor['counter'])) === false) { $twofactor['fails']++; $twofactor['last'] = time(); set_user_pref('twofactor', $twofactor); throw new AuthenticationException("Wrong Two-Factor Token."); } if ($twofactor['counter'] !== false) { if ($server_c !== true && $server_c !== $twofactor['counter']) { $twofactor['counter'] = $server_c + 1; } else { $twofactor['counter']++; } } $twofactor['fails'] = 0; set_user_pref('twofactor', $twofactor); $_SESSION['twofactor'] = true; return true; } /** * Verify HOTP token honouring window * * @param string $key Secret Key * @param int $otp OTP supplied by user * @param int|boolean $counter Counter, if false timestamp is used * @return boolean|int */ public static function verifyHOTP($key, $otp, $counter = false) { if (self::oathHOTP($key, $counter) == $otp) { return true; } else { if ($counter === false) { //TimeBased HOTP requires lookbehind and lookahead. $counter = floor(microtime(true) / self::KEY_INTERVAL); $initcount = $counter - ((self::OTP_WINDOW + 1) * self::KEY_INTERVAL); $endcount = $counter + (self::OTP_WINDOW * self::KEY_INTERVAL); $totp = true; } else { //Counter based HOTP only has lookahead, not lookbehind. $initcount = $counter - 1; $endcount = $counter + self::OTP_WINDOW; $totp = false; } while (++$initcount <= $endcount) { if (self::oathHOTP($key, $initcount) == $otp) { if (!$totp) { return $initcount; } else { return true; } } } } return false; } /** * Generate HOTP (RFC 4226) * @param string $key Secret Key * @param int|boolean $counter Optional Counter, Defaults to Timestamp * @return int */ private static function oathHOTP($key, $counter = false) { if ($counter === false) { $counter = floor(microtime(true) / self::KEY_INTERVAL); } $length = strlen($key); $x = -1; $y = $z = 0; $kbin = ""; while (++$x < $length) { $y <<= 5; $y += self::$base32[$key[$x]]; $z += 5; if ($z >= 8) { $z -= 8; $kbin .= chr(($y & (0xFF << $z)) >> $z); } } $hash = hash_hmac('sha1', pack('N*', 0) . pack('N*', $counter), $kbin, true); $offset = ord($hash[19]) & 0xf; $truncated = (((ord($hash[$offset + 0]) & 0x7f) << 24) | ((ord($hash[$offset + 1]) & 0xff) << 16) | ((ord($hash[$offset + 2]) & 0xff) << 8) | (ord($hash[$offset + 3]) & 0xff)) % pow(10, self::OTP_SIZE); return str_pad($truncated, self::OTP_SIZE, '0', STR_PAD_LEFT); } }