<?php
/**
 * TwoFactor.php
 *
 * Two-Factor Authentication Library
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * @license GPL
 * @package LibreNMS
 * @link       http://librenms.org
 * @subpackage Authentication
 * @author f0o <f0o@devilcode.org>
 * @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 .= '
      <div class="row">
        <div class="col-md-offset-4 col-md-4">
          <div class="panel panel-default">
            <div class="panel-heading">
              <h3 class="panel-title">
                <img src="' . Config::get('title_image') . '">
              </h3>
            </div>
            <div class="panel-body">
              <div class="container-fluid">
                  <form class="form-horizontal" role="form" action="" method="post" name="twofactorform">';
        }

        $ret .= '
        <div class="form-group">
          <div class="col-md-12">
            <input type="text" name="twofactor" id="twofactor" class="form-control" autocomplete="off" placeholder="Please enter auth token" />
          </div>
        </div>
        <div class="form-group">
          <div class="col-md-12">
            <button type="submit" class="btn btn-default btn-block" name="submit">Submit</button>
          </div>
         </div>
        </div>';

        $ret .= '<script>document.twofactorform.twofactor.focus();</script>';

        if ($form_tags) {
            $ret .= '</form>';
        }

        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);
    }
}
