1 | <?php |
||||||
2 | |||||||
3 | declare(strict_types=1); |
||||||
4 | |||||||
5 | namespace OMSAML2; |
||||||
6 | |||||||
7 | use DOMElement; |
||||||
8 | use Exception; |
||||||
9 | use RobRichards\XMLSecLibs\XMLSecurityKey; |
||||||
10 | use SAML2\AuthnRequest; |
||||||
11 | use SAML2\Compat\AbstractContainer; |
||||||
12 | use SAML2\Compat\ContainerSingleton; |
||||||
13 | use SAML2\Constants; |
||||||
14 | use SAML2\DOMDocumentFactory; |
||||||
15 | use SAML2\SignedElement; |
||||||
16 | use SAML2\Utils; |
||||||
17 | use SAML2\XML\md\EntityDescriptor; |
||||||
18 | use SAML2\XML\md\IDPSSODescriptor; |
||||||
19 | use SAML2\XML\saml\Issuer; |
||||||
20 | |||||||
21 | /** |
||||||
22 | * @package OMSAML2 |
||||||
23 | */ |
||||||
24 | class OMSAML2 |
||||||
25 | { |
||||||
26 | public const LOA_LOW = 'http://eidas.europa.eu/LoA/low'; |
||||||
27 | public const LOA_SUBSTANTIAL = 'http://eidas.europa.eu/LoA/substantial'; |
||||||
28 | public const LOA_HIGH = 'http://eidas.europa.eu/LoA/high'; |
||||||
29 | |||||||
30 | /**@var $idp_metadata_url string */ |
||||||
0 ignored issues
–
show
Documentation
Bug
introduced
by
![]() |
|||||||
31 | protected static $idp_metadata_url = null; |
||||||
32 | /**@var $idp_metadata_contents string */ |
||||||
0 ignored issues
–
show
|
|||||||
33 | protected static $idp_metadata_contents = null; |
||||||
34 | /**@var $idp_certificate XMLSecurityKey */ |
||||||
0 ignored issues
–
show
|
|||||||
35 | protected static $idp_certificate = null; |
||||||
36 | /**@var $own_certificate XMLSecurityKey */ |
||||||
0 ignored issues
–
show
|
|||||||
37 | protected static $own_certificate = null; |
||||||
38 | /**@var $own_private_key XMLSecurityKey */ |
||||||
0 ignored issues
–
show
|
|||||||
39 | protected static $own_private_key = null; |
||||||
40 | |||||||
41 | /** |
||||||
42 | * Reset state of whole component to default |
||||||
43 | */ |
||||||
44 | public static function reset(): void |
||||||
45 | { |
||||||
46 | self::$idp_metadata_contents = null; |
||||||
47 | self::$idp_metadata_url = null; |
||||||
48 | self::$own_private_key = null; |
||||||
49 | self::$own_certificate = null; |
||||||
50 | self::$idp_certificate = null; |
||||||
51 | } |
||||||
52 | |||||||
53 | /** |
||||||
54 | * Returns associative array of found Logout URLs |
||||||
55 | * keys are binding constants, such as Constants::BINDING_HTTP_REDIRECT |
||||||
56 | * |
||||||
57 | * @param EntityDescriptor|null $idp_descriptor |
||||||
58 | * @return array |
||||||
59 | * @throws Exception |
||||||
60 | * @see Constants |
||||||
61 | * |
||||||
62 | */ |
||||||
63 | public static function extractSSOLogoutUrls(?EntityDescriptor $idp_descriptor = null): array |
||||||
64 | { |
||||||
65 | return self::extractSSOUrls(true, $idp_descriptor); |
||||||
66 | } |
||||||
67 | |||||||
68 | /** |
||||||
69 | * @param bool $extract_logout_urls |
||||||
70 | * @param EntityDescriptor $idp_descriptor |
||||||
71 | * @return array |
||||||
72 | * @throws Exception |
||||||
73 | */ |
||||||
74 | public static function extractSSOUrls(bool $extract_logout_urls = false, ?EntityDescriptor $idp_descriptor = null): array |
||||||
75 | { |
||||||
76 | if (empty($idp_descriptor)) { |
||||||
77 | $idp_descriptor = self::getIdpDescriptor(); |
||||||
78 | } |
||||||
79 | |||||||
80 | $idp_sso_descriptor = false; |
||||||
81 | if ($idp_descriptor instanceof EntityDescriptor) { |
||||||
0 ignored issues
–
show
|
|||||||
82 | foreach ($idp_descriptor->getRoleDescriptor() as $role_descriptor) { |
||||||
83 | if ($role_descriptor instanceof IDPSSODescriptor) { |
||||||
84 | $idp_sso_descriptor = $role_descriptor; |
||||||
85 | } |
||||||
86 | } |
||||||
87 | } |
||||||
88 | |||||||
89 | $found = []; |
||||||
90 | |||||||
91 | if ($idp_sso_descriptor instanceof IDPSSODescriptor) { |
||||||
92 | foreach ($extract_logout_urls ? $idp_sso_descriptor->getSingleLogoutService() : $idp_sso_descriptor->getSingleSignOnService() as $descriptorType) { |
||||||
93 | if (empty($descriptorType->getBinding())) { |
||||||
94 | continue; |
||||||
95 | } |
||||||
96 | $found[$descriptorType->getBinding()] = $descriptorType->getLocation(); |
||||||
97 | } |
||||||
98 | } |
||||||
99 | |||||||
100 | return $found; |
||||||
101 | } |
||||||
102 | |||||||
103 | /** |
||||||
104 | * Returns EntityDescriptor instance or null, if metadata could not be fetched |
||||||
105 | * throws exception in case of invalid or dangerous XML contents |
||||||
106 | * |
||||||
107 | * @param string|null $metadata_string |
||||||
108 | * @return EntityDescriptor|null null if provided string or automatically retrieved string is empty |
||||||
109 | * @throws Exception |
||||||
110 | */ |
||||||
111 | public static function getIdpDescriptor(?string $metadata_string = null): ?EntityDescriptor |
||||||
112 | { |
||||||
113 | if (empty($metadata_string)) { |
||||||
114 | $metadata_string = self::getIdPMetadataContents(); |
||||||
115 | if (empty($metadata_string)) { |
||||||
116 | return null; |
||||||
117 | } |
||||||
118 | } |
||||||
119 | $metadata_dom = DOMDocumentFactory::fromString($metadata_string); |
||||||
120 | return new EntityDescriptor($metadata_dom->documentElement); |
||||||
121 | } |
||||||
122 | |||||||
123 | /** |
||||||
124 | * Returns cached or freshly retrieved IdP metadata as a string, or null |
||||||
125 | * |
||||||
126 | * @param string|null $url |
||||||
127 | * @return null|string |
||||||
128 | * @throws Exception |
||||||
129 | */ |
||||||
130 | public static function getIdPMetadataContents(?string $url = null): ?string |
||||||
131 | { |
||||||
132 | if (!empty($url)) { |
||||||
133 | self::setIdPMetadataUrl($url); |
||||||
134 | } |
||||||
135 | if (empty(self::$idp_metadata_contents)) { |
||||||
136 | if (empty(self::getIdPMetadataUrl())) { |
||||||
137 | throw new Exception("IdP Metadata URL not yet configured"); |
||||||
138 | } |
||||||
139 | $idp_metadata_contents_fresh = file_get_contents(self::getIdPMetadataUrl()); |
||||||
140 | self::setIdPMetadataContents($idp_metadata_contents_fresh); |
||||||
141 | } |
||||||
142 | return self::$idp_metadata_contents; |
||||||
143 | } |
||||||
144 | |||||||
145 | /** |
||||||
146 | * Sets metadata content cache to provided string contents or null, if provided value is empty |
||||||
147 | * |
||||||
148 | * @param string $contents |
||||||
149 | */ |
||||||
150 | public static function setIdPMetadataContents(?string $contents): void |
||||||
151 | { |
||||||
152 | $contents = trim($contents); |
||||||
153 | self::$idp_metadata_contents = empty($contents) ? null : $contents; |
||||||
154 | } |
||||||
155 | |||||||
156 | /** |
||||||
157 | * Retrieves currently configured IdP Metadata URL or null if current value is empty |
||||||
158 | * |
||||||
159 | * @return null|string |
||||||
160 | */ |
||||||
161 | public static function getIdPMetadataUrl(): ?string |
||||||
162 | { |
||||||
163 | return empty(self::$idp_metadata_url) ? null : self::$idp_metadata_url; |
||||||
164 | } |
||||||
165 | |||||||
166 | /** |
||||||
167 | * If provided URL is not string or is empty, will set null |
||||||
168 | * |
||||||
169 | * @param string $url |
||||||
170 | */ |
||||||
171 | public static function setIdPMetadataUrl(string $url): void |
||||||
172 | { |
||||||
173 | $url = trim($url); |
||||||
174 | if ($url != self::$idp_metadata_url) { |
||||||
175 | // empty metadata contents cache if URL changes |
||||||
176 | self::$idp_metadata_contents = null; |
||||||
177 | } |
||||||
178 | self::$idp_metadata_url = empty($url) ? null : $url; |
||||||
179 | } |
||||||
180 | |||||||
181 | /** |
||||||
182 | * Validates signed element (metadata, auth-response, logout-response) signature |
||||||
183 | * |
||||||
184 | * @param XMLSecurityKey $publicKey |
||||||
185 | * @param SignedElement|null $idp_descriptor if not provided, EntityDescriptor (IdP metadata) will be retrieved internally by configured URL |
||||||
186 | * @return bool |
||||||
187 | * @throws Exception |
||||||
188 | */ |
||||||
189 | public static function validateSignature(XMLSecurityKey $publicKey = null, ?SignedElement $idp_descriptor = null): bool |
||||||
190 | { |
||||||
191 | if (empty($idp_descriptor)) { |
||||||
192 | $idp_descriptor = self::getIdpDescriptor(); |
||||||
193 | } |
||||||
194 | |||||||
195 | if (empty($publicKey)) { |
||||||
196 | $publicKey = self::getIdpCertificate(); |
||||||
197 | } |
||||||
198 | |||||||
199 | return $idp_descriptor->validate($publicKey); |
||||||
0 ignored issues
–
show
It seems like
$publicKey can also be of type null ; however, parameter $key of SAML2\SignedElementHelper::validate() does only seem to accept RobRichards\XMLSecLibs\XMLSecurityKey , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() It seems like
$publicKey can also be of type null ; however, parameter $key of SAML2\SignedElement::validate() does only seem to accept RobRichards\XMLSecLibs\XMLSecurityKey , maybe add an additional type check?
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
200 | } |
||||||
201 | |||||||
202 | /** |
||||||
203 | * Returns internally configured IdP certificate as XMLSecurityKey or null if not configured yet |
||||||
204 | * |
||||||
205 | * @return XMLSecurityKey|null |
||||||
206 | */ |
||||||
207 | public static function getIdpCertificate(): ?XMLSecurityKey |
||||||
208 | { |
||||||
209 | return self::$idp_certificate; |
||||||
210 | } |
||||||
211 | |||||||
212 | /** |
||||||
213 | * @param string|null $certificate_url |
||||||
214 | * @param string|null $certificate_data |
||||||
215 | * @param string $algorithm |
||||||
216 | * @return bool |
||||||
217 | */ |
||||||
218 | public static function setIdpCertificate(?string $certificate_url = null, ?string $certificate_data = null, $algorithm = XMLSecurityKey::RSA_SHA256): bool |
||||||
219 | { |
||||||
220 | try { |
||||||
221 | self::$idp_certificate = new XMLSecurityKey($algorithm, ['type' => 'public']); |
||||||
222 | self::$idp_certificate->loadKey(empty($certificate_data) ? file_get_contents($certificate_url) : $certificate_data, false, true); |
||||||
223 | self::$idp_certificate->encryptData('abcdef'); |
||||||
224 | return true; |
||||||
225 | } catch (Exception $e) { |
||||||
226 | self::$idp_certificate = null; |
||||||
227 | ContainerSingleton::getInstance()->getLogger()->critical('setIdpCertificate failed: ' . $e->getMessage(), [$e]); |
||||||
228 | return false; |
||||||
229 | } |
||||||
230 | } |
||||||
231 | |||||||
232 | /** |
||||||
233 | * Returns stored certificate/public-key in form of XMLSecurityKey, or null if not configured |
||||||
234 | * |
||||||
235 | * @return null|XMLSecurityKey |
||||||
236 | */ |
||||||
237 | public static function getOwnCertificatePublicKey(): ?XMLSecurityKey |
||||||
238 | { |
||||||
239 | return self::$own_certificate; |
||||||
240 | } |
||||||
241 | |||||||
242 | /** |
||||||
243 | * Returns stored private-key as XMLSecurityKey instance, or null if not configured |
||||||
244 | * |
||||||
245 | * @return null|XMLSecurityKey |
||||||
246 | */ |
||||||
247 | public static function getOwnPrivateKey(): ?XMLSecurityKey |
||||||
248 | { |
||||||
249 | return self::$own_private_key; |
||||||
250 | } |
||||||
251 | |||||||
252 | /** |
||||||
253 | * Creates public key for verifying RSA/SHA256 signature |
||||||
254 | * |
||||||
255 | * @param string $path absolute path to certificate file or URL from which it can be retrieved |
||||||
256 | * @param string $algorithm |
||||||
257 | * @param string $type |
||||||
258 | * @return XMLSecurityKey |
||||||
259 | * @throws Exception |
||||||
260 | */ |
||||||
261 | public static function getPublicKeyFromCertificate(string $path, $algorithm = XMLSecurityKey::RSA_SHA256, $type = 'public'): XMLSecurityKey |
||||||
262 | { |
||||||
263 | $cert_data = file_get_contents($path); |
||||||
264 | $key = new XMLSecurityKey($algorithm, ['type' => $type]); |
||||||
265 | $key->loadKey($cert_data, false, true); |
||||||
266 | return $key; |
||||||
267 | } |
||||||
268 | |||||||
269 | /** |
||||||
270 | * Create private key for verifying RSA/SHA256 signature |
||||||
271 | * |
||||||
272 | * @param string $path absolute path to key file or URL from which it can be retrieved |
||||||
273 | * @param string $algorithm |
||||||
274 | * @param string $type |
||||||
275 | * @return XMLSecurityKey |
||||||
276 | * @throws Exception |
||||||
277 | */ |
||||||
278 | public static function getPrivateKeyFromFile(string $path, $algorithm = XMLSecurityKey::RSA_SHA256, $type = 'private'): XMLSecurityKey |
||||||
279 | { |
||||||
280 | $key_data = file_get_contents($path); |
||||||
281 | $key = new XMLSecurityKey($algorithm, ['type' => $type]); |
||||||
282 | $key->loadKey($key_data, false, false); |
||||||
283 | return $key; |
||||||
284 | } |
||||||
285 | |||||||
286 | /** |
||||||
287 | * Generates AuthRequest/AuthnRequest using provided information |
||||||
288 | * |
||||||
289 | * @param AbstractContainer $container |
||||||
290 | * @param string $issuer |
||||||
291 | * @param string $assertionConsumerServiceURL |
||||||
292 | * @param string $idpLoginRedirectUrl |
||||||
293 | * @param string $levelOfAssurance |
||||||
294 | * @param string $requestedAuthnContextComparison |
||||||
295 | * @return AuthnRequest |
||||||
296 | * @throws Exception |
||||||
297 | */ |
||||||
298 | public static function generateAuthRequest(AbstractContainer $container, string $issuer, string $assertionConsumerServiceURL, string $idpLoginRedirectUrl, string $levelOfAssurance, string $requestedAuthnContextComparison): AuthnRequest |
||||||
299 | { |
||||||
300 | ContainerSingleton::setContainer($container); |
||||||
301 | $request = new AuthnRequest(); |
||||||
302 | |||||||
303 | $issuerImpl = new Issuer(); |
||||||
304 | $issuerImpl->setValue($issuer); |
||||||
305 | |||||||
306 | $request->setIssuer($issuerImpl); |
||||||
307 | $request->setId($container->generateId()); |
||||||
308 | $request->setAssertionConsumerServiceURL($assertionConsumerServiceURL); |
||||||
309 | $request->setDestination($idpLoginRedirectUrl); |
||||||
310 | $request->setRequestedAuthnContext([ |
||||||
311 | 'AuthnContextClassRef' => [$levelOfAssurance], |
||||||
312 | 'Comparison' => $requestedAuthnContextComparison |
||||||
313 | ]); |
||||||
314 | |||||||
315 | return $request; |
||||||
316 | } |
||||||
317 | |||||||
318 | /** |
||||||
319 | * Signs given DOMDocument (ie. AuthRequest) with $privateKey, and optionally adds certificate, if provided. |
||||||
320 | * Signature type (ie. RSA/SHA256) is determined by type of XMLSecurityKey provided |
||||||
321 | * |
||||||
322 | * @param DOMElement $document |
||||||
323 | * @param XMLSecurityKey $privateKey |
||||||
324 | * @param XMLSecurityKey|null $certificate |
||||||
325 | * @return DOMElement |
||||||
326 | */ |
||||||
327 | public static function signDocument(DOMElement $document, XMLSecurityKey $privateKey = null, XMLSecurityKey $certificate = null): DOMElement |
||||||
328 | { |
||||||
329 | if (empty($privateKey)) { |
||||||
330 | $privateKey = self::$own_private_key; |
||||||
331 | } |
||||||
332 | |||||||
333 | $insertAfter = $document->firstChild; |
||||||
334 | |||||||
335 | if ($document->getElementsByTagName('Issuer')->length > 0) { |
||||||
336 | $insertAfter = $document->getElementsByTagName('Issuer')->item(0)->nextSibling; |
||||||
337 | } |
||||||
338 | |||||||
339 | Utils::insertSignature($privateKey, !empty($certificate) ? [$certificate] : [], $document, $insertAfter); |
||||||
340 | |||||||
341 | return $document; |
||||||
342 | } |
||||||
343 | |||||||
344 | /** |
||||||
345 | * Sets internal private-key from PEM data |
||||||
346 | * |
||||||
347 | * @param string $private_key_pem_string |
||||||
348 | * @param string $algorithm |
||||||
349 | * @return bool true if set successfully, false if operation failed (exception will not be thrown) |
||||||
350 | * @throws Exception |
||||||
351 | */ |
||||||
352 | public static function setOwnPrivateKeyData(string $private_key_pem_string, $algorithm = XMLSecurityKey::RSA_SHA256): bool |
||||||
353 | { |
||||||
354 | try { |
||||||
355 | self::$own_private_key = new XMLSecurityKey($algorithm, ['type' => 'private']); |
||||||
356 | self::$own_private_key->loadKey($private_key_pem_string, false, false); |
||||||
357 | self::$own_private_key->signData("abcdef"); |
||||||
358 | return true; |
||||||
359 | } catch (Exception $e) { |
||||||
360 | self::$own_private_key = null; |
||||||
361 | ContainerSingleton::getInstance()->getLogger()->critical('setOwnPrivateKeyData failed: ' . $e->getMessage(), [$e]); |
||||||
362 | return false; |
||||||
363 | } |
||||||
364 | } |
||||||
365 | |||||||
366 | /** |
||||||
367 | * Sets internal certificate/public-key from PEM certificate data |
||||||
368 | * |
||||||
369 | * @param string $certificate_pem_string |
||||||
370 | * @param string $algorithm |
||||||
371 | * @return bool true if set successfully, false if operation failed (exception will not be thrown) |
||||||
372 | * @throws Exception |
||||||
373 | */ |
||||||
374 | public static function setOwnCertificatePublicKey(string $certificate_pem_string, $algorithm = XMLSecurityKey::RSA_SHA256): bool |
||||||
375 | { |
||||||
376 | try { |
||||||
377 | self::$own_certificate = new XMLSecurityKey($algorithm, ['type' => 'public']); |
||||||
378 | self::$own_certificate->loadKey($certificate_pem_string, false, true); |
||||||
379 | self::$own_certificate->encryptData('abcdef'); |
||||||
380 | return true; |
||||||
381 | } catch (Exception $e) { |
||||||
382 | self::$own_certificate = null; |
||||||
383 | ContainerSingleton::getInstance()->getLogger()->critical('setOwnCertificatePublicKey failed: ' . $e->getMessage(), [$e]); |
||||||
384 | return false; |
||||||
385 | } |
||||||
386 | } |
||||||
387 | |||||||
388 | /** |
||||||
389 | * @param DOMElement $element |
||||||
390 | * @return string |
||||||
391 | * @throws Exception |
||||||
392 | */ |
||||||
393 | public static function getSSORedirectUrl(DOMElement $element): string |
||||||
394 | { |
||||||
395 | return self::getSAMLRequestUrl($element, self::extractSSOLoginUrls()[Constants::BINDING_HTTP_REDIRECT]); |
||||||
396 | } |
||||||
397 | |||||||
398 | /** |
||||||
399 | * @param DOMElement $element |
||||||
400 | * @param string $base_request_url |
||||||
401 | * @return string |
||||||
402 | */ |
||||||
403 | public static function getSAMLRequestUrl(DOMElement $element, $base_request_url): string |
||||||
404 | { |
||||||
405 | $encoded_element = $element->ownerDocument->saveXML(); |
||||||
406 | $encoded_element = gzdeflate($encoded_element); |
||||||
407 | $encoded_element = base64_encode($encoded_element); |
||||||
408 | $encoded_element = urlencode($encoded_element); |
||||||
409 | |||||||
410 | return $base_request_url . (parse_url($base_request_url, PHP_URL_QUERY) ? '&' : '?') . 'SAMLRequest=' . $encoded_element; |
||||||
411 | } |
||||||
412 | |||||||
413 | /** |
||||||
414 | * Returns associative array of found Login URLs |
||||||
415 | * keys are binding constants, such as Constants::BINDING_HTTP_POST |
||||||
416 | * |
||||||
417 | * @param EntityDescriptor|null $idp_descriptor |
||||||
418 | * @return array |
||||||
419 | * @throws Exception |
||||||
420 | * @see Constants |
||||||
421 | * |
||||||
422 | */ |
||||||
423 | public static function extractSSOLoginUrls(?EntityDescriptor $idp_descriptor = null): array |
||||||
424 | { |
||||||
425 | return self::extractSSOUrls(false, $idp_descriptor); |
||||||
426 | } |
||||||
427 | |||||||
428 | |||||||
429 | } |