diff --git a/modules/saml2/lib/Message.php b/modules/saml2/lib/Message.php index c332a3693f4beaaeb9774ac79ba312e9c976518d..1f8c9a39a913db3f035011c6c914810a9215efae 100644 --- a/modules/saml2/lib/Message.php +++ b/modules/saml2/lib/Message.php @@ -74,6 +74,179 @@ class sspmod_saml2_Message { } + /** + * Find the certificate used to sign a message or assertion. + * + * An exception is thrown if we are unable to locate the certificate. + * + * @param array $certFingerprints The fingerprints we are looking for. + * @param array $certificates Array of certificates. + * @return string Certificate, in PEM-format. + */ + private static function findCertificate(array $certFingerprints, array $certificates) { + + $candidates = array(); + + foreach ($certificates as $cert) { + $fp = strtolower(sha1(base64_decode($cert))); + if (!in_array($fp, $certFingerprints, TRUE)) { + $candidates[] = $fp; + continue; + } + + /* We have found a matching fingerprint. */ + $pem = "-----BEGIN CERTIFICATE-----\n" . + chunk_split($cert, 64) . + "-----END CERTIFICATE-----\n"; + return $pem; + } + + $candidates = "'" . implode("', '", $candidates) . "'"; + $fps = "'" . implode("', '", $certFingerprints) . "'"; + throw new SimpleSAML_Error_Exception('Unable to find a certificate matching the configured ' . + 'fingerprint. Candidates: ' . $candidates . '; certFingerprint: ' . $fps . '.'); + } + + + /** + * Check the signature on a SAML2 message or assertion. + * + * @param SimpleSAML_Configuration $srcMetadata The metadata of the sender. + * @param SAML2_SignedElement $element Either a SAML2_Response or a SAML2_Assertion. + */ + private static function checkSign(SimpleSAML_Configuration $srcMetadata, SAML2_SignedElement $element) { + + $certificates = $element->getCertificates(); + SimpleSAML_Logger::debug('Found ' . count($certificates) . ' certificates in ' . get_class($element)); + + /* Find the certificate that should verify signatures by this entity. */ + $certArray = SimpleSAML_Utilities::loadPublicKey($srcMetadata->toArray(), FALSE); + if ($certArray !== NULL) { + if (array_key_exists('PEM', $certArray)) { + $pemCert = $certArray['PEM']; + } else { + /* + * We don't have the full certificate stored. Try to find it + * in the message or the assertion instead. + */ + if (count($certificates) === 0) { + /* We need the full certificate in order to match it against the fingerprint. */ + SimpleSAML_Logger::debug('No certificate in message when validating against fingerprint.'); + return FALSE; + } + + $certFingerprints = $certArray['certFingerprint']; + if (count($certFingerprints) === 0) { + /* For some reason, we have a certFingerprint entry without any fingerprints. */ + throw new SimpleSAML_Error_Exception('certFingerprint array was empty.'); + } + + $pemCert = self::findCertificate($certFingerprints, $certificates); + } + } else { + /* Attempt CA validation. */ + $caFile = $srcMetadata->getString('caFile', NULL); + if ($caFile === NULL) { + throw new SimpleSAML_Error_Exception( + 'Missing certificate in metadata for ' . + var_export($srcMetadata->getString('entityid'), TRUE)); + } + $globalConfig = SimpleSAML_Configuration::getInstance(); + $caFile = $globalConfig->getPathValue('certdir') . $caFile; + + if (count($certificates) === 0) { + /* We need the full certificate in order to check it against the CA file. */ + SimpleSAML_Logger::debug('No certificate in message when validating with CA.'); + return FALSE; + } + + /* We assume that it is the first certificate that was used to sign the message. */ + $pemCert = "-----BEGIN CERTIFICATE-----\n" . + chunk_split($certificates[0], 64) . + "-----END CERTIFICATE-----\n"; + + SimpleSAML_Utilities::validateCA($pemCert, $caFile); + } + + + /* Extract the public key from the certificate for validation. */ + $key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA1, array('type'=>'public')); + $key->loadKey($pemCert); + + /* + * Make sure that we have a valid signature on either the response + * or the assertion. + */ + return $element->validate($key); + } + + + /** + * Decrypt an assertion. + * + * This function takes in a SAML2_Assertion and decrypts it if it is encrypted. + * If it is unencrypted, and encryption is enabled in the metadata, an exception + * will be throws. + * + * @param SimpleSAML_Configuration $srcMetadata The metadata of the sender (IdP). + * @param SimpleSAML_Configuration $dstMetadata The metadata of the recipient (SP). + * @param SAML2_Assertion|SAML2_EncryptedAssertion $assertion The assertion we are decrypting. + * @return SAML2_Assertion The assertion. + */ + private static function decryptAssertion(SimpleSAML_Configuration $srcMetadata, + SimpleSAML_Configuration $dstMetadata, $assertion) { + assert('$assertion instanceof SAML2_Assertion || $assertion instanceof SAML2_EncryptedAssertion'); + + if ($assertion instanceof SAML2_Assertion) { + $encryptAssertion = $srcMetadata->getBoolean('assertion.encryption', NULL); + if ($encryptAssertion === NULL) { + $encryptAssertion = $dstMetadata->getBoolean('assertion.encryption', FALSE); + } + if ($encryptAssertion) { + /* The assertion was unencrypted, but we have encryption enabled. */ + throw new Exception('Received unencrypted assertion, but encryption was enabled.'); + } + + return $assertion; + } + + + $sharedKey = $srcMetadata->getString('sharedkey', NULL); + if ($sharedKey !== NULL) { + $key = new XMLSecurityKey(XMLSecurityKey::AES128_CBC); + $key->loadKey($sharedKey); + } else { + /* Find the private key we should use to decrypt messages to this SP. */ + $keyArray = SimpleSAML_Utilities::loadPrivateKey($dstMetadata->toArray(), TRUE); + if (!array_key_exists('PEM', $keyArray)) { + throw new Exception('Unable to locate key we should use to decrypt the assertion.'); + } + + /* Extract the public key from the certificate for encryption. */ + $key = new XMLSecurityKey(XMLSecurityKey::RSA_1_5, array('type'=>'private')); + if (array_key_exists('password', $keyArray)) { + $key->passphrase = $keyArray['password']; + } + $key->loadKey($keyArray['PEM']); + } + + return $assertion->getAssertion($key); + } + + + /** + * Retrieve the status code of a response as a sspmod_saml2_error. + * + * @param SAML2_StatusResponse $response The response. + * @return sspmod_saml2_Error The error. + */ + public static function getResponseError(SAML2_StatusResponse $response) { + + $status = $response->getStatus(); + new sspmod_saml2_Error($status['Code'], $status['SubCode'], $status['Message']); + } + + /** * Build an authentication request based on information in the metadata. * @@ -119,6 +292,107 @@ class sspmod_saml2_Message { return $lr; } + + /** + * Process a response message. + * + * If the response is an error response, we will throw a sspmod_saml2_Error + * exception with the error. + * + * @param SimpleSAML_Configuration $spMetadata The metadata of the service provider. + * @param SimpleSAML_Configuration $idpMetadata The metadata of the identity provider. + * @param SAML2_Response $response The response. + * @return SAML2_Assertion The assertion in the response, if it is valid. + */ + public static function processResponse( + SimpleSAML_Configuration $spMetadata, SimpleSAML_Configuration $idpMetadata, + SAML2_Response $response + ) { + + if (!$response->isSuccess()) { + throw self::getResponseError($response); + } + + /* + * When we get this far, the response itself is valid. + * We only need to check signatures and conditions of the response. + */ + + $assertion = $response->getAssertions(); + if (empty($assertion)) { + throw new SimpleSAML_Error_Exception('No assertions found in response from IdP.'); + } elseif (count($assertion) > 1) { + throw new SimpleSAML_Error_Exception('More than one assertion found in response from IdP.'); + } + $assertion = $assertion[0]; + + $assertion = self::decryptAssertion($idpMetadata, $spMetadata, $assertion); + + if (!self::checkSign($idpMetadata, $assertion)) { + if (!self::checkSign($idpMetadata, $response)) { + throw new SimpleSAML_Error_Exception('Neither the assertion nor the response was signed.'); + } + } + /* At least one valid signature found. */ + + + /* Make sure that some fields in the assertion matches the same fields in the message. */ + + $asrtInResponseTo = $assertion->getInResponseTo(); + $msgInResponseTo = $response->getInResponseTo(); + if ($asrtInResponseTo !== NULL && $msgInResponseTo !== NULL) { + if ($asrtInResponseTo !== $msgInResponseTo) { + throw new SimpleSAML_Error_Exception('InResponseTo in assertion did not match InResponseTo in message.'); + } + } + + $asrtDestination = $assertion->getDestination(); + $msgDestination = $response->getDestination(); + if ($asrtDestination !== NULL && $msgDestination !== NULL) { + if ($asrtDestination !== $msgDestination) { + throw new SimpleSAML_Error_Exception('Destination in assertion did not match Destination in message.'); + } + } + + + /* Check various properties of the assertion. */ + + $notBefore = $assertion->getNotBefore(); + if ($notBefore > time() + 60) { + throw new SimpleSAML_Error_Exception('Received an assertion that is valid in the future. Check clock synchronization on IdP and SP.'); + } + + $notOnOrAfter = $assertion->getNotOnOrAfter(); + if ($notOnOrAfter <= time() - 60) { + throw new SimpleSAML_Error_Exception('Received an assertion that has expired. Check clock synchronization on IdP and SP.'); + } + + $sessionNotOnOrAfter = $assertion->getSessionNotOnOrAfter(); + if ($sessionNotOnOrAfter !== NULL && $sessionNotOnOrAfter <= time() - 60) { + throw new SimpleSAML_Error_Exception('Received an assertion with a session that has expired. Check clock synchronization on IdP and SP.'); + } + + $destination = $assertion->getDestination(); + $currentURL = SimpleSAML_Utilities::selfURLNoQuery(); + if ($destination !== $currentURL) { + throw new Exception('Recipient in assertion doesn\'t match the current URL. Recipient is "' . + $destination . '", current URL is "' . $currentURL . '".'); + } + + $validAudiences = $assertion->getValidAudiences(); + if ($validAudiences !== NULL) { + $spEntityId = $spMetadata->getString('entityid'); + if (!in_array($spEntityId, $validAudiences, TRUE)) { + $candidates = '[' . implode('], [', $validAudiences) . ']'; + throw new SimpleSAML_Error_Exception('This SP [' . $spEntityId . '] is not a valid audience for the assertion. Candidates were: ' . $candidates); + } + } + + /* As far as we can tell, the assertion is valid. */ + return $assertion; + } + + } ?> \ No newline at end of file diff --git a/modules/saml2/www/sp/acs.php b/modules/saml2/www/sp/acs.php index a43c70dbb686ae1e600396b39163d6603001ed6d..0230131f8538d33365e11a64a8fafa30e2aae427 100644 --- a/modules/saml2/www/sp/acs.php +++ b/modules/saml2/www/sp/acs.php @@ -4,15 +4,18 @@ * Assertion consumer service handler for SAML 2.0 SP authentication client. */ -if (!array_key_exists('SAMLResponse', $_POST)) { - throw new SimpleSAML_Error_BadRequest('Missing SAMLResponse to AssertionConsumerService'); +$b = SAML2_Binding::getCurrentBinding(); +$response = $b->receive(); +if (!($response instanceof SAML2_Response)) { + throw new SimpleSAML_Error_BadRequest('Invalid message received to AssertionConsumerService endpoint.'); } -if (!array_key_exists('RelayState', $_POST)) { - throw new SimpleSAML_Error_BadRequest('Missing RelayState to AssertionConsumerService'); +$relayState = $response->getRelayState(); +if (empty($relayState)) { + throw new SimpleSAML_Error_BadRequest('Missing relaystate in message received on AssertionConsumerService endpoint.'); } -$state = SimpleSAML_Auth_State::loadState($_POST['RelayState'], sspmod_saml2_Auth_Source_SP::STAGE_SENT); +$state = SimpleSAML_Auth_State::loadState($relayState, sspmod_saml2_Auth_Source_SP::STAGE_SENT); /* Find authentication source. */ assert('array_key_exists(sspmod_saml2_Auth_Source_SP::AUTHID, $state)'); @@ -23,22 +26,14 @@ if ($source === NULL) { throw new Exception('Could not find authentication source with id ' . $sourceId); } -$config = SimpleSAML_Configuration::getInstance(); -$metadata = SimpleSAML_Metadata_MetaDataStorageHandler::getMetadataHandler(); - -$binding = new SimpleSAML_Bindings_SAML20_HTTPPost($config, $metadata); -$authnResponse = $binding->decodeResponse($_POST); - -$result = $authnResponse->process(); - -/* Check status code. */ -if($result === FALSE) { - /* Not successful. */ - SimpleSAML_Auth_State::throwException($state, $authnResponse->getStatus()->toException()); +$idp = $response->getIssuer(); +if ($idp === NULL) { + throw new Exception('Missing <saml:Issuer> in message delivered to AssertionConsumerService.'); } -/* The response should include the entity id of the IdP. */ -$idp = $authnResponse->getIssuer(); +$metadata = SimpleSAML_Metadata_MetaDataStorageHandler::getMetadataHandler(); +$idpMetadata = $metadata->getMetaDataConfig($idp, 'saml20-idp-remote'); +$spMetadata = $metadata->getMetaDataConfig($source->getEntityId(), 'saml20-sp-hosted'); /* Check if the IdP is allowed to authenticate users for this authentication source. */ if (!$source->isIdPValid($idp)) { @@ -46,25 +41,29 @@ if (!$source->isIdPValid($idp)) { '. The IdP was ' . var_export($idp, TRUE)); } -/* - * Retrieve the name identifier. We also convert it to the format used by the - * logout request handler. - */ -$nameId = $authnResponse->getNameID(); -$nameId['Value'] = $nameId['value']; -unset($nameId['value']); + +try { + $assertion = sspmod_saml2_Message::processResponse($spMetadata, $idpMetadata, $response); +} catch (sspmod_saml2_Error $e) { + /* The status of the response wasn't "success". */ + $e = $e->toException(); + SimpleSAML_Auth_State::throwException($state, $e); +} + +$nameId = $assertion->getNameID(); +$sessionIndex = $assertion->getSessionIndex(); /* We need to save the NameID and SessionIndex for logout. */ $logoutState = array( sspmod_saml2_Auth_Source_SP::LOGOUT_IDP => $idp, sspmod_saml2_Auth_Source_SP::LOGOUT_NAMEID => $nameId, - sspmod_saml2_Auth_Source_SP::LOGOUT_SESSIONINDEX => $authnResponse->getSessionIndex(), + sspmod_saml2_Auth_Source_SP::LOGOUT_SESSIONINDEX => $sessionIndex, ); $state['LogoutState'] = $logoutState; $source->onLogin($idp, $state); -$state['Attributes'] = $authnResponse->getAttributes(); +$state['Attributes'] = $assertion->getAttributes(); SimpleSAML_Auth_Source::completeAuth($state); ?> \ No newline at end of file