Passed
Push — master ( 38fbdf...ca585a )
by Tim
02:00
created

AuthnResponse::doXPathQuery()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 8
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 15
rs 10
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
 */
11
12
namespace SimpleSAML\Module\casserver\Shib13;
13
14
use DOMDocument;
15
use DOMNode;
16
use DOMNodeList;
17
use DOMXpath;
18
use SAML2\DOMDocumentFactory;
19
use SimpleSAML\Configuration;
20
use SimpleSAML\Error;
21
use SimpleSAML\Metadata\MetaDataStorageHandler;
22
use SimpleSAML\Utils;
23
use SimpleSAML\XML\Validator;
24
25
class AuthnResponse
26
{
27
    /**
28
     * @var \SimpleSAML\XML\Validator|null This variable contains an XML validator for this message.
29
     */
30
    private $validator = null;
31
32
    /**
33
     * @var bool Whether this response was validated by some external means (e.g. SSL).
34
     */
35
    private $messageValidated = false;
36
37
    /** @var string */
38
    public const SHIB_PROTOCOL_NS = 'urn:oasis:names:tc:SAML:1.0:protocol';
39
40
    /** @var string */
41
    public const SHIB_ASSERT_NS = 'urn:oasis:names:tc:SAML:1.0:assertion';
42
43
    /**
44
     * @var \DOMDocument|null The DOMDocument which represents this message.
45
     */
46
    private $dom = null;
47
48
    /**
49
     * @var string|null The relaystate which is associated with this response.
50
     */
51
    private $relayState = null;
52
53
54
    /**
55
     * Set whether this message was validated externally.
56
     *
57
     * @param bool $messageValidated  TRUE if the message is already validated, FALSE if not.
58
     * @return void
59
     */
60
    public function setMessageValidated(bool $messageValidated): void
61
    {
62
        $this->messageValidated = $messageValidated;
63
    }
64
65
66
    /**
67
     * @param string $xml
68
     * @throws \Exception
69
     * @return void
70
     */
71
    public function setXML(string $xml): void
72
    {
73
        try {
74
            $this->dom = DOMDocumentFactory::fromString(str_replace("\r", "", $xml));
75
        } catch (\Exception $e) {
76
            throw new \Exception('Unable to parse AuthnResponse XML.');
77
        }
78
    }
79
80
81
    /**
82
     * @param string|null $relayState
83
     * @return void
84
     */
85
    public function setRelayState(?string $relayState) : void
86
    {
87
        $this->relayState = $relayState;
88
    }
89
90
91
    /**
92
     * @return string|null
93
     */
94
    public function getRelayState(): ?string
95
    {
96
        return $this->relayState;
97
    }
98
99
100
    /**
101
     * @throws \SimpleSAML\Error\Exception
102
     * @return bool
103
     */
104
    public function validate(): bool
105
    {
106
        assert($this->dom instanceof DOMDocument);
107
108
        if ($this->messageValidated) {
109
            // This message was validated externally
110
            return true;
111
        }
112
113
        // Validate the signature
114
        $this->validator = new Validator($this->dom, ['ResponseID', 'AssertionID']);
115
116
        // Get the issuer of the response
117
        $issuer = $this->getIssuer();
118
119
        // Get the metadata of the issuer
120
        $metadata = MetaDataStorageHandler::getMetadataHandler();
121
        $md = $metadata->getMetaDataConfig($issuer, 'shib13-idp-remote');
122
123
        $publicKeys = $md->getPublicKeys('signing');
124
        if (!empty($publicKeys)) {
125
            $certFingerprints = [];
126
            foreach ($publicKeys as $key) {
127
                if ($key['type'] !== 'X509Certificate') {
128
                    continue;
129
                }
130
                $certFingerprints[] = sha1(base64_decode($key['X509Certificate']));
131
            }
132
            $this->validator->validateFingerprint($certFingerprints);
0 ignored issues
show
Bug introduced by
The method validateFingerprint() does not exist on SimpleSAML\XML\Validator. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

132
            $this->validator->/** @scrutinizer ignore-call */ 
133
                              validateFingerprint($certFingerprints);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
133
        } elseif ($md->hasValue('certFingerprint')) {
134
            $certFingerprints = $md->getArrayizeString('certFingerprint');
135
136
            // Validate the fingerprint
137
            $this->validator->validateFingerprint($certFingerprints);
138
        } elseif ($md->hasValue('caFile')) {
139
            // Validate against CA
140
            $this->validator->validateCA(Utils\Config::getCertPath($md->getString('caFile')));
0 ignored issues
show
Bug introduced by
The method validateCA() does not exist on SimpleSAML\XML\Validator. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

140
            $this->validator->/** @scrutinizer ignore-call */ 
141
                              validateCA(Utils\Config::getCertPath($md->getString('caFile')));

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
141
        } else {
142
            throw new Error\Exception(
143
                'Missing certificate in Shibboleth 1.3 IdP Remote metadata for identity provider [' . $issuer . '].'
144
            );
145
        }
146
147
        return true;
148
    }
149
150
151
    /**
152
     * Checks if the given node is validated by the signature on this response.
153
     *
154
     * @param \DOMElement|\SimpleXMLElement $node Node to be validated.
155
     * @return bool TRUE if the node is validated or FALSE if not.
156
     */
157
    private function isNodeValidated($node): bool
158
    {
159
        if ($this->messageValidated) {
160
            // This message was validated externally
161
            return true;
162
        }
163
164
        if ($this->validator === null) {
165
            return false;
166
        }
167
168
        // Convert the node to a DOM node if it is an element from SimpleXML
169
        if ($node instanceof \SimpleXMLElement) {
170
            $node = dom_import_simplexml($node);
171
        }
172
173
        assert($node instanceof DOMNode);
174
175
        return $this->validator->isNodeValidated($node);
176
    }
177
178
179
    /**
180
     * This function runs an xPath query on this authentication response.
181
     *
182
     * @param string $query   The query which should be run.
183
     * @param \DOMNode $node  The node which this query is relative to. If this node is NULL (the default)
184
     *                        then the query will be relative to the root of the response.
185
     * @return \DOMNodeList
186
     */
187
    private function doXPathQuery(string $query, DOMNode $node = null): DOMNodeList
188
    {
189
        assert($this->dom instanceof DOMDocument);
190
191
        if ($node === null) {
192
            $node = $this->dom->documentElement;
193
        }
194
195
        assert($node instanceof DOMNode);
196
197
        $xPath = new DOMXpath($this->dom);
198
        $xPath->registerNamespace('shibp', self::SHIB_PROTOCOL_NS);
199
        $xPath->registerNamespace('shib', self::SHIB_ASSERT_NS);
200
201
        return $xPath->query($query, $node);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $xPath->query($query, $node) could return the type false which is incompatible with the type-hinted return DOMNodeList. Consider adding an additional type-check to rule them out.
Loading history...
202
    }
203
204
205
    /**
206
     * Retrieve the session index of this response.
207
     *
208
     * @return string|null  The session index of this response.
209
     */
210
    public function getSessionIndex(): ?string
211
    {
212
        assert($this->dom instanceof DOMDocument);
213
214
        $query = '/shibp:Response/shib:Assertion/shib:AuthnStatement';
215
        $nodelist = $this->doXPathQuery($query);
216
        if ($node = $nodelist->item(0)) {
217
            return $node->getAttribute('SessionIndex');
218
        }
219
220
        return null;
221
    }
222
223
224
    /**
225
     * @throws \Exception
226
     * @return array
227
     */
228
    public function getAttributes(): array
229
    {
230
        $metadata = MetaDataStorageHandler::getMetadataHandler();
231
        $md = $metadata->getMetaData($this->getIssuer(), 'shib13-idp-remote');
232
        $base64 = isset($md['base64attributes']) ? $md['base64attributes'] : false;
233
234
        if (!($this->dom instanceof DOMDocument)) {
0 ignored issues
show
introduced by
$this->dom is always a sub-type of DOMDocument.
Loading history...
235
            return [];
236
        }
237
238
        $attributes = [];
239
240
        $assertions = $this->doXPathQuery('/shibp:Response/shib:Assertion');
241
242
        foreach ($assertions as $assertion) {
243
            if (!$this->isNodeValidated($assertion)) {
244
                throw new \Exception('Shib13 AuthnResponse contained an unsigned assertion.');
245
            }
246
247
            $conditions = $this->doXPathQuery('shib:Conditions', $assertion);
248
            if ($conditions->length > 0) {
249
                $condition = $conditions->item(0);
250
251
                $start = $condition->getAttribute('NotBefore');
252
                $end = $condition->getAttribute('NotOnOrAfter');
253
254
                if ($start && $end) {
255
                    if (!self::checkDateConditions($start, $end)) {
256
                        error_log('Date check failed ... (from ' . $start . ' to ' . $end . ')');
257
                        continue;
258
                    }
259
                }
260
            }
261
262
            $attribute_nodes = $this->doXPathQuery(
263
                'shib:AttributeStatement/shib:Attribute/shib:AttributeValue',
264
                $assertion
265
            );
266
267
            foreach ($attribute_nodes as $attribute) {
268
                /** @var \DOMElement $attribute */
269
270
                $value = $attribute->textContent;
271
                /** @var \DOMElement $parentNode */
272
                $parentNode = $attribute->parentNode;
273
                $name = $parentNode->getAttribute('AttributeName');
274
275
                if ($attribute->hasAttribute('Scope')) {
276
                    $scopePart = '@' . $attribute->getAttribute('Scope');
277
                } else {
278
                    $scopePart = '';
279
                }
280
281
                if (empty($name)) {
282
                    throw new \Exception('Shib13 Attribute node without an AttributeName.');
283
                }
284
285
                if (!array_key_exists($name, $attributes)) {
286
                    $attributes[$name] = [];
287
                }
288
289
                if ($base64) {
290
                    $encodedvalues = explode('_', $value);
291
                    foreach ($encodedvalues as $v) {
292
                        $attributes[$name][] = base64_decode($v) . $scopePart;
293
                    }
294
                } else {
295
                    $attributes[$name][] = $value . $scopePart;
296
                }
297
            }
298
        }
299
300
        return $attributes;
301
    }
302
303
304
    /**
305
     * @throws \Exception
306
     * @return string
307
     */
308
    public function getIssuer(): string
309
    {
310
        $query = '/shibp:Response/shib:Assertion/@Issuer';
311
        $nodelist = $this->doXPathQuery($query);
312
313
        if ($attr = $nodelist->item(0)) {
314
            return $attr->value;
315
        } else {
316
            throw new \Exception('Could not find Issuer field in Authentication response');
317
        }
318
    }
319
320
321
    /**
322
     * @return array
323
     */
324
    public function getNameID(): array
325
    {
326
        $nameID = [];
327
328
        $query = '/shibp:Response/shib:Assertion/shib:AuthenticationStatement/shib:Subject/shib:NameIdentifier';
329
        $nodelist = $this->doXPathQuery($query);
330
331
        if ($node = $nodelist->item(0)) {
332
            $nameID["Value"] = $node->nodeValue;
333
            $nameID["Format"] = $node->getAttribute('Format');
334
        }
335
336
        return $nameID;
337
    }
338
339
340
    /**
341
     * Build a authentication response.
342
     *
343
     * @param \SimpleSAML\Configuration $idp Metadata for the IdP the response is sent from.
344
     * @param \SimpleSAML\Configuration $sp Metadata for the SP the response is sent to.
345
     * @param string $shire The endpoint on the SP the response is sent to.
346
     * @param array|null $attributes The attributes which should be included in the response.
347
     * @return string The response.
348
     */
349
    public function generate(Configuration $idp, Configuration $sp, string $shire, ?array $attributes): string
350
    {
351
        if ($sp->hasValue('scopedattributes')) {
352
            $scopedAttributes = $sp->getArray('scopedattributes');
353
        } elseif ($idp->hasValue('scopedattributes')) {
354
            $scopedAttributes = $idp->getArray('scopedattributes');
355
        } else {
356
            $scopedAttributes = [];
357
        }
358
359
        $id = Utils\Random::generateID();
360
361
        $issueInstant = Utils\Time::generateTimestamp();
362
363
        // 30 seconds timeskew back in time to allow differing clocks
364
        $notBefore = Utils\Time::generateTimestamp(time() - 30);
365
366
        $assertionExpire = Utils\Time::generateTimestamp(time() + 300); // 5 minutes
367
        $assertionid = Utils\Random::generateID();
368
369
        $spEntityId = $sp->getString('entityid');
370
371
        $audience = $sp->getString('audience', $spEntityId);
372
        $base64 = $sp->getBoolean('base64attributes', false);
373
374
        $namequalifier = $sp->getString('NameQualifier', $spEntityId);
375
        $nameid = Utils\Random::generateID();
376
        $subjectNode =
377
            '<Subject>' .
378
            '<NameIdentifier' .
379
            ' Format="urn:mace:shibboleth:1.0:nameIdentifier"' .
380
            ' NameQualifier="' . htmlspecialchars($namequalifier) . '"' .
381
            '>' .
382
            htmlspecialchars($nameid) .
383
            '</NameIdentifier>' .
384
            '<SubjectConfirmation>' .
385
            '<ConfirmationMethod>' .
386
            'urn:oasis:names:tc:SAML:1.0:cm:bearer' .
387
            '</ConfirmationMethod>' .
388
            '</SubjectConfirmation>' .
389
            '</Subject>';
390
391
        $encodedattributes = '';
392
393
        if (is_array($attributes)) {
0 ignored issues
show
introduced by
The condition is_array($attributes) is always true.
Loading history...
394
            $encodedattributes .= '<AttributeStatement>';
395
            $encodedattributes .= $subjectNode;
396
397
            foreach ($attributes as $name => $value) {
398
                $encodedattributes .= $this->encAttribute($name, $value, $base64, $scopedAttributes);
399
            }
400
401
            $encodedattributes .= '</AttributeStatement>';
402
        }
403
404
        /*
405
         * The SAML 1.1 response message
406
         */
407
        $response = '<Response xmlns="urn:oasis:names:tc:SAML:1.0:protocol"
408
    xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion"
409
    xmlns:samlp="urn:oasis:names:tc:SAML:1.0:protocol" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
410
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" IssueInstant="' . $issueInstant . '"
411
    MajorVersion="1" MinorVersion="1"
412
    Recipient="' . htmlspecialchars($shire) . '" ResponseID="' . $id . '">
413
    <Status>
414
        <StatusCode Value="samlp:Success" />
415
    </Status>
416
    <Assertion xmlns="urn:oasis:names:tc:SAML:1.0:assertion"
417
        AssertionID="' . $assertionid . '" IssueInstant="' . $issueInstant . '"
418
        Issuer="' . htmlspecialchars($idp->getString('entityid')) . '" MajorVersion="1" MinorVersion="1">
419
        <Conditions NotBefore="' . $notBefore . '" NotOnOrAfter="' . $assertionExpire . '">
420
            <AudienceRestrictionCondition>
421
                <Audience>' . htmlspecialchars($audience) . '</Audience>
422
            </AudienceRestrictionCondition>
423
        </Conditions>
424
        <AuthenticationStatement AuthenticationInstant="' . $issueInstant . '"
425
            AuthenticationMethod="urn:oasis:names:tc:SAML:1.0:am:unspecified">' .
426
            $subjectNode . '
427
        </AuthenticationStatement>
428
        ' . $encodedattributes . '
429
    </Assertion>
430
</Response>';
431
432
        return $response;
433
    }
434
435
436
    /**
437
     * Format a shib13 attribute.
438
     *
439
     * @param string $name  Name of the attribute.
440
     * @param array $values  Values of the attribute (as an array of strings).
441
     * @param bool $base64  Whether the attriubte values should be base64-encoded.
442
     * @param array $scopedAttributes  Array of attributes names which are scoped.
443
     * @return string  The attribute encoded as an XML-string.
444
     */
445
    private function encAttribute(string $name, array $values, bool $base64, array $scopedAttributes): string
446
    {
447
        if (in_array($name, $scopedAttributes, true)) {
448
            $scoped = true;
449
        } else {
450
            $scoped = false;
451
        }
452
453
        $attr = '<Attribute AttributeName="' . htmlspecialchars($name) .
454
            '" AttributeNamespace="urn:mace:shibboleth:1.0:attributeNamespace:uri">';
455
        foreach ($values as $value) {
456
            $scopePart = '';
457
            if ($scoped) {
458
                $tmp = explode('@', $value, 2);
459
                if (count($tmp) === 2) {
460
                    $value = $tmp[0];
461
                    $scopePart = ' Scope="' . htmlspecialchars($tmp[1]) . '"';
462
                }
463
            }
464
465
            if ($base64) {
466
                $value = base64_encode($value);
467
            }
468
469
            $attr .= '<AttributeValue' . $scopePart . '>' . htmlspecialchars($value) . '</AttributeValue>';
470
        }
471
        $attr .= '</Attribute>';
472
473
        return $attr;
474
    }
475
476
    /**
477
     * Check if we are currently between the given date & time conditions.
478
     *
479
     * Note that this function allows a 10-minute leap from the initial time as marked by $start.
480
     *
481
     * @param string|null $start A SAML2 timestamp marking the start of the period to check. Defaults to null, in which
482
     *     case there's no limitations in the past.
483
     * @param string|null $end A SAML2 timestamp marking the end of the period to check. Defaults to null, in which
484
     *     case there's no limitations in the future.
485
     *
486
     * @return bool True if the current time belongs to the period specified by $start and $end. False otherwise.
487
     *
488
     * @see \SAML2\Utils::xsDateTimeToTimestamp.
489
     *
490
     * @author Andreas Solberg, UNINETT AS <[email protected]>
491
     * @author Olav Morken, UNINETT AS <[email protected]>
492
     */
493
    protected static function checkDateConditions(string $start = null, string $end = null): bool
494
    {
495
        $currentTime = time();
496
497
        if (!empty($start)) {
498
            $startTime = \SAML2\Utils::xsDateTimeToTimestamp($start);
499
            // allow for a 10 minute difference in time
500
            if (($startTime < 0) || (($startTime - 600) > $currentTime)) {
501
                return false;
502
            }
503
        }
504
        if (!empty($end)) {
505
            $endTime = \SAML2\Utils::xsDateTimeToTimestamp($end);
506
            if (($endTime < 0) || ($endTime <= $currentTime)) {
507
                return false;
508
            }
509
        }
510
        return true;
511
    }
512
}
513