Skip to content
Snippets Groups Projects
  • Thijs Kinkhorst's avatar
    Repair broken (but passing) tests and add more coverage · a20debe7
    Thijs Kinkhorst authored
    The protectindexpage test was broken and never changed the
    protectindexpage setting, but the logic was also broken so
    it always passed and never got past the protected index
    page guard in the controller so not a lot of code in the
    controller was run.
    
    Also add more tests for the metadata controllers.
    a20debe7
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
ProcessingChain.php 10.39 KiB
<?php

declare(strict_types=1);

namespace SimpleSAML\Auth;

use Exception;
use SAML2\Exception\Protocol\NoPassiveException;
use SimpleSAML\Assert\Assert;
use SimpleSAML\Configuration;
use SimpleSAML\Error;
use SimpleSAML\Logger;
use SimpleSAML\Module;
use SimpleSAML\Utils;

/**
 * Class for implementing authentication processing chains for IdPs.
 *
 * This class implements a system for additional steps which should be taken by an IdP before
 * submitting a response to a SP. Examples of additional steps can be additional authentication
 * checks, or attribute consent requirements.
 *
 * @package SimpleSAMLphp
 */

class ProcessingChain
{
    /**
     * The list of remaining filters which should be applied to the state.
     */
    public const FILTERS_INDEX = '\SimpleSAML\Auth\ProcessingChain.filters';


    /**
     * The stage we use for completed requests.
     */
    public const COMPLETED_STAGE = '\SimpleSAML\Auth\ProcessingChain.completed';


    /**
     * The request parameter we will use to pass the state identifier when we redirect after
     * having completed processing of the state.
     */
    public const AUTHPARAM = 'AuthProcId';


    /**
     * All authentication processing filters, in the order they should be applied.
     */
    private array $filters = [];


    /**
     * Initialize an authentication processing chain for the given service provider
     * and identity provider.
     *
     * @param array $idpMetadata  The metadata for the IdP.
     * @param array $spMetadata  The metadata for the SP.
     * @param string $mode
     */
    public function __construct(array $idpMetadata, array $spMetadata, string $mode = 'idp')
    {
        $config = Configuration::getInstance();
        $configauthproc = $config->getOptionalArray('authproc.' . $mode, null);

        if (!empty($configauthproc)) {
            $configfilters = self::parseFilterList($configauthproc);
            self::addFilters($this->filters, $configfilters);
        }
        if (array_key_exists('authproc', $idpMetadata)) {
            $idpFilters = self::parseFilterList($idpMetadata['authproc']);
            self::addFilters($this->filters, $idpFilters);
        }

        if (array_key_exists('authproc', $spMetadata)) {
            $spFilters = self::parseFilterList($spMetadata['authproc']);
            self::addFilters($this->filters, $spFilters);
        }

        Logger::debug('Filter config for ' . $idpMetadata['entityid'] . '->' .
            $spMetadata['entityid'] . ': ' . str_replace("\n", '', var_export($this->filters, true)));
    }


    /**
     * Sort & merge filter configuration
     *
     * Inserts unsorted filters into sorted filter list. This sort operation is stable.
     *
     * @param array &$target  Target filter list. This list must be sorted.
     * @param array $src  Source filters. May be unsorted.
     */
    private static function addFilters(array &$target, array $src): void
    {
        foreach ($src as $filter) {
            $fp = $filter->priority;

            // Find insertion position for filter
            for ($i = count($target) - 1; $i >= 0; $i--) {
                if ($target[$i]->priority <= $fp) {
                    // The new filter should be inserted after this one
                    break;
                }
            }
            /* $i now points to the filter which should preceede the current filter. */
            array_splice($target, $i + 1, 0, [$filter]);
        }
    }


    /**
     * Parse an array of authentication processing filters.
     *
     * @param array $filterSrc  Array with filter configuration.
     * @return array  Array of ProcessingFilter objects.
     */
    private static function parseFilterList(array $filterSrc): array
    {
        $parsedFilters = [];

        foreach ($filterSrc as $priority => $filter) {
            if (is_string($filter)) {
                $filter = ['class' => $filter];
            }

            if (!is_array($filter)) {
                throw new Exception(
                    "Invalid authentication processing filter configuration: " .
                    "One of the filters wasn't a string or an array."
                );
            }

            $parsedFilters[] = self::parseFilter($filter, $priority);
        }

        return $parsedFilters;
    }

    /**
     * Parse an authentication processing filter.
     *
     * @param array $config      Array with the authentication processing filter configuration.
     * @param int $priority      The priority of the current filter, (not included in the filter
     *                           definition.)
     * @return \SimpleSAML\Auth\ProcessingFilter  The parsed filter.
     */
    private static function parseFilter(array $config, int $priority): ProcessingFilter
    {
        if (!array_key_exists('class', $config)) {
            throw new Exception('Authentication processing filter without name given.');
        }

        $className = Module::resolveClass(
            $config['class'],
            'Auth\Process',
            '\SimpleSAML\Auth\ProcessingFilter'
        );
        $config['%priority'] = $priority;
        unset($config['class']);

        /** @var \SimpleSAML\Auth\ProcessingFilter */
        return new $className($config, null);
    }


    /**
     * Process the given state.
     *
     * This function will only return if processing completes. If processing requires showing
     * a page to the user, we will not be able to return from this function. There are two ways
     * this can be handled:
     * - Redirect to a URL: We will redirect to the URL set in $state['ReturnURL'].
     * - Call a function: We will call the function set in $state['ReturnCall'].
     *
     * If an exception is thrown during processing, it should be handled by the caller of
     * this function. If the user has redirected to a different page, the exception will be
     * returned through the exception handler defined on the state array. See
     * State for more information.
     *
     * @see State
     * @see State::EXCEPTION_HANDLER_URL
     * @see State::EXCEPTION_HANDLER_FUNC
     *
     * @param array &$state  The state we are processing.
     * @throws \SimpleSAML\Error\Exception
     * @throws \SimpleSAML\Error\UnserializableException
     */
    public function processState(array &$state): void
    {
        Assert::true(array_key_exists('ReturnURL', $state) || array_key_exists('ReturnCall', $state));
        Assert::true(!array_key_exists('ReturnURL', $state) || !array_key_exists('ReturnCall', $state));

        $state[self::FILTERS_INDEX] = $this->filters;

        try {
            while (count($state[self::FILTERS_INDEX]) > 0) {
                $filter = array_shift($state[self::FILTERS_INDEX]);
                $filter->process($state);
            }
        } catch (Error\Exception $e) {
            // No need to convert the exception
            throw $e;
        } catch (Exception $e) {
            /*
             * To be consistent with the exception we return after an redirect,
             * we convert this exception before returning it.
             */
            throw new Error\UnserializableException($e);
        }

        // Completed
    }


    /**
     * Continues processing of the state.
     *
     * This function is used to resume processing by filters which for example needed to show
     * a page to the user.
     *
     * This function will never return. Exceptions thrown during processing will be passed
     * to whatever exception handler is defined in the state array.
     *
     * @param array $state  The state we are processing.
     */
    public static function resumeProcessing(array $state): void
    {
        while (count($state[self::FILTERS_INDEX]) > 0) {
            $filter = array_shift($state[self::FILTERS_INDEX]);
            try {
                $filter->process($state);
            } catch (Error\Exception $e) {
                State::throwException($state, $e);
            } catch (Exception $e) {
                $e = new Error\UnserializableException($e);
                State::throwException($state, $e);
            }
        }

        // Completed

        Assert::true(array_key_exists('ReturnURL', $state) || array_key_exists('ReturnCall', $state));
        Assert::true(!array_key_exists('ReturnURL', $state) || !array_key_exists('ReturnCall', $state));


        if (array_key_exists('ReturnURL', $state)) {
            /*
             * Save state information, and redirect to the URL specified
             * in $state['ReturnURL'].
             */
            $id = State::saveState($state, self::COMPLETED_STAGE);
            $httpUtils = new Utils\HTTP();
            $httpUtils->redirectTrustedURL($state['ReturnURL'], [self::AUTHPARAM => $id]);
        } else {
            /* Pass the state to the function defined in $state['ReturnCall']. */

            // We are done with the state array in the session. Delete it.
            State::deleteState($state);

            $func = $state['ReturnCall'];
            Assert::isCallable($func);

            call_user_func($func, $state);
            Assert::true(false);
        }
    }


    /**
     * Process the given state passivly.
     *
     * Modules with user interaction are expected to throw an \SAML\Exception\Protocol\NoPassiveException exception
     * which are silently ignored. Exceptions of other types are passed further up the call stack.
     *
     * This function will only return if processing completes.
     *
     * @param array &$state  The state we are processing.
     */
    public function processStatePassive(array &$state): void
    {
        // Should not be set when calling this method
        Assert::keyNotExists($state, 'ReturnURL');

        // Notify filters about passive request
        $state['isPassive'] = true;

        $state[self::FILTERS_INDEX] = $this->filters;

        while (count($state[self::FILTERS_INDEX]) > 0) {
            $filter = array_shift($state[self::FILTERS_INDEX]);
            try {
                $filter->process($state);
            } catch (NoPassiveException $e) {
                // Ignore \SAML2\Exception\Protocol\NoPassiveException exceptions
            }
        }
    }


    /**
     * Retrieve a state which has finished processing.
     *
     * @param string $id The state identifier.
     * @see State::parseStateID()
     * @return array|null The state referenced by the $id parameter.
     */
    public static function fetchProcessedState(string $id): ?array
    {
        return State::loadState($id, self::COMPLETED_STAGE);
    }
}