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 */ |
|
|
|
|
31
|
|
|
protected static $idp_metadata_url = null; |
32
|
|
|
/**@var $idp_metadata_contents string */ |
|
|
|
|
33
|
|
|
protected static $idp_metadata_contents = null; |
34
|
|
|
/**@var $idp_certificate XMLSecurityKey */ |
|
|
|
|
35
|
|
|
protected static $idp_certificate = null; |
36
|
|
|
/**@var $own_certificate XMLSecurityKey */ |
|
|
|
|
37
|
|
|
protected static $own_certificate = null; |
38
|
|
|
/**@var $own_private_key XMLSecurityKey */ |
|
|
|
|
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) { |
|
|
|
|
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); |
|
|
|
|
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
|
|
|
} |