Passed
Pull Request — master (#14)
by Tim
02:04
created

MetaLoader::createContext()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 23
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 12
c 1
b 0
f 0
nc 6
nop 1
dl 0
loc 23
rs 9.2222
1
<?php
2
3
namespace SimpleSAML\Module\metarefresh;
4
5
use Exception;
6
use RobRichards\XMLSecLibs\XMLSecurityDSig;
7
use SAML2\DOMDocumentFactory;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Configuration;
10
use SimpleSAML\Logger;
11
use SimpleSAML\Metadata;
12
use SimpleSAML\Utils;
13
14
/**
15
 * @package SimpleSAMLphp
16
 */
17
class MetaLoader
18
{
19
    /** @var int|null */
20
    private $expire;
21
22
    /** @var array */
23
    private $metadata = [];
24
25
    /** @var object|null */
26
    private $oldMetadataSrc;
27
28
    /** @var string|null */
29
    private $stateFile = null;
30
31
    /** @var bool*/
32
    private $changed = false;
33
34
    /** @var array */
35
    private $state = [];
36
37
    /** @var array */
38
    private $types = [
39
        'saml20-idp-remote',
40
        'saml20-sp-remote',
41
        'attributeauthority-remote'
42
    ];
43
44
45
    /**
46
     * Constructor
47
     *
48
     * @param int|null $expire
49
     * @param string|null  $stateFile
50
     * @param object|null  $oldMetadataSrc
51
     */
52
    public function __construct(int $expire = null, string $stateFile = null, object $oldMetadataSrc = null)
53
    {
54
        $this->expire = $expire;
55
        $this->oldMetadataSrc = $oldMetadataSrc;
56
        $this->stateFile = $stateFile;
57
58
        // Read file containing $state from disk
59
        /** @psalm-var array|null */
60
        $state = null;
61
        if (!is_null($stateFile) && is_readable($stateFile)) {
62
            include($stateFile);
63
        }
64
65
        if (!empty($state)) {
0 ignored issues
show
introduced by
The condition empty($state) is always false.
Loading history...
66
            $this->state = $state;
67
        }
68
    }
69
70
71
    /**
72
     * Get the types of entities that will be loaded.
73
     *
74
     * @return array The entity types allowed.
75
     */
76
    public function getTypes(): array
77
    {
78
        return $this->types;
79
    }
80
81
82
    /**
83
     * Set the types of entities that will be loaded.
84
     *
85
     * @param string|array $types Either a string with the name of one single type allowed, or an array with a list of
86
     * types. Pass an empty array to reset to all types of entities.
87
     */
88
    public function setTypes($types): void
89
    {
90
        if (!is_array($types)) {
91
            $types = [$types];
92
        }
93
        $this->types = $types;
94
    }
95
96
97
    /**
98
     * This function processes a SAML metadata file.
99
     *
100
     * @param array $source
101
     */
102
    public function loadSource(array $source): void
103
    {
104
        if (preg_match('@^https?://@i', $source['src'])) {
105
            // Build new HTTP context
106
            $context = $this->createContext($source);
107
108
            // GET!
109
            try {
110
                /** @var array $response  We know this because we set the third parameter to `true` */
111
                $response = Utils\HTTP::fetch($source['src'], $context, true);
112
                list($data, $responseHeaders) = $response;
113
            } catch (Exception $e) {
114
                Logger::warning('metarefresh: ' . $e->getMessage());
115
            }
116
117
            // We have response headers, so the request succeeded
118
            if (!isset($responseHeaders)) {
119
                // No response headers, this means the request failed in some way, so re-use old data
120
                Logger::debug('No response from ' . $source['src'] . ' - attempting to re-use cached metadata');
121
                $this->addCachedMetadata($source);
122
                return;
123
            } elseif (preg_match('@^HTTP/1\.[01]\s304\s@', $responseHeaders[0])) {
124
                // 304 response
125
                Logger::debug('Received HTTP 304 (Not Modified) - attempting to re-use cached metadata');
126
                $this->addCachedMetadata($source);
127
                return;
128
            } elseif (!preg_match('@^HTTP/1\.[01]\s200\s@', $responseHeaders[0])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $responseHeaders does not seem to be defined for all execution paths leading up to this point.
Loading history...
129
                // Other error
130
                Logger::debug('Error from ' . $source['src'] . ' - attempting to re-use cached metadata');
131
                $this->addCachedMetadata($source);
132
                return;
133
            }
134
        } else {
135
            // Local file.
136
            $data = file_get_contents($source['src']);
137
            $responseHeaders = null;
138
        }
139
140
        // Everything OK. Proceed.
141
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
142
            // Stale or no metadata, so a fresh copy
143
            Logger::debug('Downloaded fresh copy');
144
        }
145
146
        try {
147
            $entities = $this->loadXML($data, $source);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $data does not seem to be defined for all execution paths leading up to this point.
Loading history...
148
        } catch (Exception $e) {
149
            Logger::debug(
150
                'XML parser error when parsing ' . $source['src'] . ' - attempting to re-use cached metadata'
151
            );
152
            Logger::debug('XML parser returned: ' . $e->getMessage());
153
            $this->addCachedMetadata($source);
154
            return;
155
        }
156
157
        foreach ($entities as $entity) {
158
            if (!$this->processBlacklist($entity, $source)) {
159
                continue;
160
            }
161
            if (!$this->processWhitelist($entity, $source)) {
162
                continue;
163
            }
164
            if (!$this->processAttributeWhitelist($entity, $source)) {
165
                continue;
166
            }
167
            if (!$this->processCertificates($entity, $source)) {
168
                continue;
169
            }
170
171
            $template = null;
172
            if (array_key_exists('template', $source)) {
173
                $template = $source['template'];
174
            }
175
176
            if (in_array('saml20-sp-remote', $this->types, true)) {
177
                $this->addMetadata($source['src'], $entity->getMetadata20SP(), 'saml20-sp-remote', $template);
178
            }
179
            if (in_array('saml20-idp-remote', $this->types, true)) {
180
                $this->addMetadata($source['src'], $entity->getMetadata20IdP(), 'saml20-idp-remote', $template);
181
            }
182
            if (in_array('attributeauthority-remote', $this->types, true)) {
183
                $attributeAuthorities = $entity->getAttributeAuthorities();
184
                if (!empty($attributeAuthorities)) {
185
                    $this->addMetadata(
186
                        $source['src'],
187
                        $attributeAuthorities,
188
                        'attributeauthority-remote',
189
                        $template
190
                    );
191
                }
192
            }
193
        }
194
195
        $this->saveState($source, $responseHeaders);
196
    }
197
198
199
    /**
200
     * @param \SimpleSAML\Metadata\SAMLParser $entity
201
     * @param array $source
202
     * @bool
203
     */
204
    private function processCertificates(Metadata\SAMLParser $entity, array $source): bool
205
    {
206
        if (array_key_exists('certificates', $source) && ($source['certificates'] !== null)) {
207
            if (!$entity->validateSignature($source['certificates'])) {
208
                $entityId = $entity->getEntityId();
209
                Logger::info(
210
                    'Skipping "' . $entityId . '" - could not verify signature using certificate.' . "\n"
211
                );
212
                return false;
213
            }
214
        }
215
        return true;
216
    }
217
218
219
    /**
220
     * @param \SimpleSAML\Metadata\SAMLParser $entity
221
     * @param array $source
222
     * @bool
223
     */
224
    private function processBlacklist(Metadata\SAMLParser $entity, array $source): bool
225
    {
226
        if (isset($source['blacklist'])) {
227
            if (!empty($source['blacklist']) && in_array($entity->getEntityId(), $source['blacklist'], true)) {
228
                Logger::info('Skipping "' . $entity->getEntityId() . '" - blacklisted.' . "\n");
229
                return false;
230
            }
231
        }
232
        return true;
233
    }
234
235
236
    /**
237
     * @param \SimpleSAML\Metadata\SAMLParser $entity
238
     * @param array $source
239
     * @bool
240
     */
241
    private function processWhitelist(Metadata\SAMLParser $entity, array $source): bool
242
    {
243
        if (isset($source['whitelist'])) {
244
            if (!empty($source['whitelist']) && !in_array($entity->getEntityId(), $source['whitelist'], true)) {
245
                Logger::info('Skipping "' . $entity->getEntityId() . '" - not in the whitelist.' . "\n");
246
                return false;
247
            }
248
        }
249
        return true;
250
    }
251
252
253
    /**
254
     * @param \SimpleSAML\Metadata\SAMLParser $entity
255
     * @param array $source
256
     * @bool
257
     */
258
    private function processAttributeWhitelist(Metadata\SAMLParser $entity, array $source): bool
259
    {
260
        /* Do we have an attribute whitelist? */
261
        if (isset($source['attributewhitelist']) && !empty($source['attributewhitelist'])) {
262
            $idpMetadata = $entity->getMetadata20IdP();
263
            if (!isset($idpMetadata)) {
264
                /* Skip non-IdPs */
265
                return false;
266
            }
267
268
            /**
269
             * Do a recursive comparison for each whitelist of the attributewhitelist with the idpMetadata for this
270
             * IdP. At least one of these whitelists should match
271
             */
272
            $match = false;
273
            foreach ($source['attributewhitelist'] as $whitelist) {
274
                if ($this->containsArray($whitelist, $idpMetadata)) {
275
                    $match = true;
276
                    break;
277
                }
278
            }
279
             if (!$match) {
280
                /* No match found -> next IdP */
281
                return false;
282
            }
283
            Logger::debug('Whitelisted entityID: ' . $entity->getEntityID());
284
        }
285
        return true;
286
    }
287
288
289
    /**
290
     * @param array|string $src
291
     * @param array|string $dst
292
     * @return bool
293
     *
294
     * Recursively checks whether array $dst contains array $src. If $src
295
     * is not an array, a literal comparison is being performed.
296
     */
297
    private function containsArray($src, $dst): bool
298
    {
299
        if (is_array($src)) {
300
            if (!is_array($dst)) {
301
                return false;
302
            }
303
            $dstKeys = array_keys($dst);
304
305
            /* Loop over all src keys */
306
            foreach ($src as $srcKey => $srcval) {
307
                if (is_int($srcKey)) {
308
                    /* key is number, check that the key appears as one
309
                     * of the destination keys: if not, then src has
310
                     * more keys than dst */
311
                    if (!array_key_exists($srcKey, $dst)) {
312
                        return false;
313
                    }
314
315
                    /* loop over dest keys, to find value: we don't know
316
                     * whether they are in the same order */
317
                    $submatch = false;
318
                    foreach ($dstKeys as $dstKey) {
319
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
320
                            $submatch = true;
321
                            break;
322
                        }
323
                    }
324
                    if (!$submatch) {
325
                        return false;
326
                    }
327
                } else {
328
                    /* key is regexp: find matching keys */
329
                    /** @var array|false $matchingDstKeys */
330
                    $matchingDstKeys = preg_grep($srcKey, $dstKeys);
331
                    if (!is_array($matchingDstKeys)) {
332
                        return false;
333
                    }
334
335
                    $match = false;
336
                    foreach ($matchingDstKeys as $dstKey) {
337
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
338
                            /* Found a match */
339
                            $match = true;
340
                            break;
341
                        }
342
                    }
343
                    if (!$match) {
344
                        /* none of the keys has a matching value */
345
                        return false;
346
                    }
347
                }
348
            }
349
            /* each src key/value matches */
350
            return true;
351
        } else {
352
            /* src is not an array, do a regexp match against dst */
353
            return (preg_match($src, strval($dst)) === 1);
354
        }
355
    }
356
357
    /**
358
     * Create HTTP context, with any available caches taken into account
359
     *
360
     * @param array $source
361
     * @return array
362
     */
363
    private function createContext(array $source): array
364
    {
365
        $config = Configuration::getInstance();
366
        $name = $config->getString('technicalcontact_name', null);
367
        $mail = $config->getString('technicalcontact_email', null);
368
369
        $rawheader = "User-Agent: SimpleSAMLphp metarefresh, run by $name <$mail>\r\n";
370
371
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
372
            if (array_key_exists($source['src'], $this->state)) {
373
                $sourceState = $this->state[$source['src']];
374
375
                if (isset($sourceState['last-modified'])) {
376
                    $rawheader .= 'If-Modified-Since: ' . $sourceState['last-modified'] . "\r\n";
377
                }
378
379
                if (isset($sourceState['etag'])) {
380
                    $rawheader .= 'If-None-Match: ' . $sourceState['etag'] . "\r\n";
381
                }
382
            }
383
        }
384
385
        return ['http' => ['header' => $rawheader]];
386
    }
387
388
389
    /**
390
     * @param array $source
391
     */
392
    private function addCachedMetadata(array $source): void
393
    {
394
        if (isset($this->oldMetadataSrc)) {
395
            foreach ($this->types as $type) {
396
                foreach ($this->oldMetadataSrc->getMetadataSet($type) as $entity) {
0 ignored issues
show
Bug introduced by
The method getMetadataSet() does not exist on null. ( Ignorable by Annotation )

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

396
                foreach ($this->oldMetadataSrc->/** @scrutinizer ignore-call */ getMetadataSet($type) as $entity) {

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...
397
                    if (array_key_exists('metarefresh:src', $entity)) {
398
                        if ($entity['metarefresh:src'] == $source['src']) {
399
                            $this->addMetadata($source['src'], $entity, $type);
400
                        }
401
                    }
402
                }
403
            }
404
        }
405
    }
406
407
408
    /**
409
     * Store caching state data for a source
410
     *
411
     * @param array $source
412
     * @param array|null $responseHeaders
413
     */
414
    private function saveState(array $source, ?array $responseHeaders): void
415
    {
416
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
417
            // Headers section
418
            if ($responseHeaders !== null) {
0 ignored issues
show
introduced by
The condition $responseHeaders !== null is always true.
Loading history...
419
                $candidates = ['last-modified', 'etag'];
420
421
                foreach ($candidates as $candidate) {
422
                    if (array_key_exists($candidate, $responseHeaders)) {
423
                        $this->state[$source['src']][$candidate] = $responseHeaders[$candidate];
424
                    }
425
                }
426
            }
427
428
            if (!empty($this->state[$source['src']])) {
429
                // Timestamp when this src was requested.
430
                $this->state[$source['src']]['requested_at'] = $this->getTime();
431
                $this->changed = true;
432
            }
433
        }
434
    }
435
436
437
    /**
438
     * Parse XML metadata and return entities
439
     *
440
     * @param string $data
441
     * @param array $source
442
     * @return \SimpleSAML\Metadata\SAMLParser[]
443
     * @throws \Exception
444
     */
445
    private function loadXML(string $data, array $source): array
446
    {
447
        try {
448
            $doc = DOMDocumentFactory::fromString($data);
449
        } catch (Exception $e) {
450
            throw new Exception('Failed to read XML from ' . $source['src']);
451
        }
452
        return Metadata\SAMLParser::parseDescriptorsElement($doc->documentElement);
453
    }
454
455
456
    /**
457
     * This function writes the state array back to disk
458
     *
459
     */
460
    public function writeState(): void
461
    {
462
        if ($this->changed && !is_null($this->stateFile)) {
463
            Logger::debug('Writing: ' . $this->stateFile);
464
            Utils\System::writeFile(
465
                $this->stateFile,
466
                "<?php\n/* This file was generated by the metarefresh module at " . $this->getTime() . ".\n" .
467
                " Do not update it manually as it will get overwritten. */\n" .
468
                '$state = ' . var_export($this->state, true) . ";\n?>\n",
469
                0644
470
            );
471
        }
472
    }
473
474
475
    /**
476
     * This function writes the metadata to stdout.
477
     *
478
     */
479
    public function dumpMetadataStdOut(): void
480
    {
481
        foreach ($this->metadata as $category => $elements) {
482
            echo '/* The following data should be added to metadata/' . $category . '.php. */' . "\n";
483
484
            foreach ($elements as $m) {
485
                $filename = $m['filename'];
486
                $entityID = $m['metadata']['entityid'];
487
                $time = $this->getTime();
488
                echo "\n";
489
                echo '/* The following metadata was generated from ' . $filename . ' on ' . $time . '. */' . "\n";
490
                echo '$metadata[\'' . addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
491
            }
492
493
            echo "\n";
494
            echo '/* End of data which should be added to metadata/' . $category . '.php. */' . "\n";
495
            echo "\n";
496
        }
497
    }
498
499
500
    /**
501
     * This function adds metadata from the specified file to the list of metadata.
502
     * This function will return without making any changes if $metadata is NULL.
503
     *
504
     * @param string $filename The filename the metadata comes from.
505
     * @param \SAML2\XML\md\AttributeAuthorityDescriptor[]|null $metadata The metadata.
506
     * @param string $type The metadata type.
507
     * @param array|null $template The template.
508
     */
509
    private function addMetadata(string $filename, ?array $metadata, string $type, array $template = null): void
510
    {
511
        if ($metadata === null) {
0 ignored issues
show
introduced by
The condition $metadata === null is always false.
Loading history...
512
            return;
513
        }
514
515
        if (isset($template)) {
516
            $metadata = array_merge($metadata, $template);
517
        }
518
519
        $metadata['metarefresh:src'] = $filename;
520
        if (!array_key_exists($type, $this->metadata)) {
521
            $this->metadata[$type] = [];
522
        }
523
524
        // If expire is defined in constructor...
525
        if (!empty($this->expire)) {
526
            // If expire is already in metadata
527
            if (array_key_exists('expire', $metadata)) {
528
                // Override metadata expire with more restrictive global config
529
                if ($this->expire < $metadata['expire']) {
530
                    $metadata['expire'] = $this->expire;
531
                }
532
533
                // If expire is not already in metadata use global config
534
            } else {
535
                $metadata['expire'] = $this->expire;
536
            }
537
        }
538
        $this->metadata[$type][] = ['filename' => $filename, 'metadata' => $metadata];
539
    }
540
541
542
    /**
543
     * This function writes the metadata to an ARP file
544
     *
545
     * @param \SimpleSAML\Configuration $config
546
     */
547
    public function writeARPfile(Configuration $config): void
548
    {
549
        $arpfile = $config->getValue('arpfile');
550
        $types = ['saml20-sp-remote'];
551
552
        $md = [];
553
        foreach ($this->metadata as $category => $elements) {
554
            if (!in_array($category, $types, true)) {
555
                continue;
556
            }
557
            $md = array_merge($md, $elements);
558
        }
559
560
        // $metadata, $attributemap, $prefix, $suffix
561
        $arp = new ARP(
562
            $md,
563
            $config->getValue('attributemap', ''),
564
            $config->getValue('prefix', ''),
565
            $config->getValue('suffix', '')
566
        );
567
568
569
        $arpxml = $arp->getXML();
570
571
        Logger::info('Writing ARP file: ' . $arpfile . "\n");
572
        file_put_contents($arpfile, $arpxml);
573
    }
574
575
576
    /**
577
     * This function writes the metadata to to separate files in the output directory.
578
     *
579
     * @param string $outputDir
580
     */
581
    public function writeMetadataFiles(string $outputDir): void
582
    {
583
        while (strlen($outputDir) > 0 && $outputDir[strlen($outputDir) - 1] === '/') {
584
            $outputDir = substr($outputDir, 0, strlen($outputDir) - 1);
585
        }
586
587
        if (!file_exists($outputDir)) {
588
            Logger::info('Creating directory: ' . $outputDir . "\n");
589
            $res = @mkdir($outputDir, 0777, true);
590
            if ($res === false) {
591
                throw new Exception('Error creating directory: ' . $outputDir);
592
            }
593
        }
594
595
        foreach ($this->types as $type) {
596
            $filename = $outputDir . '/' . $type . '.php';
597
598
            if (array_key_exists($type, $this->metadata)) {
599
                $elements = $this->metadata[$type];
600
                Logger::debug('Writing: ' . $filename);
601
602
                $content  = '<?php' . "\n" . '/* This file was generated by the metarefresh module at ';
603
                $content .= $this->getTime() . "\nDo not update it manually as it will get overwritten\n" . '*/' . "\n";
604
605
                foreach ($elements as $m) {
606
                    $entityID = $m['metadata']['entityid'];
607
                    $content .= "\n" . '$metadata[\'';
608
                    $content .= addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
609
                }
610
611
                $content .= "\n" . '?>';
612
613
                Utils\System::writeFile($filename, $content, 0644);
614
            } elseif (is_file($filename)) {
615
                if (unlink($filename)) {
616
                    Logger::debug('Deleting stale metadata file: ' . $filename);
617
                } else {
618
                    Logger::warning('Could not delete stale metadata file: ' . $filename);
619
                }
620
            }
621
        }
622
    }
623
624
625
    /**
626
     * Save metadata for loading with the 'serialize' metadata loader.
627
     *
628
     * @param string $outputDir  The directory we should save the metadata to.
629
     */
630
    public function writeMetadataSerialize(string $outputDir): void
631
    {
632
        $metaHandler = new Metadata\MetaDataStorageHandlerSerialize(['directory' => $outputDir]);
633
634
        // First we add all the metadata entries to the metadata handler
635
        foreach ($this->metadata as $set => $elements) {
636
            foreach ($elements as $m) {
637
                $entityId = $m['metadata']['entityid'];
638
639
                Logger::debug(
640
                    'metarefresh: Add metadata entry ' .
641
                    var_export($entityId, true) . ' in set ' . var_export($set, true) . '.'
642
                );
643
                $metaHandler->saveMetadata($entityId, $set, $m['metadata']);
644
            }
645
        }
646
647
        // Then we delete old entries which should no longer exist
648
        $ct = time();
649
        foreach ($metaHandler->getMetadataSets() as $set) {
650
            foreach ($metaHandler->getMetadataSet($set) as $entityId => $metadata) {
651
                if (!array_key_exists('expire', $metadata) || !is_int($metadata['expire'])) {
652
                    Logger::warning(
653
                        'metarefresh: Metadata entry without valid expire timestamp: ' . var_export($entityId, true) .
654
                        ' in set ' . var_export($set, true) . '.'
655
                    );
656
                    continue;
657
                }
658
659
                $expire = $metadata['expire'];
660
                if ($expire > $ct) {
661
                    continue;
662
                }
663
664
                /** @var int $stamp */
665
                $stamp = date('l jS \of F Y h:i:s A', $expire);
666
                Logger::debug('metarefresh: ' . $entityId . ' expired ' . $stamp);
667
                Logger::debug(
668
                    'metarefresh: Delete expired metadata entry ' .
669
                    var_export($entityId, true) . ' in set ' . var_export($set, true) .
670
                    '. (' . ($ct - $expire) . ' sec)'
671
                );
672
                $metaHandler->deleteMetadata($entityId, $set);
673
            }
674
        }
675
    }
676
677
678
    /**
679
     * @return string
680
     */
681
    private function getTime(): string
682
    {
683
        // The current date, as a string
684
        date_default_timezone_set('UTC');
685
        return date('Y-m-d\\TH:i:s\\Z');
686
    }
687
}
688