diff --git a/lib/SimpleSAML/XML/SAML20/AuthnResponse.php b/lib/SimpleSAML/XML/SAML20/AuthnResponse.php index 851d8aa69211371e5d02c16ca6ab68f8ab3bd60e..dabd787f0e043e0912c1812bf66d813b13b07b45 100644 --- a/lib/SimpleSAML/XML/SAML20/AuthnResponse.php +++ b/lib/SimpleSAML/XML/SAML20/AuthnResponse.php @@ -34,38 +34,55 @@ class SimpleSAML_XML_SAML20_AuthnResponse extends SimpleSAML_XML_AuthnResponse { /** * This variable contains an XML validator for this message. */ - private $validator = null; - + private $validator = NULL; - function __construct(SimpleSAML_Configuration $configuration, SimpleSAML_Metadata_MetaDataStorageHandler $metadatastore) { - $this->configuration = $configuration; - $this->metadata = $metadatastore; - } - - public function validate() { - - $dom = $this->getDOM(); + /** + * This varaible contains the entitiyid of the IdP which issued this message. + */ + private $issuer = NULL; - /* Validate the signature. */ - $this->validator = new SimpleSAML_XML_Validator($dom, 'ID'); - // Get the issuer of the response. - $issuer = $this->getIssuer(); + /** + * This variable contains the NameID of this subject. It is an associative array with + * two keys: + * - 'Format' The type of the NameID. + * - 'value' Tha value of the NameID. + * + * This variable will be set by the processSubject function. A exception will be thrown if the response + * contains two different NameIDs. + */ + private $nameid = NULL; - /* Get the metadata of the issuer. */ - $md = $this->metadata->getMetaData($issuer, 'saml20-idp-remote'); - /* Get fingerprint for the certificate of the issuer. */ - $issuerFingerprint = $md['certFingerprint']; + /** + * This variable contains the SessionIndex, as set by a AuthnStatement element in an assertion. + */ + private $sessionIndex = NULL; + + + /** + * This associative array contains the attribute we extract from the response. + */ + private $attributes = array(); - /* Validate the fingerprint. */ - $this->validator->validateFingerprint($issuerFingerprint); - return true; + function __construct(SimpleSAML_Configuration $configuration, SimpleSAML_Metadata_MetaDataStorageHandler $metadatastore) { + $this->configuration = $configuration; + $this->metadata = $metadatastore; } + /* The following methods aren't used anymore. They are included because it is required by inheritance. + * TODO: Remove them. + */ + public function validate() { throw new Exception('TODO!'); } + public function createSession() { throw new Exception('TODO!'); } + public function getAttributes() { throw new Exception('TODO!'); } + public function getIssuer() { throw new Exception('TODO!'); } + public function getNameID() { throw new Exception('TODO!'); } + + /** * This function runs an xPath query on this authentication response. * @@ -94,128 +111,314 @@ class SimpleSAML_XML_SAML20_AuthnResponse extends SimpleSAML_XML_AuthnResponse { } - public function createSession() { - - SimpleSAML_Session::init(true, 'saml2'); - $session = SimpleSAML_Session::getInstance(); - $session->setAttributes($this->getAttributes()); - - $session->setNameID($this->getNameID()); - $session->setSessionIndex($this->getSessionIndex()); - $session->setIdP($this->getIssuer()); - - return $session; + /** + * This function checks if the user has added the given id to 'saml2.relaxvalidation' + * in the saml2-idp-remote configuration. + * + * @param $id The id which identifies a part of the verification which may be relaxed. + * @return TRUE if this id is added to the list, FALSE if not. + */ + private function isValidationRelaxed($id) { + + assert('is_string($id)'); + assert('$this->issuer != NULL'); + + /* Get the metadata of the issuer. */ + $md = $this->metadata->getMetaData($this->issuer, 'saml20-idp-remote'); + + if(!array_key_exists('saml2.relaxvalidation', $md)) { + /* The user hasn't added a saml2.relaxvalidation option. */ + return FALSE; + } + + $rv = $md['saml2.relaxvalidation']; + if(!is_array($rv)) { + throw new Exception('saml2.relaxvalidation must be an array.'); + } + + return in_array($id, $rv, TRUE); } - - - // TODO: Not tested, but neigther is it used. - function getSessionIndex() { - $token = $this->getDOM(); - if ($token instanceof DOMDocument) { - $xPath = new DOMXpath($token); - $xPath->registerNamespace('mysaml', self::SAML2_ASSERT_NS); - $xPath->registerNamespace('mysamlp', self::SAML2_PROTOCOL_NS); - - $query = '/mysamlp:Response/mysaml:Assertion/mysaml:AuthnStatement'; - $nodelist = $xPath->query($query); - if ($node = $nodelist->item(0)) { - return $node->getAttribute('SessionIndex'); - } + + + /** + * This function finds the issuer of this response. It will first search the Response element, + * and if it isn't found there, it will search all Assertion elements. + */ + private function findIssuer() { + + /* First check the Response element. */ + $issuer = $this->doXPathQuery('/samlp:Response/saml:Issuer')->item(0); + if($issuer !== NULL) { + return $issuer->textContent; } - return NULL; + + /* Then we search the Assertion elements. */ + $issuers = $this->doXPathQuery('/samlp:Response/saml:Assertion/saml:Issuer'); + + if($issuers->length === 0) { + throw new Exception('Unable to determine the issuer of this SAML2 AuthnResponse message.'); + } + + /* Since all Issuer elements should be equal in this version of simpleSAMLphp, we pick + * the first Issuer element we find. + */ + return $issuers->item(0)->textContent; } + + /** + * This function validates the signature element of this response. It will throw an exception + * if it is unable to validate the signature. + */ + private function validateSignature() { - public function getAttributes() { + $dom = $this->getDOM(); + + /* Validate the signature. */ + $this->validator = new SimpleSAML_XML_Validator($dom, 'ID'); - $md = $this->metadata->getMetadata($this->getIssuer(), 'saml20-idp-remote'); + /* Get the metadata of the issuer. */ + $md = $this->metadata->getMetaData($this->issuer, 'saml20-idp-remote'); - $base64 = isset($md['base64attributes']) ? $md['base64attributes'] : false; + /* Get fingerprint for the certificate of the issuer. */ + $issuerFingerprint = $md['certFingerprint']; + + /* Validate the fingerprint. */ + $this->validator->validateFingerprint($issuerFingerprint); + } + + + /** + * This function processes a Subject node. It will throw + * an Exception if the subject cannot be confirmed. On successful verification, + * the data stored about this subject will be saved. + */ + private function processSubject($subject) { + + /* We currently require urn:oasis:names:tc:SAML:2.0:cm:bearer subject confirmation. */ + $bearerValidated = false; - $attributes = array(); - $token = $this->getDOM(); + /* Iterate over the SubjectConfirmation nodes, looking for it. */ + foreach($this->doXPathQuery('saml:SubjectConfirmation', $subject) as $subjectConfirmation) { + $method = $subjectConfirmation->getAttributeNode('Method'); + if($method === NULL) { + throw new Exception('SubjectConfirmation is missing the required Method attribute.'); + } + if($method->value !== 'urn:oasis:names:tc:SAML:2.0:cm:bearer') { + throw new Exception('Unhandled SubjectConfirmationData: ' . $method->value); + } + + $subjectConfirmationData = $this->doXPathQuery('saml:SubjectConfirmationData', $subjectConfirmation); + if($subjectConfirmationData === NULL) { + throw new Exception('Bearer confirmation node without verification data.'); + } - if($this->validator === NULL) { - throw new Exception('Called getAttributes on a SAML2 AuthnResponse which hasn\'t been validated.'); + /* TODO: Verify this subject. */ } - if ( !($token instanceof DOMDocument)) { - throw new Exception('Called getAttributes on a SAML2 AuthnResponse which doesn\'t contain a message.'); + /* We expect the subject node to contain a NameID element which identifies this subject. */ + $nameid = $this->doXPathQuery('saml:NameID', $subject)->item(0); + if($nameid === NULL) { + throw new Exception('Could not find the NameID node in a Subject node.'); } - $assertions = $this->doXPathQuery('/samlp:Response/saml:Assertion'); - foreach($assertions as $assertion) { + $format = $nameid->getAttribute('Format'); + $value = $nameid->textContent; - if(!$this->validator->isNodeValidated($assertion)) { - throw new Exception('A SAML2 AuthnResponse contained an Assertion which isn\'t verified by the signature.'); - } + if($this->nameid === NULL) { + /* We haven't saved a nameID earlier. Save it now. */ + $this->nameid = array('Format' => $format, 'value' => $value); + return; + } - foreach($this->doXPathQuery('saml:Conditions', $assertion) as $condition) { + /* We have saved a nameID earlier. Verify that this nameID is equal. */ + if($this->nameid['Format'] !== $format || $this->nameid['value'] !== $value) { + throw new Exception('Multiple assertions with different nameIDs is unsupported by simpleSAMLphp'); + } + } - $start = $condition->getAttribute("NotBefore"); - $end = $condition->getAttribute("NotOnOrAfter"); - if (! SimpleSAML_Utilities::checkDateConditions($start, $end)) { - throw new Exception("Date check failed (between $start and $end). Check if the clocks on the SP and IdP are synchronized. Alternatively you can get this message, when you move back in history or refresh an old page."); - } + /** + * This function processes a Conditions node. It will throw an exception if any of the conditions + * are invalid. + */ + private function processConditions($conditions) { + + /* First verify the NotBefore and NotOnOrAfter attributes if they are present. */ + $notBefore = $conditions->getAttribute("NotBefore"); + $notOnOrAfter = $conditions->getAttribute("NotOnOrAfter"); + if (! SimpleSAML_Utilities::checkDateConditions($notBefore, $notOnOrAfter)) { + throw new Exception('Date check failed (between ' . $notBefore . ' and ' . $notOnOrAfter . ').' . + ' Check if the clocks on the SP and IdP are synchronized. Alternatively' . + ' you can get this message, when you move back in history or refresh an old page.'); + } + + + if($this->doXPathQuery('Condition', $conditions)->length > 0) { + if(!$this->isValidationRelaxed('unknowncondition')) { + throw new Exception('A Conditions node in a SAML2 AuthnResponse contained a' . + ' Condition node. This is unsupported by simpleSAMLphp. To disable this' . + ' check, add \'unknowncondition\' to the \'saml2.relaxvalidation\' list in' . + ' \'saml2-idp-remote\'.'); } + } - foreach($this->doXPathQuery('saml:AttributeStatement/saml:Attribute/saml:AttributeValue', $assertion) as $attribute) { - $name = $attribute->parentNode->getAttribute('Name'); - $value = $attribute->textContent; + $spEntityId = $this->metadata->getMetaDataCurrentEntityID('saml20-sp-hosted'); - if(!array_key_exists($name, $attributes)) { - $attributes[$name] = array(); - } + /* The specification says that every AudienceRestriction element must be valid, but only one + * Audience element in each AudienceRestriction element must be valid. + */ + foreach($this->doXPathQuery('AudienceRestriction', $conditions) as $ar) { - if ($base64) { - foreach(explode('_', $value) AS $v) { - $attributes[$name][] = base64_decode($v); - } - } else { - $attributes[$name][] = $value; + $validAudience = false; + foreach($this->doXPathQuery('Audience', $ar) as $a) { + if($a->textContent === $spEntityId) { + $validAudience = true; } } + if(!$validAudience) { + throw new Exception('Could not verify audience of SAML2 AuthnResponse.'); + } } - return $attributes; + /* We ignore OneTimeUse and ProxyRestriction conditions. */ } - - public function getIssuer() { - $dom = $this->getDOM(); - $issuer = null; - if ($issuerNodes = $dom->getElementsByTagName('Issuer')) { - if ($issuerNodes->length > 0) { - $issuer = $issuerNodes->item(0)->textContent; + + /** + * This function processes a AuthnStatement node. It will throw an exception if the statement is + * invalid. + */ + private function processAuthnStatement($authnStatement) { + /* Extract the SessionIndex. */ + $sessionIndex = $authnStatement->getAttributeNode('SessionIndex'); + if($sessionIndex !== NULL) { + $sessionIndex = $sessionIndex->value; + if($this->sessionIndex === NULL) { + $this->sessionIndex = $sessionIndex; + } elseif($this->sessionIndex !== $sessionIndex) { + throw new Exception('Got two different session indexes in a SAML2 AuthnResponse.'); } } - return $issuer; } - - public function getNameID() { - - $dom = $this->getDOM(); - $nameID = array(); - - if ($dom instanceof DOMDocument) { - $xPath = new DOMXpath($dom); - $xPath->registerNamespace('mysaml', self::SAML2_ASSERT_NS); - $xPath->registerNamespace('mysamlp', self::SAML2_PROTOCOL_NS); - - $query = '/mysamlp:Response/mysaml:Assertion/mysaml:Subject/mysaml:NameID'; - $nodelist = $xPath->query($query); - if ($node = $nodelist->item(0)) { - - $nameID["value"] = $node->nodeValue; - //$nameID["NameQualifier"] = $node->getAttribute('NameQualifier'); - //$nameID["SPNameQualifier"] = $node->getAttribute('SPNameQualifier'); - $nameID["Format"] = $node->getAttribute('Format'); + + + /** + * This function processes a AttributeStatement node. + */ + private function processAttributeStatement($attributeStatement) { + + $md = $this->metadata->getMetadata($this->issuer, 'saml20-idp-remote'); + $base64 = isset($md['base64attributes']) ? $md['base64attributes'] : false; + + foreach($this->doXPathQuery('saml:Attribute/saml:AttributeValue', $attributeStatement) as $attribute) { + + $name = $attribute->parentNode->getAttribute('Name'); + $value = $attribute->textContent; + + if(!array_key_exists($name, $this->attributes)) { + $this->attributes[$name] = array(); + } + + if ($base64) { + foreach(explode('_', $value) AS $v) { + $this->attributes[$name][] = base64_decode($v); + } + } else { + $this->attributes[$name][] = $value; } } - return $nameID; + } + + + /** + * This function processes a Assertion node. It will throw an exception if the assertion is invalid. + */ + private function processAssertion($assertion) { + + /* Make sure that the assertion is signed. */ + if(!$this->validator->isNodeValidated($assertion)) { + throw new Exception('A SAML2 AuthnResponse contained an Assertion which isn\'t verified by' . + ' the signature.'); + } + + $subject = $this->doXPathQuery('saml:Subject', $assertion)->item(0); + if($subject === NULL) { + if(!$this->isValidationRelaxed('nosubject')) { + throw new Exception('Could not find required Subject information in a SAML2' . + ' AuthnResponse. To disable this check, add \'nosubject\' to the' . + ' \'saml2.relaxvalidation\' list in \'saml2-idp-remote\'.'); + } + } else { + $this->processSubject($subject); + } + + $conditions = $this->doXPathQuery('saml:Conditions', $assertion)->item(0); + if($conditions === NULL) { + if(!$this->isValidationRelaxed('noconditions')) { + throw new Exception('Could not find required Conditions node in a SAML2' . + ' AuthnResponse. To disable this check, add \'noconditions\' to the' . + ' \'saml2.relaxvalidation\' list in \'saml2-idp-remote\'.'); + } + } else { + $this->processConditions($conditions); + } + + $authnStatement = $this->doXPathQuery('saml:AuthnStatement', $assertion)->item(0); + if($authnStatement === NULL) { + if(!$this->isValidationRelaxed('noauthnstatement')) { + throw new Exception('Could not find required AuthnStatement node in a SAML2' . + ' AuthnResponse. To disable this check, add \'noauthnstatement\' to the' . + ' \'saml2.relaxvalidation\' list in \'saml2-idp-remote\'.'); + } + } else { + $this->processAuthnStatement($authnStatement); + } + + $attributeStatement = $this->doXPathQuery('saml:AttributeStatement', $assertion)->item(0); + if($attributeStatement === NULL) { + if(!$this->isValidationRelaxed('noattributestatement')) { + throw new Exception('Could not find required AttributeStatement in a SAML2' . + ' AuthnResponse. To disable this check, add \'noattributestatement\' to the' . + ' \'saml2.relaxvalidation\' list in \'saml2-idp-remote\'.'); + } + } else { + $this->processAttributeStatement($attributeStatement); + } + } + + + /** + * This function processes a response message and adds information from it to the + * current session if it is valid. It throws an exception if it is invalid. + */ + public function process() { + /* Find the issuer of this response. */ + $this->issuer = $this->findIssuer(); + + /* Validate the signature element. */ + $this->validateSignature(); + + /* Process all assertions. */ + $assertions = $this->doXPathQuery('/samlp:Response/saml:Assertion'); + foreach($assertions as $assertion) { + $this->processAssertion($assertion); + } + + if($this->nameid === NULL) { + throw new Exception('No nameID found in AuthnResponse.'); + } + + /* Update the session information */ + SimpleSAML_Session::init(true, 'saml2'); + $session = SimpleSAML_Session::getInstance(); + + $session->setAttributes($this->attributes); + $session->setNameID($this->nameid); + $session->setSessionIndex($this->sessionIndex); + $session->setIdP($this->issuer); } diff --git a/metadata-templates/saml20-idp-remote.php b/metadata-templates/saml20-idp-remote.php index f97c08c14a7101a9394e6f047ef5b621a13415ab..a9e934c7d11f0bd82b68c025180f2768b8c4e2ad 100644 --- a/metadata-templates/saml20-idp-remote.php +++ b/metadata-templates/saml20-idp-remote.php @@ -31,6 +31,25 @@ $metadata = array( 'request.signing' => false, 'certificate' => "idp.example.org.crt", + /* + * It is possible to relax some parts of the validation of SAML2 messages. + * To relax a part, add the id to the 'saml2.relaxvalidation' array. + * + * Valid ids: + * - 'unknowncondition' Disables errors when encountering unknown <Condition> nodes. + * - 'nosubject' Ignore missing <Subject> in <Assertion>. + * - 'noconditions' Ignore missing <Conditions> in <Assertion>. + * - 'noauthnstatement' Ignore missing <AuthnStatement> in <Assertion>. + * - 'noattributestatement' Ignore missing <AttributeStatement> in <Assertion>. + * + * Example: + * 'saml2.relaxvalidation' => array('unknowncondition', 'noattributestatement'), + * + * Default: + * 'saml2.relaxvalidation' => array(), + */ + 'saml2.relaxvalidation' => array(), + ), diff --git a/www/saml2/sp/AssertionConsumerService.php b/www/saml2/sp/AssertionConsumerService.php index 2101498deac125adb874d2f8eb2990f8e747ee3d..650ee213d1285f0e41160f2966fda89f86c5748a 100644 --- a/www/saml2/sp/AssertionConsumerService.php +++ b/www/saml2/sp/AssertionConsumerService.php @@ -41,25 +41,17 @@ try { $binding = new SimpleSAML_Bindings_SAML20_HTTPPost($config, $metadata); $authnResponse = $binding->decodeResponse($_POST); - - $authnResponse->validate(); - - $session = $authnResponse->createSession(); - if (isset($session)) { - - $attributes = $session->getAttributes(); - $logger->log(LOG_NOTICE, $session->getTrackID(), 'SAML2.0', 'SP.AssertionConsumerService', 'AuthnResponse', '-', - 'Successfully created local session from Authentication Response'); + $authnResponse->process(); + + $logger->log(LOG_NOTICE, $session->getTrackID(), 'SAML2.0', 'SP.AssertionConsumerService', 'AuthnResponse', '-', + 'Successfully created local session from Authentication Response'); - $relayState = $authnResponse->getRelayState(); - if (isset($relayState)) { - SimpleSAML_Utilities::redirect($relayState); - } else { - throw new Exception('Could not find RelayState parameter, you are stucked here.'); - } + $relayState = $authnResponse->getRelayState(); + if (isset($relayState)) { + SimpleSAML_Utilities::redirect($relayState); } else { - throw new Exception('Unkown error. Could not get session.'); + throw new Exception('Could not find RelayState parameter, you are stuck here.'); } } catch(Exception $exception) {