Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
PrivacyideaAuthSource.php 13.90 KiB
<?php

declare(strict_types=1);

namespace SimpleSAML\Module\privacyidea\Auth\Source;

use PrivacyIDEA\PHPClient\PIResponse;
use SimpleSAML\Auth\Source;
use SimpleSAML\Auth\State;
use SimpleSAML\Error\ConfigurationError;
use SimpleSAML\Logger;
use SimpleSAML\Module;
use SimpleSAML\Module\core\Auth\UserPassBase;
use SimpleSAML\Module\privacyidea\Auth\Utils;
use SimpleSAML\Session;
use SimpleSAML\Utils\HTTP;

const DEFAULT_UID_KEYS = ['username', 'surname', 'email', 'givenname', 'mobile', 'phone', 'realm', 'resolver'];

/**
 * privacyIDEA authentication module.
 */
class PrivacyideaAuthSource extends UserPassBase
{
    /**
     * @var array The serverconfig is listed in this array
     */
    public $authSourceConfig;

    /**
     * @var PrivacyIDEA object representing the privacyIDEA authentication server
     */
    public $pi;

    /**
     * Constructor for this authentication source.
     *
     * @param array $info   information about this authentication source
     * @param array $config Configuration set in authsources.php
     */
    public function __construct(array $info, array $config)
    {
        assert(gettype($info) === 'array');
        assert(gettype($config) === 'array');

        parent::__construct($info, $config);

        if (!array_key_exists('attributemap', $config)) {
            $config['attributemap'] = [];
        }
        if (!array_key_exists('detailmap', $config)) {
            $config['detailmap'] = [];
        }
        if (!array_key_exists('concatenationmap', $config)) {
            $config['concatenationmap'] = [];
        }

        $this->authSourceConfig = $config;
        $this->pi = Utils::createPrivacyIDEAInstance($this->authSourceConfig);
        if ($this->pi === null) {
            throw new ConfigurationError('privacyIDEA: Initialization failed.');
        }
    }

    /**
     * Initialize login. This function saves the information about the login, and redirects to the login page.
     *
     * @override
     *
     * @param array $state Information about the current authentication
     */
    public function authenticate(&$state)
    {
        assert(gettype($state) === 'array');
        Logger::debug('privacyIDEA AuthSource authenticate');

        // SSO check if authentication should be skipped
        if (
            array_key_exists('SSO', $this->authSourceConfig)
            && $this->authSourceConfig['SSO'] === true
            && Utils::checkForValidSSO($state)
        ) {
            $session = Session::getSessionFromRequest();
            $attributes = $session->getData('privacyidea:privacyidea', 'attributes');
            Logger('privacyIDEA: SSO retrieved attributes from session: ' . print_r($attributes, true));
            $state['Attributes'] = $attributes;
            Source::completeAuth($state);
        }

        $state['privacyidea:privacyidea'] = $this->authSourceConfig;

        // We are going to need the authID in order to retrieve this authentication source later.
        $state['privacyidea:privacyidea']['AuthId'] = self::getAuthId();
        Logger::debug('privacyIDEA AuthSource authId: ' . $state['privacyidea:privacyidea']['AuthId']);
        $state['privacyidea:privacyidea']['transactionID'] = '';
        $state['privacyidea:privacyidea']['authenticationMethod'] = 'authsource';

        $state['privacyidea:privacyidea:ui']['step'] = '1';
        $state['privacyidea:privacyidea:ui']['pushAvailable'] = false;
        $state['privacyidea:privacyidea:ui']['otpAvailable'] = true;
        $state['privacyidea:privacyidea:ui']['message'] = '';
        $state['privacyidea:privacyidea:ui']['webAuthnSignRequest'] = '';
        $state['privacyidea:privacyidea:ui']['u2fSignRequest'] = '';
        $state['privacyidea:privacyidea:ui']['mode'] = 'otp';
        $state['privacyidea:privacyidea:ui']['otpFieldHint'] = $this->authSourceConfig['otpFieldHint'] ?? '';
        $state['privacyidea:privacyidea:ui']['passFieldHint'] = $this->authSourceConfig['passFieldHint'] ?? '';
        $state['privacyidea:privacyidea:ui']['loadCounter'] = '1';
        $state['privacyidea:privacyidea:ui']['messageOverride'] = $this->authSourceConfig['messageOverride'] ?? null;

        $stateId = State::saveState($state, 'privacyidea:privacyidea');

        $url = Module::getModuleURL('privacyidea/FormBuilder.php');
        HTTP::redirectTrustedURL(
            $url,
            [
            'stateId' => $stateId,
            ]
        );
    }

    /**
     * This function process the login for auth source.
     *
     * @param string $stateId
     * @param array  $formParams
     */
    public static function authSourceLogin($stateId, $formParams)
    {
        assert(gettype($stateId) === 'array');
        assert(gettype($formParams) === 'array');

        $state = State::loadState($stateId, 'privacyidea:privacyidea');
        $step = $state['privacyidea:privacyidea:ui']['step'];

        $source = Source::getById($state['privacyidea:privacyidea']['AuthId']);
        if (!$source) {
            throw new Exception('Could not find authentication source with ID ' . $state['AuthId']);
        }

        // If it is the first step, trigger challenges or send the password if configured
        $username = $formParams['username'];
        $password = '';
        if (!empty($formParams['pass'])) {
            $password = $formParams['pass'];
        }

        $response = null;
        if ($step === 1) {
            $state['privacyidea:privacyidea']['username'] = $username;
            $stateId = State::saveState($state, 'privacyidea:privacyidea');

            if (
                array_key_exists('doTriggerChallenge', $source->authSourceConfig)
                && $source->authSourceConfig['doTriggerChallenge'] === true
            ) {
                if (!empty($username) && $source->pi->serviceAccountAvailable()) {
                    try {
                        $response = $source->pi->triggerChallenge($username);
                    } catch (Exception $e) {
                        Utils::handlePrivacyIDEAException($e, $state);
                    }
                }
            } elseif (
                array_key_exists('doSendPassword', $source->authSourceConfig)
                && $source->authSourceConfig['doSendPassword'] === true
            ) {
                if (!empty($username)) {
                    try {
                        $response = $source->pi->validateCheck($username, $password);
                    } catch (Exception $e) {
                        Utils::handlePrivacyIDEAException($e, $state);
                    }
                }
            }
            // Save the state at the end of step
            $stateId = State::saveState($state, 'privacyidea:privacyidea');
        } elseif ($step > 1) {
            try {
                $response = Utils::authenticatePI($state, $formParams);
            } catch (Exception $e) {
                Utils::handlePrivacyIDEAException($e, $state);
            }
            $stateId = State::saveState($state, 'privacyidea:privacyidea');
        } else {
            Logger::error('privacyIDEA: UNDEFINED STEP: ' . $step);
        }

        if ($response !== null) {
            $stateId = Utils::processPIResponse($stateId, $response, $source->authSourceConfig);
        }

        $state = State::loadState($stateId, 'privacyidea:privacyidea');

        // Increase steps counter
        if (empty($state['privacyidea:privacyidea']['errorMessage'])) {
            $state['privacyidea:privacyidea:ui']['step'] = $step + 1;
        }

        //Logger::error("NEW STEP: " . $state['privacyidea:privacyidea:ui']['step']);
        $stateId = State::saveState($state, 'privacyidea:privacyidea');
        $url = Module::getModuleURL('privacyidea/FormBuilder.php');
        HTTP::redirectTrustedURL(
            $url,
            [
            'stateId' => $stateId,
            ]
        );
    }

    /**
     * Check if the attributes that are required by SSP are contained in the response of privacyIDEA. They are then
     * merged with the attributes specified in the configuration before returning control to SSP. If the authentication
     * is complete, this function does not return. If SSO is enabled, this will also register the loginCompletedHandler
     * that will write the necessary data.
     *
     * @param array $state
     * @param $authSourceConfig
     */
    public static function checkAuthenticationComplete($state, PIResponse $piResponse, $authSourceConfig)
    {
        $attributes = $piResponse->detailAndAttributes;

        if (!empty($attributes)) {
            $userAttributes = $attributes['attributes'];
            $detailAttributes = $attributes['detail'];

            $completeAttributes = self::mergeAttributes($userAttributes, $detailAttributes, $authSourceConfig);
            $state['Attributes'] = $completeAttributes;

            if (array_key_exists('SSO', $authSourceConfig) && $authSourceConfig['SSO'] === true) {
                /*
                 * In order to be able to register a logout handler for the session (mandatory for SSO to work),
                 * the authority is required in the session's authData.
                 * The authority can be put there by invoking Session::doLogin,
                 * which should be done by the LoginCompletedHandler.
                 * To be able to do something after Session::doLogin, the LoginCompletedHandler has to be replaced with
                 * an implementation that writes the SSO data and attributes in this case (AuthSource) to the session.
                 */
                $state['LoginCompletedHandler'] = [
                    'SimpleSAML\Module\privacyidea\Auth\Source\PrivacyideaAuthSource',
                    'loginCompletedWriteSSO',
                ];
            }

            // Return control to simpleSAMLphp after successful authentication.
            Source::completeAuth($state);
        }
    }

    /**
     * Copy of the original loginCompletedHandler that will additionally write the SSO data and user attributes to the
     * session after performing the standard login.
     *
     * @param array $state the state after the login has completed
     */
    public static function loginCompletedWriteSSO(array $state)
    {
        Logger::debug('privacyIDEA: loginCompletedWriteSSO');
        assert(array_key_exists('\SimpleSAML\Auth\Source.Return', $state));
        assert(array_key_exists('\SimpleSAML\Auth\Source.id', $state));
        assert(array_key_exists('Attributes', $state));
        assert(!array_key_exists('LogoutState', $state) || is_array($state['LogoutState']));

        $return = $state['\SimpleSAML\Auth\Source.Return'];

        // save session state
        $session = Session::getSessionFromRequest();
        $authId = $state['\SimpleSAML\Auth\Source.id'];
        $session->doLogin($authId, State::getPersistentAuthData($state));

        // In addition to the SSO data, the attributes have to be written to the session so that they can be retrieved
        // and used on the next login
        Utils::tryWriteSSO();
        $session->setData('privacyidea:privacyidea', 'attributes', $state['Attributes']);

        if (is_string($return)) {
            // redirect...
            HTTP::redirectTrustedURL($return);
        } else {
            call_user_func($return, $state);
        }
        assert(false);
    }

    /**
     * Attempt to log in using the given username and password.
     *
     * @override
     *
     * @param string $username the username the user wrote
     * @param string $password the password the user wrote
     */
    protected function login($username, $password)
    {
        // Stub.
        Logger::debug('privacyIDEA AuthSource login stub');
    }

    /**
     * This function merge all attributes and detail which SimpleSAMLphp needs.
     *
     * @param $userAttributes
     * @param $detailAttributes
     * @param $authSourceConfig
     *
     * @return array
     */
    protected static function mergeAttributes($userAttributes, $detailAttributes, $authSourceConfig)
    {
        // Prepare attributes array to return
        $attributes = [];

        // attributemap is set in config/authsources.php
        $keys = array_merge(array_keys($authSourceConfig['attributemap']), DEFAULT_UID_KEYS);
        $keys = array_unique($keys);

        // Keep all reservations from attributemap to translate PI attributes names to SAML attributes names.
        foreach ($keys as $key) {
            $attributeValue = $userAttributes[$key];

            if ($attributeValue) {
                $attributeKey = ($authSourceConfig['attributemap'][$key] ?? null) ?: $key;
                $attributes[$attributeKey] = is_array($attributeValue) ? $attributeValue : [$attributeValue];
            }
        }

        // Keep all reservations from detailmap to know which attributes are set to show in UI.
        // Detailmap was set in config/authsources.php
        foreach ($authSourceConfig['detailmap'] as $key => $mappedKey) {
            $attributeValue = $detailAttributes->{$key};
            $attributes[$mappedKey] = is_array($attributeValue) ? $attributeValue : [$attributeValue];
        }

        // Keep all reservations from concatenationmap to fuse some attributes together.
        // Concatenationmap was set in config/authsources.php
        foreach ($authSourceConfig['concatenationmap'] as $key => $mappedKey) {
            $concatenationArr = explode(',', $key);
            $concatenationValues = [];

            foreach ($concatenationArr as $item) {
                $concatenationValues[] = $userAttributes->{$item};
            }

            $concatenationString = implode(' ', $concatenationValues);
            $attributes[$mappedKey] = [$concatenationString];
        }

        Logger::debug('privacyIDEA: Attributes returned: ' . print_r($attributes, true));

        return $attributes;
    }

    /**
     * Check if url is allowed.
     *
     * @param $id
     */
    private static function checkIdLegality($id)
    {
        $sid = State::parseStateID($id);
        if ($sid['url'] !== null) {
            HTTP::checkURLAllowed($sid['url']);
        }
    }
}