AD FS i delegacja SAML Token w PHP

Miałem na swoje nieszczęście przyjemność rozbudowywać istniejącą aplikację w PHP o autoryzację SAML. Aplikacja posiadała wcześniej własny system logowania użytkowników, ale z racji na mnogość aplikacji w obrębie jednej firmy zechciano wdrożyć SSO (Single Sign On). Zostało to oparte o AD FS (Active Directory Federation Server) ze względu na posiadanie kont domenowych. Takie rozwiązanie umożliwia scentralizowane zarządzanie użytkownikami. Powstał również panel administracyjny który posiada usługę, zwracającą informację o uprawnieniach zalogowanego użytkownika. Głównym problemem w całym tym modelu biznesowym jest brak wsparcia w języku PHP dla AD FS. W innych językach powstały liczne biblioteki wspierające standard WS-Trust 1.4 i sprowadzają proces logowania i delegacji uprawnień do 10 linijek kodu. W celu realizacji procesu delegacji uprawnień użytkownika przez aplikację, powstała mała klasa, która uzyskuje Security Token z STS’a. Znajdziesz ją w poprzednim poście: AD FS – uzyskiwanie Security Token dla użytkownika.

Flow aplikacji które zostało zrealizowane:

  1. Użytkownik wchodzi na aplikację example.com
  2. Zostaje przekierowany do IdP (Działanie inicjalizacji SP dla niezalogowanych użytkowników)
  3. Użytkownik loguje się w IdP i zostaje przekierowany do SP
  4. Aplikacja (SP) uzyskuje SAML Assertion z SAML Response
  5. Serwer dekoduje SAML Assertion i uzyskuje Security Token użytkownika
  6. Serwer deleguje Security Token użytkownika do zewnętrznej usługi poprzez ActAs SAML Request
  7. Usługa oddaje dane tak jakby o dane pytał się użytkownik

SAML_DELEGATION

W celu realizacji takiego flow aplikacji użyłem biblioteki SimpleSAMLphp. Biblioteka SimpleSAMLphp po prawidłowej konfiguracji zapewnia wsparcie dla pierwszych 5 kroków procesu logowania. Proces konfiguracji biblioteki i AD FS znajdziesz na tym blogu Integrating SimpleSAMLphp with ADFS 2012R2. Pozostał za to element którego na próżno szukać w google, szczególnie dodając słowo „PHP”. Poniżej zamieszczam klasę która realizuje ostanie 2 punkty z flow logowania.

W celu zrealizowania tej funkcjonalności dokonałem modyfikacji 2 plików w bibliotekach SimpleSAMLphp.

Zmodyfikowałem simplesamlphp-1.13.2\lib\SimpleSAML\Utils\XML.php

    Dodałem:
    ============================================================================
    Linia 115: return $message;


    Nowy wygląd funkcji: 
    ============================================================================

    public static function debugSAMLMessage($message, $type) {
        if (!(is_string($type) && (is_string($message) || $message instanceof \DOMElement))) {
            throw new \InvalidArgumentException('Invalid input parameters.');
        }

        $globalConfig = \SimpleSAML_Configuration::getInstance();


        if ($message instanceof \DOMElement) {
            $message = $message->ownerDocument->saveXML($message);
        }

        switch ($type) {
            case 'in':
                \SimpleSAML_Logger::debug('Received message:');
                break;
            case 'out':
                \SimpleSAML_Logger::debug('Sending message:');
                break;
            case 'decrypt':
                \SimpleSAML_Logger::debug('Decrypted message:');
                break;
            case 'encrypt':
                \SimpleSAML_Logger::debug('Encrypted message:');
                break;
            default:
                assert(false);
        }

        $str = self::formatXMLString($message);
        foreach (explode("\n", $str) as $line) {
            if (!$globalConfig->getBoolean('debug', true)) {
                \SimpleSAML_Logger::debug($line);
            }
        }
        
        return $message;
    }

Zmodyfikowałem simplesamlphp-1.13.2\vendor\simplesamlphp\saml2\src\SAML2\EncryptedAssertion.php

Dodałem:
============================================================================
$str = \SimpleSAML\Utils\XML::debugSAMLMessage($assertionXML, 'decrypt');
$sessionObject = SimpleSAML_Session::getSession();
$sessionObject->setData('string', 'SAMLAssertionCLEAR', $str);


Nowy wygląd funkcji: 
============================================================================
public function getAssertion(XMLSecurityKey $inputKey, array $blacklist = array()) {
    $assertionXML = SAML2_Utils::decryptElement($this->encryptedData, $inputKey, $blacklist);

    $str = \SimpleSAML\Utils\XML::debugSAMLMessage($assertionXML, 'decrypt');
    $sessionObject = SimpleSAML_Session::getSession();
    $sessionObject->setData('string', 'SAMLAssertionCLEAR', $str);

    return new SAML2_Assertion($assertionXML);
}

Dzięki tym zabiegom odkodowany Security Token użytkownika uzyskany z SAML Response zapisałem do zmiennej sesyjnej SAMLAssertionCLEAR. Klasa pobiera uzyskany token, wysyła odpowiednie zapytania i umożliwia połączenie się z zewnętrznym serwisem. Klasa została ogołocona do podstawowych funkcji, ale powinna działać.

<?php

class default_exception_handler {

    public function __construct($message) {
        echo "<h2>Unexpected error occurred</h2>";
        echo "<p>$message</p>";
        exit;
    }

}

/**
 * @author Paweł Kasztelan (pawel@kasztelan.me)
 */
class SAML {

    /**
     * ID autentykacji po której się poruszamy
     * @var string
     */
    private $authSource;

    /**
     * Zmienne do ADFS'a
     * @var type 
     */
    private $SAML_ApplicationLogin;
    private $SAML_ApplicationPassword;
    private $SAML_AuthorizationEntityID;
    private $SAML_TakePrivilagesUrl;
    private $SAML_AuthorizationService;

    /**
     * Zmienne przetrzymujące pobrane i przetworzone dane tokenów i uprawnień
     * @var type 
     */
    private $samlToken;
    private $samlActAsToken;
    private $privilagesArray = array();
    private $claimsArray = array();
    private $module = "Authorization";

    /**
     * Buduje sesje SimpleSAMLphp i jeżeli użytkownik jest zalogowany to pobiera
     * jego uprawnienia dla konta. Domyślnie pobiera moduł Autoryzacji, bo ma go
     * każdy który ma konto w AD FS.
     * 
     * @param type $authSource
     * @param type $module
     */
    public function __construct($authSource, $config = array(), $module = 'Authorization') {
        parent::__construct($authSource);

        $this->authSource = $authSource;
        $this->module = $module;

        $this->SAML_ApplicationLogin = $config['Application_Login'];
        $this->SAML_ApplicationPassword = $config['Application_Password'];
        $this->SAML_AuthorizationEntityID = $config['Application_EntityID'];

        $this->SAML_AuthorizationService = $config['Authorization_Service_URL'];
        $this->SAML_TakePrivilagesUrl = $config['PrivilagesServiceURL'];

        // Sprawdza czy użytkownik jest zalogowany. Jeżeli jest i token dla
        // aplikacji jest pusty to wyśle request o nowy token
        // i pobierze uprawnienia dla wskazanego modułu
        if ($this->isAuthenticated()) {

            $session = SimpleSAML_Session::getSession();

            if (empty($session->getData('string', 'SAML_ActAsToken'))) {
                $this->ActAsRequest();
            }

            $this->use_sendPrivilages($module);
        }
    }

    /**
     * Pobierz cały token SAML2.0 dla zalogowanego użytkownika
     * @return type
     */
    public function get_UserAuthToken() {

        if (!$this->isAuthenticated()) {
            SimpleSAML_Logger::debug('[ActAs] User is not logged in.');
            new default_exception_handler('User is not logged in.');
        } elseif (empty($this->samlToken)) {
            return $this->use_SAMLUserToken();
        }

        return $this->samlToken;
    }

    /**
     * Zwraca stworzony dla aplikacji token do podpisu zapytań
     * @return type
     */
    public function get_FinalAppToken() {

        $session = SimpleSAML_Session::getSession();

        if (!empty($session->getData('string', 'SAML_ActAsToken'))) {
            $this->samlActAsToken = $session->getData('string', 'SAML_ActAsToken');
        }

        return $this->samlActAsToken;
    }

    /**
     * Wykonuje zapytanie o uprawnienie i dopiero zwraca arraya
     * @param type $module
     * @return array
     */
    public function get_Privilages($module = '') {

        if (!$this->isAuthenticated()) {
            SimpleSAML_Logger::debug('[ActAs] User is not logged in.');
            new default_exception_handler('User is not logged in.');
        }

        if (empty($module)) {
            $module = 'Authorization';
        }

        $this->use_sendPrivilages($module);
        return $this->privilagesArray;
    }

    /**
     * Deleguj autoryzację z aplikacji na użytkownika
     */
    public function ActAsRequest() {

        $RequestXML = <<<XML
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
    <s:Header>
        <a:Action s:mustUnderstand="1">http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue</a:Action>
        <a:To s:mustUnderstand="1">$this->SAML_AuthorizationService</a:To>
        <o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" >
            <o:UsernameToken u:Id="uuid-6a13a244-dac6-42c1-84c5-cbb345b0c4c4-1">
                <o:Username>$this->SAML_ApplicationLogin</o:Username>
                <o:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">$this->SAML_ApplicationPassword</o:Password>
            </o:UsernameToken>
        </o:Security>
    </s:Header>
    <s:Body>
        <trust:RequestSecurityToken xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
            <wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
                <a:EndpointReference>
                    <a:Address>$this->SAML_AuthorizationEntityID</a:Address>
                </a:EndpointReference>
            </wsp:AppliesTo>
            <trust:KeyType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer</trust:KeyType>
            <trust:ActAs xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200802">
                {$this->use_SAMLUserToken()}
            </trust:ActAs>
            <trust:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</trust:RequestType>
        </trust:RequestSecurityToken>
    </s:Body>
</s:Envelope>
XML;

        $result = $this->use_send($RequestXML);

        if (preg_match('/<s:Fault>[\s\S]*?<\/s:Fault>/', $result)) {
            preg_match('/<s:Text[\s\S]*?>([\s\S]*?)<\/s:Text>/', $result, $errorMatches);
            SimpleSAML_Logger::debug('[ActAs] Unexpected error occurred: ' . $errorMatches[1]);
            new default_exception_handler('Unexpected error occurred: ' . $errorMatches[1]);
        }

        if (preg_match('/HTTP Error/', $result)) {
            preg_match('/HTTP Error[\s\S]*?unavailable./', $result, $errorMatches);
            SimpleSAML_Logger::debug('[ActAs] Unexpected error occurred: ' . $errorMatches[0]);
            new default_exception_handler('Unexpected error occurred: ' . $errorMatches[0]);
        }

        preg_match('/<saml:Assertion[\s\S]*?<\/saml:Assertion>/', $result, $matches);
        $this->samlActAsToken = $matches[0];

        $session = SimpleSAML_Session::getSession();
        $session->setData('string', 'SAML_ActAsToken', $matches[0]);
    }

    /**
     * Pobierz token użytkownika dla aktualnej sesji SimpleSAMLphp
     * @return string
     */
    function use_SAMLUserToken() {

        $session = SimpleSAML_Session::getSession();
        $xml = $session->getData('string', 'SAMLAssertionCLEAR');
        $this->samlToken = $xml;

        return $this->samlToken;
    }

    /**
     * Wysyła zapytanie do AD FS aby uzyskać delegowany token
     * 
     * @param type $xml
     * @return type
     */
    function use_send($xml) {
        $TakeToken = curl_init();
        curl_setopt($TakeToken, CURLOPT_URL, $this->Neuca_AuthorizationService);
        curl_setopt($TakeToken, CURLOPT_SSL_VERIFYHOST, 0);
        curl_setopt($TakeToken, CURLOPT_SSL_VERIFYPEER, 0);
        curl_setopt($TakeToken, CURLOPT_POST, 1);
        curl_setopt($TakeToken, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($TakeToken, CURLOPT_HTTPHEADER, array('Content-Type: application/soap+xml; charset=utf-8'));
        curl_setopt($TakeToken, CURLOPT_POSTFIELDS, $xml);
        $result = curl_exec($TakeToken);

        return $result;
    }

    /**
     * Wysyła zaptanie o uprawnienia dla użytkownika
     * @param type $module
     * @return type
     */
    function use_sendPrivilages($module = false) {

        $TakePriv = curl_init();
        curl_setopt($TakePriv, CURLOPT_URL, $this->Neuca_TakePrivilagesUrl . $module);
        curl_setopt($TakePriv, CURLOPT_SSL_VERIFYHOST, 0);
        curl_setopt($TakePriv, CURLOPT_SSL_VERIFYPEER, 0);
        curl_setopt($TakePriv, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($TakePriv, CURLOPT_HTTPHEADER, array('Authorization: Saml ' . $this->get_FinalAppToken()));
        $result = curl_exec($TakePriv);

        $ob = json_decode($result);
        if ($ob === null) {
            $LoadPrivilages = $this->use_LoadPermision($module);
            $ob = json_decode($LoadPrivilages);
            if (!$ob) {
                SimpleSAML_Logger::debug('[ActAs] Twój klucz wygasł lub jest nieprawidłowy.');
                $ob = array('error' => 'Twój klucz wygasł lub jest nieprawidłowy.');
            }
            $this->privilagesArray = $ob;
        } else {
            $this->use_WritePermision($result, $module);
            $this->privilagesArray = $ob;
        }

        return $this->privilagesArray;
    }

}

 

 

Kasztelan Paweł

Programista samouk, zakochany w ZF i Laravel, szerzący opinię że PHP + JS + HTML + CSS to są języki w których może zostać stworzona aplikacja równie dobra, a nawet lepsza od twardego klienta.