Skip to content
Snippets Groups Projects
Select Git revision
  • b0a1a7adf5203efa32661b96ecb023fef74c5d2d
  • main default protected
  • 5.5 protected
  • atlantis
  • 5.3 protected
  • 5.0 protected
  • issue-23
  • issue8-seat-logging-and-export
  • ticket-216
  • tickets-215-216-241-242
10 results

AbstractPluginCommand.php

Blame
  • Forked from Stud.IP / Stud.IP
    Source project has a limited visibility.
    Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    TwoFactorAuth.php 10.84 KiB
    <?php
    /**
     * Class handling the two factor authentication
     *
     * @author  Jan-Hendrik Willms <tleilax+studip@gmail.com>
     * @license GPL2 or any later version
     * @since   Stud.IP 4.4
     *
     * @see TFASecret model
     */
    final class TwoFactorAuth
    {
        const SESSION_KEY           = 'tfa/confirmed';
        const SESSION_REDIRECT      = 'tfa/redirect';
        const SESSION_ENFORCE       = 'tfa/enforce';
        const SESSION_DATA          = 'tfa/data';
        const SESSION_CONFIRMATIONS = 'tfa/confirmations';
        const SESSION_FAILED        = 'tfa/failed';
    
        const COOKIE_KEY = 'tfa/authentication';
    
        private static $instance = null;
    
        /**
         * Returns an instance of the authentication
         * @return TwoFactorAuth object
         */
        public static function get()
        {
            if (self::$instance === null) {
                self::$instance = new self();
            }
            return self::$instance;
        }
    
        /**
         * Returns whether the two factor authentication is enabled for the given
         * user (defaults to current user). The user's permissions decide whether
         * the two factor authentication is enabled or not.
         *
         * @param  User  $user User to check (optional, defaults to current user)
         * @return boolean
         */
        public static function isEnabledForUser(User $user = null)
        {
            if ($user === null) {
                $user = User::findCurrent();
            }
    
            $valid_perms = array_filter(array_map('trim', explode(',', Config::get()->TFA_PERMS)));
            return in_array($user->perms, $valid_perms);
        }
    
        public static function removeCookie()
        {
            // Remove cookie
            setcookie(
                self::COOKIE_KEY . '/' . $GLOBALS['user']->id,
                '',
                strtotime('-1 year'),
                $GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']
            );
        }
    
        private $secret = null;
    
        /**
         * Private constructor to enforce singleton
         */
        private function __construct()
        {
            if (session_status() !== PHP_SESSION_ACTIVE) {
                throw new Exception('2FA requires a valid session');
            }
    
            if (!isset($_SESSION[self::SESSION_FAILED])) {
                $_SESSION[self::SESSION_FAILED] = [];
            } else {
                // Remove failed items after 5 minutes
                $_SESSION[self::SESSION_FAILED] = array_filter(
                    $_SESSION[self::SESSION_FAILED],
                    function ($timestamp) {
                        return $timestamp > time() - Config::get()->TFA_MAX_TRIES_TIMESPAN;
                    }
                );
            }
    
            $user = User::findCurrent();
            if (!$user) {
                return;
            }
    
            $secret = TFASecret::find($user->id);
            if (!$secret) {
                return;
            }
    
            $this->secret = $secret;
        }
    
        /**
         * Secures the current session, if applicable.
         *
         * This method checks the following:
         * - is 2fa enabled for the current user
         * - is the request an ajax call
         * - does the user have a secret, meaning 2fa is enabled
         * - is the secret already confirmed
         * - has the session already been confirmed (identified by a valid random
         *   token stored in the session)
         * - is the computer trusted (identified by a valid random token stored in
         *   a cookie)
         *
         * If the user has 2fa enabled, it's secret is confirmed and the session has
         * not been secured yet, a validation screen with a prompt to enter a valid
         * token is presented to the user.
         */
        public function secureSession()
        {
            // Not enabled for user's perm?
            if (!self::isEnabledForUser()) {
                return;
            }
    
            // TODO: AJAX?
            if (Request::isXhr()) {
                return;
            }
    
            // Not enabled?
            if (!$this->secret) {
                return;
            }
    
            $this->validateFromRequest();
    
            // Not confirmed?
            if (!$this->secret->confirmed) {
                return;
            }
    
            // User has already confirmed this session?
            if (isset($_SESSION[self::SESSION_KEY])) {
                list($code, $timeslice) = array_values($_SESSION[self::SESSION_KEY]);
                if ($this->secret->validateToken($code, (int) $timeslice, true)) {
                    return;
                }
                unset($_SESSION[self::SESSION_KEY]);
            }
    
            // Trusted computer?
            $user_cookie_key = self::COOKIE_KEY . '/' . $GLOBALS['user']->id;
            if (isset($_COOKIE[$user_cookie_key])) {
                list($code, $timeslice) = explode(':', $_COOKIE[$user_cookie_key]);
                if ($this->secret->validateToken($code, (int) $timeslice, true)) {
                    $this->registerSecretInSession();
                    return;
                }
    
                self::removeCookie();
            }
    
            $this->showConfirmationScreen('', [
                'global' => true,
            ]);
        }
    
        /**
         * Requests a 2fa token input to confirm a specific action.
         *
         * @param  string $action Name of the action to confirm
         * @param  string $text   Text to display to the user
         * @param  array  $data   Optional additional data to pass to the
         *                        confirmation screen (for internal use)
         */
        public function confirm($action, $text, array $data = []): void
        {
            if (isset($_SESSION[self::SESSION_CONFIRMATIONS])
                && is_array($_SESSION[self::SESSION_CONFIRMATIONS])
                && in_array($action, $_SESSION[self::SESSION_CONFIRMATIONS]))
            {
                $_SESSION[self::SESSION_CONFIRMATIONS] = array_diff(
                    $_SESSION[self::SESSION_CONFIRMATIONS],
                    [$action]
                );
            } else {
                $this->showConfirmationScreen($text, $data + [
                    'confirm' => $action,
                ]);
            }
        }
    
        /**
         * Displays the token input screen to the user. This will be the last
         * action since it dies after display.
         *
         * @param  string $text Text to display to the user
         * @param  array  $data Optional additional data (for internal use)
         */
        private function showConfirmationScreen($text = '', array $data = [])
        {
            $data = array_merge(['global' => false], $data);
    
            $_SESSION[self::SESSION_DATA] = array_merge($data, [
                '__nonce'  => md5(uniqid('tfa-nonce', true)),
                '__params' => Request::getInstance()->getIterator()->getArrayCopy(),
            ]);
    
            if ($this->secret->type === 'email') {
                StudipMail::sendMessage(
                    $this->secret->user->email,
                    _('Ihr Zwei-Faktor-Token'),
                    sprintf(
                        _('Bitte geben Sie dieses Token ein: %s'),
                        $this->secret->getToken()
                    )
                );
            }
            PageLayout::setBodyElementId('tfa-confirmation-screen');
    
            echo $GLOBALS['template_factory']->render(
                'tfa-validate.php',
                $_SESSION[self::SESSION_DATA] + [
                    'secret'   => $this->secret,
                    'text'     => $text,
                    'blocked'  => $this->isBlocked(),
                    'duration' => Config::get()->TFA_TRUST_DURATION,
                ],
                'layouts/base.php'
            );
            page_close();
            die;
        }
    
        /**
         * Registers the current secret in session by storing a valid random token
         * along with the according timeslice.
         */
        private function registerSecretInSession()
        {
            $timeslice = mt_rand(0, PHP_INT_MAX);
            $_SESSION[self::SESSION_KEY] = [
                'code'      => $this->secret->getToken($timeslice),
                'timeslice' => $timeslice,
            ];
        }
    
        /**
         * Registers the current secret in a cookie by storing a valid random token
         * along with the according timeslice.
         */
        private function registerSecretInCookie()
        {
            $lifetime_in_days = Config::get()->TFA_TRUST_DURATION;
            $lifetime = $lifetime_in_days > 0 ? strtotime("+{$lifetime_in_days} days") : 2147483647;
    
            $timeslice = mt_rand(0, PHP_INT_MAX);
            setcookie(
                self::COOKIE_KEY . '/' . $GLOBALS['user']->id,
                implode(':', [$this->secret->getToken($timeslice), $timeslice]),
                $lifetime,
                $GLOBALS['CANONICAL_RELATIVE_PATH_STUDIP']
            );
        }
    
        /**
         * Detects and validates a submitted tfa token input. This will stop the
         * current request if token is present and invalid and will return to the
         * request as expected when either token is present and valid or no token
         * was submitted at all.
         *
         * This method also registers the secret in session (if global in data is
         * set to true) or registers the secret in a cookie (if request parameter
         * "tfa-trusted" was sent).
         */
        private function validateFromRequest()
        {
            if (
                $this->isBlocked()
                || !Request::isPost()
                || !Request::submitted('tfacode-input')
                || !Request::submitted('tfa-nonce')
                || !isset($_SESSION[self::SESSION_DATA])
                || !is_array($_SESSION[self::SESSION_DATA])
            ) {
                return;
            }
    
            $data = $_SESSION[self::SESSION_DATA];
            if (Request::option('tfa-nonce') === $data['__nonce']) {
                $token = implode('', Request::intArray('tfacode-input'));
    
                if ($this->secret->validateToken($token)) {
                    $_SESSION[self::SESSION_FAILED] = [];
    
                    if ($data['global'] ?: false) {
                        $this->registerSecretInSession();
    
                        if (Request::int('tfa-trusted')) {
                            $this->registerSecretInCookie();
                        }
                    }
    
                    if ($data['confirm'] ?: false) {
                        if (!isset($_SESSION[self::SESSION_CONFIRMATIONS])) {
                            $_SESSION[self::SESSION_CONFIRMATIONS] = [];
                        }
                        $_SESSION[self::SESSION_CONFIRMATIONS][] = $data['confirm'];
                    }
    
                    // Remove tfa parameters from request
                    Request::set('tfa-nonce', null);
                    Request::set('tfacode-input', null);
                    Request::set('tfa-trusted', null);
    
                    // Add previous parameters to request
                    foreach ($data['__params'] as $key => $value) {
                        Request::set($key, $value);
                    }
                } else {
                    $_SESSION[self::SESSION_FAILED][] = time();
    
                    PageLayout::postError(_('Das eingegebene Token ist nicht gültig.'));
                }
    
                unset($_SESSION[self::SESSION_DATA]);
            }
        }
    
        /**
         * Returns whether the current session is blocked from any more token
         * inputs. This happens if too many false inputs happen in a short amount
         * of time and should prevent brute force attacks.
         *
         * @return boolean
         */
        private function isBlocked()
        {
            return count($_SESSION[self::SESSION_FAILED]) >= Config::get()->TFA_MAX_TRIES
                 ? min($_SESSION[self::SESSION_FAILED])
                 : false;
        }
    }