Completed
Pull Request — master (#1132)
by Tim
15:36
created

lib/SimpleSAML/XML/Shib13/AuthnResponse.php (1 issue)

Severity
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * A Shibboleth 1.3 authentication response.
7
 *
8
 * @author Andreas Åkre Solberg, UNINETT AS. <[email protected]>
9
 * @package SimpleSAMLphp
10
 * @deprecated This class will be removed in a future release
11
 */
12
13
namespace SimpleSAML\XML\Shib13;
14
15
use DOMDocument;
16
use DOMNode;
17
use DOMNodeList;
18
use DOMXpath;
19
use SAML2\DOMDocumentFactory;
20
use SimpleSAML\Configuration;
21
use SimpleSAML\Error;
22
use SimpleSAML\Metadata\MetaDataStorageHandler;
23
use SimpleSAML\Utils;
24
use SimpleSAML\XML\Validator;
25
26
class AuthnResponse
27
{
28
    /**
29
     * @var \SimpleSAML\XML\Validator|null This variable contains an XML validator for this message.
30
     */
31
    private $validator = null;
32
33
34
    /**
35
     * @var bool Whether this response was validated by some external means (e.g. SSL).
36
     */
37
    private $messageValidated = false;
38
39
40
    /** @var string */
41
    const SHIB_PROTOCOL_NS = 'urn:oasis:names:tc:SAML:1.0:protocol';
42
43
44
    /** @var string */
45
    const SHIB_ASSERT_NS = 'urn:oasis:names:tc:SAML:1.0:assertion';
46
47
48
    /**
49
     * @var \DOMDocument|null The DOMDocument which represents this message.
50
     */
51
    private $dom = null;
52
53
    /**
54
     * @var string|null The relaystate which is associated with this response.
55
     */
56
    private $relayState = null;
57
58
59
    /**
60
     * Set whether this message was validated externally.
61
     *
62
     * @param bool $messageValidated  TRUE if the message is already validated, FALSE if not.
63
     * @return void
64
     */
65
    public function setMessageValidated($messageValidated)
66
    {
67
        assert(is_bool($messageValidated));
68
69
        $this->messageValidated = $messageValidated;
70
    }
71
72
73
    /**
74
     * @param string $xml
75
     * @throws \Exception
76
     * @return void
77
     */
78
    public function setXML($xml)
79
    {
80
        assert(is_string($xml));
81
82
        try {
83
            $this->dom = DOMDocumentFactory::fromString(str_replace("\r", "", $xml));
84
        } catch (\Exception $e) {
85
            throw new \Exception('Unable to parse AuthnResponse XML.');
86
        }
87
    }
88
89
90
    /**
91
     * @param string|null $relayState
92
     * @return void
93
     */
94
    public function setRelayState($relayState)
95
    {
96
        $this->relayState = $relayState;
97
    }
98
99
100
    /**
101
     * @return string|null
102
     */
103
    public function getRelayState()
104
    {
105
        return $this->relayState;
106
    }
107
108
109
    /**
110
     * @throws \SimpleSAML\Error\Exception
111
     * @return bool
112
     */
113
    public function validate()
114
    {
115
        assert($this->dom instanceof DOMDocument);
116
117
        if ($this->messageValidated) {
118
            // This message was validated externally
119
            return true;
120
        }
121
122
        // Validate the signature
123
        $this->validator = new Validator($this->dom, ['ResponseID', 'AssertionID']);
124
125
        // Get the issuer of the response
126
        $issuer = $this->getIssuer();
127
128
        // Get the metadata of the issuer
129
        $metadata = MetaDataStorageHandler::getMetadataHandler();
130
        $md = $metadata->getMetaDataConfig($issuer, 'shib13-idp-remote');
131
132
        $publicKeys = $md->getPublicKeys('signing');
133
        if (!empty($publicKeys)) {
134
            $certFingerprints = [];
135
            foreach ($publicKeys as $key) {
136
                if ($key['type'] !== 'X509Certificate') {
137
                    continue;
138
                }
139
                $certFingerprints[] = sha1(base64_decode($key['X509Certificate']));
140
            }
141
            $this->validator->validateFingerprint($certFingerprints);
142
        } elseif ($md->hasValue('certFingerprint')) {
143
            $certFingerprints = $md->getArrayizeString('certFingerprint');
144
145
            // Validate the fingerprint
146
            $this->validator->validateFingerprint($certFingerprints);
147
        } elseif ($md->hasValue('caFile')) {
148
            // Validate against CA
149
            $this->validator->validateCA(Utils\Config::getCertPath($md->getString('caFile')));
150
        } else {
151
            throw new Error\Exception(
152
                'Missing certificate in Shibboleth 1.3 IdP Remote metadata for identity provider [' . $issuer . '].'
153
            );
154
        }
155
156
        return true;
157
    }
158
159
160
    /**
161
     * Checks if the given node is validated by the signature on this response.
162
     *
163
     * @param \DOMElement|\SimpleXMLElement $node Node to be validated.
164
     * @return bool TRUE if the node is validated or FALSE if not.
165
     */
166
    private function isNodeValidated($node): bool
167
    {
168
        if ($this->messageValidated) {
169
            // This message was validated externally
170
            return true;
171
        }
172
173
        if ($this->validator === null) {
174
            return false;
175
        }
176
177
        // Convert the node to a DOM node if it is an element from SimpleXML
178
        if ($node instanceof \SimpleXMLElement) {
179
            $node = dom_import_simplexml($node);
180
        }
181
182
        assert($node instanceof DOMNode);
183
184
        return $this->validator->isNodeValidated($node);
185
    }
186
187
188
    /**
189
     * This function runs an xPath query on this authentication response.
190
     *
191
     * @param string $query   The query which should be run.
192
     * @param \DOMNode $node  The node which this query is relative to. If this node is NULL (the default)
193
     *                        then the query will be relative to the root of the response.
194
     * @return \DOMNodeList
195
     */
196
    private function doXPathQuery(string $query, DOMNode $node = null): DOMNodeList
197
    {
198
        assert($this->dom instanceof DOMDocument);
199
200
        if ($node === null) {
201
            $node = $this->dom->documentElement;
202
        }
203
204
        assert($node instanceof DOMNode);
205
206
        $xPath = new DOMXpath($this->dom);
207
        $xPath->registerNamespace('shibp', self::SHIB_PROTOCOL_NS);
208
        $xPath->registerNamespace('shib', self::SHIB_ASSERT_NS);
209
210
        return $xPath->query($query, $node);
211
    }
212
213
214
    /**
215
     * Retrieve the session index of this response.
216
     *
217
     * @return string|null  The session index of this response.
218
     */
219
    public function getSessionIndex()
220
    {
221
        assert($this->dom instanceof DOMDocument);
222
223
        $query = '/shibp:Response/shib:Assertion/shib:AuthnStatement';
224
        $nodelist = $this->doXPathQuery($query);
225
        if ($node = $nodelist->item(0)) {
226
            return $node->getAttribute('SessionIndex');
227
        }
228
229
        return null;
230
    }
231
232
    
233
    /**
234
     * @throws \Exception
235
     * @return array
236
     */
237
    public function getAttributes()
238
    {
239
        $metadata = MetaDataStorageHandler::getMetadataHandler();
240
        $md = $metadata->getMetaData($this->getIssuer(), 'shib13-idp-remote');
241
        $base64 = isset($md['base64attributes']) ? $md['base64attributes'] : false;
242
243
        if (!($this->dom instanceof DOMDocument)) {
0 ignored issues
show
$this->dom is always a sub-type of DOMDocument.
Loading history...
244
            return [];
245
        }
246
247
        $attributes = [];
248
249
        $assertions = $this->doXPathQuery('/shibp:Response/shib:Assertion');
250
251
        foreach ($assertions as $assertion) {
252
            if (!$this->isNodeValidated($assertion)) {
253
                throw new \Exception('Shib13 AuthnResponse contained an unsigned assertion.');
254
            }
255
256
            $conditions = $this->doXPathQuery('shib:Conditions', $assertion);
257
            if ($conditions->length > 0) {
258
                $condition = $conditions->item(0);
259
260
                $start = $condition->getAttribute('NotBefore');
261
                $end = $condition->getAttribute('NotOnOrAfter');
262
263
                if ($start && $end) {
264
                    if (!self::checkDateConditions($start, $end)) {
265
                        error_log('Date check failed ... (from ' . $start . ' to ' . $end . ')');
266
                        continue;
267
                    }
268
                }
269
            }
270
271
            $attribute_nodes = $this->doXPathQuery(
272
                'shib:AttributeStatement/shib:Attribute/shib:AttributeValue',
273
                $assertion
274
            );
275
276
            foreach ($attribute_nodes as $attribute) {
277
                /** @var \DOMElement $attribute */
278
279
                $value = $attribute->textContent;
280
                /** @var \DOMElement $parentNode */
281
                $parentNode = $attribute->parentNode;
282
                $name = $parentNode->getAttribute('AttributeName');
283
284
                if ($attribute->hasAttribute('Scope')) {
285
                    $scopePart = '@' . $attribute->getAttribute('Scope');
286
                } else {
287
                    $scopePart = '';
288
                }
289
290
                if (empty($name)) {
291
                    throw new \Exception('Shib13 Attribute node without an AttributeName.');
292
                }
293
294
                if (!array_key_exists($name, $attributes)) {
295
                    $attributes[$name] = [];
296
                }
297
298
                if ($base64) {
299
                    $encodedvalues = explode('_', $value);
300
                    foreach ($encodedvalues as $v) {
301
                        $attributes[$name][] = base64_decode($v) . $scopePart;
302
                    }
303
                } else {
304
                    $attributes[$name][] = $value . $scopePart;
305
                }
306
            }
307
        }
308
309
        return $attributes;
310
    }
311
312
    
313
    /**
314
     * @throws \Exception
315
     * @return string
316
     */
317
    public function getIssuer()
318
    {
319
        $query = '/shibp:Response/shib:Assertion/@Issuer';
320
        $nodelist = $this->doXPathQuery($query);
321
322
        if ($attr = $nodelist->item(0)) {
323
            return $attr->value;
324
        } else {
325
            throw new \Exception('Could not find Issuer field in Authentication response');
326
        }
327
    }
328
329
330
    /**
331
     * @return array
332
     */
333
    public function getNameID()
334
    {
335
        $nameID = [];
336
337
        $query = '/shibp:Response/shib:Assertion/shib:AuthenticationStatement/shib:Subject/shib:NameIdentifier';
338
        $nodelist = $this->doXPathQuery($query);
339
340
        if ($node = $nodelist->item(0)) {
341
            $nameID["Value"] = $node->nodeValue;
342
            $nameID["Format"] = $node->getAttribute('Format');
343
        }
344
345
        return $nameID;
346
    }
347
348
349
    /**
350
     * Build a authentication response.
351
     *
352
     * @param \SimpleSAML\Configuration $idp Metadata for the IdP the response is sent from.
353
     * @param \SimpleSAML\Configuration $sp Metadata for the SP the response is sent to.
354
     * @param string $shire The endpoint on the SP the response is sent to.
355
     * @param array|null $attributes The attributes which should be included in the response.
356
     * @return string The response.
357
     */
358
    public function generate(Configuration $idp, Configuration $sp, $shire, $attributes)
359
    {
360
        assert(is_string($shire));
361
        assert($attributes === null || is_array($attributes));
362
363
        if ($sp->hasValue('scopedattributes')) {
364
            $scopedAttributes = $sp->getArray('scopedattributes');
365
        } elseif ($idp->hasValue('scopedattributes')) {
366
            $scopedAttributes = $idp->getArray('scopedattributes');
367
        } else {
368
            $scopedAttributes = [];
369
        }
370
371
        $id = Utils\Random::generateID();
372
        
373
        $issueInstant = Utils\Time::generateTimestamp();
374
        
375
        // 30 seconds timeskew back in time to allow differing clocks
376
        $notBefore = Utils\Time::generateTimestamp(time() - 30);
377
        
378
        
379
        $assertionExpire = Utils\Time::generateTimestamp(time() + 300); // 5 minutes
380
        $assertionid = Utils\Random::generateID();
381
382
        $spEntityId = $sp->getString('entityid');
383
384
        $audience = $sp->getString('audience', $spEntityId);
385
        $base64 = $sp->getBoolean('base64attributes', false);
386
387
        $namequalifier = $sp->getString('NameQualifier', $spEntityId);
388
        $nameid = Utils\Random::generateID();
389
        $subjectNode =
390
            '<Subject>' .
391
            '<NameIdentifier' .
392
            ' Format="urn:mace:shibboleth:1.0:nameIdentifier"' .
393
            ' NameQualifier="' . htmlspecialchars($namequalifier) . '"' .
394
            '>' .
395
            htmlspecialchars($nameid) .
396
            '</NameIdentifier>' .
397
            '<SubjectConfirmation>' .
398
            '<ConfirmationMethod>' .
399
            'urn:oasis:names:tc:SAML:1.0:cm:bearer' .
400
            '</ConfirmationMethod>' .
401
            '</SubjectConfirmation>' .
402
            '</Subject>';
403
404
        $encodedattributes = '';
405
406
        if (is_array($attributes)) {
407
            $encodedattributes .= '<AttributeStatement>';
408
            $encodedattributes .= $subjectNode;
409
410
            foreach ($attributes as $name => $value) {
411
                $encodedattributes .= $this->encAttribute($name, $value, $base64, $scopedAttributes);
412
            }
413
414
            $encodedattributes .= '</AttributeStatement>';
415
        }
416
417
        /*
418
         * The SAML 1.1 response message
419
         */
420
        $response = '<Response xmlns="urn:oasis:names:tc:SAML:1.0:protocol"
421
    xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion"
422
    xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
423
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IssueInstant="' . $issueInstant . '"
424
    MajorVersion="1" MinorVersion="1"
425
    Recipient="' . htmlspecialchars($shire) . '" ResponseID="' . $id . '">
426
    <Status>
427
        <StatusCode Value="samlp:Success" />
428
    </Status>
429
    <Assertion xmlns="urn:oasis:names:tc:SAML:1.0:assertion"
430
        AssertionID="' . $assertionid . '" IssueInstant="' . $issueInstant . '"
431
        Issuer="' . htmlspecialchars($idp->getString('entityid')) . '" MajorVersion="1" MinorVersion="1">
432
        <Conditions NotBefore="' . $notBefore . '" NotOnOrAfter="' . $assertionExpire . '">
433
            <AudienceRestrictionCondition>
434
                <Audience>' . htmlspecialchars($audience) . '</Audience>
435
            </AudienceRestrictionCondition>
436
        </Conditions>
437
        <AuthenticationStatement AuthenticationInstant="' . $issueInstant . '"
438
            AuthenticationMethod="urn:oasis:names:tc:SAML:1.0:am:unspecified">' .
439
            $subjectNode . '
440
        </AuthenticationStatement>
441
        ' . $encodedattributes . '
442
    </Assertion>
443
</Response>';
444
445
        return $response;
446
    }
447
448
449
    /**
450
     * Format a shib13 attribute.
451
     *
452
     * @param string $name  Name of the attribute.
453
     * @param array $values  Values of the attribute (as an array of strings).
454
     * @param bool $base64  Whether the attriubte values should be base64-encoded.
455
     * @param array $scopedAttributes  Array of attributes names which are scoped.
456
     * @return string  The attribute encoded as an XML-string.
457
     */
458
    private function encAttribute(string $name, array $values, bool $base64, array $scopedAttributes): string
459
    {
460
        if (in_array($name, $scopedAttributes, true)) {
461
            $scoped = true;
462
        } else {
463
            $scoped = false;
464
        }
465
466
        $attr = '<Attribute AttributeName="' . htmlspecialchars($name) .
467
            '" AttributeNamespace="urn:mace:shibboleth:1.0:attributeNamespace:uri">';
468
        foreach ($values as $value) {
469
            $scopePart = '';
470
            if ($scoped) {
471
                $tmp = explode('@', $value, 2);
472
                if (count($tmp) === 2) {
473
                    $value = $tmp[0];
474
                    $scopePart = ' Scope="' . htmlspecialchars($tmp[1]) . '"';
475
                }
476
            }
477
478
            if ($base64) {
479
                $value = base64_encode($value);
480
            }
481
482
            $attr .= '<AttributeValue' . $scopePart . '>' . htmlspecialchars($value) . '</AttributeValue>';
483
        }
484
        $attr .= '</Attribute>';
485
486
        return $attr;
487
    }
488
489
    /**
490
     * Check if we are currently between the given date & time conditions.
491
     *
492
     * Note that this function allows a 10-minute leap from the initial time as marked by $start.
493
     *
494
     * @param string|null $start A SAML2 timestamp marking the start of the period to check. Defaults to null, in which
495
     *     case there's no limitations in the past.
496
     * @param string|null $end A SAML2 timestamp marking the end of the period to check. Defaults to null, in which
497
     *     case there's no limitations in the future.
498
     *
499
     * @return bool True if the current time belongs to the period specified by $start and $end. False otherwise.
500
     *
501
     * @see \SAML2\Utils::xsDateTimeToTimestamp.
502
     *
503
     * @author Andreas Solberg, UNINETT AS <[email protected]>
504
     * @author Olav Morken, UNINETT AS <[email protected]>
505
     */
506
    protected static function checkDateConditions($start = null, $end = null)
507
    {
508
        $currentTime = time();
509
510
        if (!empty($start)) {
511
            $startTime = \SAML2\Utils::xsDateTimeToTimestamp($start);
512
            // allow for a 10 minute difference in time
513
            if (($startTime < 0) || (($startTime - 600) > $currentTime)) {
514
                return false;
515
            }
516
        }
517
        if (!empty($end)) {
518
            $endTime = \SAML2\Utils::xsDateTimeToTimestamp($end);
519
            if (($endTime < 0) || ($endTime <= $currentTime)) {
520
                return false;
521
            }
522
        }
523
        return true;
524
    }
525
}
526