Passed
Pull Request — master (#11)
by Tim
03:14
created

MetaLoader::addCachedMetadata()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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

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