Passed
Pull Request — master (#11)
by
unknown
04:37
created

MetaLoader::addMetadata()   B

Complexity

Conditions 7
Paths 17

Size

Total Lines 30
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 14
c 1
b 0
f 0
nc 17
nop 4
dl 0
loc 30
rs 8.8333
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
                list($data, $responseHeaders) = \SimpleSAML\Utils\HTTP::fetch($source['src'], $context, true);
110
            } catch (\Exception $e) {
111
                Logger::warning('metarefresh: ' . $e->getMessage());
112
            }
113
114
            // We have response headers, so the request succeeded
115
            if (!isset($responseHeaders)) {
116
                // No response headers, this means the request failed in some way, so re-use old data
117
                Logger::debug('No response from ' . $source['src'] . ' - attempting to re-use cached metadata');
118
                $this->addCachedMetadata($source);
119
                return;
120
            } elseif (preg_match('@^HTTP/1\.[01]\s304\s@', $responseHeaders[0])) {
121
                // 304 response
122
                Logger::debug('Received HTTP 304 (Not Modified) - attempting to re-use cached metadata');
123
                $this->addCachedMetadata($source);
124
                return;
125
            } elseif (!preg_match('@^HTTP/1\.[01]\s200\s@', $responseHeaders[0])) {
126
                // Other error
127
                Logger::debug('Error from ' . $source['src'] . ' - attempting to re-use cached metadata');
128
                $this->addCachedMetadata($source);
129
                return;
130
            }
131
        } else {
132
            // Local file.
133
            $data = file_get_contents($source['src']);
134
            $responseHeaders = null;
135
        }
136
137
        // Everything OK. Proceed.
138
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
139
            // Stale or no metadata, so a fresh copy
140
            Logger::debug('Downloaded fresh copy');
141
        }
142
143
        try {
144
            $entities = $this->loadXML($data, $source);
145
        } catch (\Exception $e) {
146
            Logger::debug('XML parser error when parsing ' . $source['src'] . ' - attempting to re-use cached metadata');
147
            Logger::debug('XML parser returned: ' . $e->getMessage());
148
            $this->addCachedMetadata($source);
149
            return;
150
        }
151
152
        foreach ($entities as $entity) {
153
            if (isset($source['blacklist'])) {
154
                if (!empty($source['blacklist']) && in_array($entity->getEntityId(), $source['blacklist'], true)) {
155
                    Logger::info('Skipping "' . $entity->getEntityId() . '" - blacklisted.' . "\n");
156
                    continue;
157
                }
158
            }
159
160
            if (isset($source['whitelist'])) {
161
                if (!empty($source['whitelist']) && !in_array($entity->getEntityId(), $source['whitelist'], true)) {
162
                    Logger::info('Skipping "' . $entity->getEntityId() . '" - not in the whitelist.' . "\n");
163
                    continue;
164
                }
165
            }
166
167
            /* Do we have an attribute whitelist? */
168
            if (isset($source['attributewhitelist']) && !empty($source['attributewhitelist'])) {
169
                $idpmetadata = $entity->getMetadata20IdP();
170
                if (!isset($idpmetadata)) /* Skip non-IdPs */
171
                    continue;
172
173
                /* Do a recursive comparison for each whitelist of the attributewhitelist with the idpmetadata for this IdP. At least one of these whitelists should match */
174
                $match=0;
175
                foreach ($source['attributewhitelist'] as $whitelist)   {
176
                    if ($this->containsArray($whitelist, $idpmetadata)) {
177
                        $match=1;
178
                        break;
179
                    }
180
                }
181
                if ($match==0) /* No match found -> next IdP */
182
                    continue;
183
                Logger::debug('Whitelisted entityID: '. $entity->getEntityID());
184
            }
185
186
            if (array_key_exists('certificates', $source) && ($source['certificates'] !== null)) {
187
                if (!$entity->validateSignature($source['certificates'])) {
188
                    Logger::info(
189
                        'Skipping "' . $entity->getEntityId() . '" - could not verify signature using certificate.' . "\n"
190
                    );
191
                    continue;
192
                }
193
            }
194
195
            $template = null;
196
            if (array_key_exists('template', $source)) {
197
                $template = $source['template'];
198
            }
199
200
            if (in_array('saml20-sp-remote', $this->types, true)) {
201
                $this->addMetadata($source['src'], $entity->getMetadata20SP(), 'saml20-sp-remote', $template);
202
            }
203
            if (in_array('saml20-idp-remote', $this->types, true)) {
204
                $this->addMetadata($source['src'], $entity->getMetadata20IdP(), 'saml20-idp-remote', $template);
205
            }
206
            if (in_array('attributeauthority-remote', $this->types, true)) {
207
                $attributeAuthorities = $entity->getAttributeAuthorities();
208
                if (!empty($attributeAuthorities)) {
209
                    $this->addMetadata(
210
                        $source['src'],
211
                        $attributeAuthorities,
212
                        'attributeauthority-remote',
213
                        $template
214
                    );
215
                }
216
            }
217
        }
218
219
        $this->saveState($source, $responseHeaders);
220
    }
221
222
223
    /*
224
     * Recursively checks whether array $dst contains array $src. If $src
225
     * is not an array, a literal comparison is being performed.
226
     */
227
    private function containsArray($src, $dst) {
228
        if (is_array($src)) {
229
            if (!is_array($dst))    {
230
                return false;
231
            }
232
            $dstkeys=array_keys($dst);
233
234
            /* Loop over all src keys */
235
            foreach($src as $srckey => $srcval)    {
236
                if (is_int($srckey))    {
237
                    /* key is number, check that the key appears as one
238
                     * of the destination keys: if not, then src has
239
                     * more keys than dst */
240
                    if (!array_key_exists($srckey, $dst))
241
                        return false;
242
243
                    /* loop over dest keys, to find value: we don't know
244
                     * whether they are in the same order */
245
                    $submatch=0;
246
                    foreach ($dstkeys as $dstkey)   {
247
                        if ($this->containsArray($srcval, $dst[$dstkey])) {
248
                            $submatch=1;
249
                            break;
250
                        }
251
                    }
252
                    if ($submatch == 0)
253
                        return false;
254
                } else {
255
                    /* key is regexp: find matching keys */
256
                    $matchingdstkeys=preg_grep($srckey, $dstkeys);
257
                    if (!is_array($matchingdstkeys))
258
                        return false;
259
260
                    $match=0;
261
                    foreach ($matchingdstkeys as $dstkey) {
262
                        if ($this->containsArray($srcval, $dst[$dstkey])) {
263
                            /* Found a match */
264
                            $match=1;
265
                            break;
266
                        }
267
                    }
268
                    if ($match==0) /* none of the keys has a matching value */
269
                        return false;
270
                }
271
            }
272
            /* each src key/value matches */
273
            return true;
274
        } else {
275
            /* src is not an array, do a regexp match against dst */
276
            return (preg_match($src, $dst) === 1);
277
        }
278
    }
279
280
    /**
281
     * Create HTTP context, with any available caches taken into account
282
     *
283
     * @param array $source
284
     * @return array
285
     */
286
    private function createContext(array $source): array
287
    {
288
        $config = Configuration::getInstance();
289
        $name = $config->getString('technicalcontact_name', null);
290
        $mail = $config->getString('technicalcontact_email', null);
291
292
        $rawheader = "User-Agent: SimpleSAMLphp metarefresh, run by $name <$mail>\r\n";
293
294
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
295
            if (array_key_exists($source['src'], $this->state)) {
296
                $sourceState = $this->state[$source['src']];
297
298
                if (isset($sourceState['last-modified'])) {
299
                    $rawheader .= 'If-Modified-Since: ' . $sourceState['last-modified'] . "\r\n";
300
                }
301
302
                if (isset($sourceState['etag'])) {
303
                    $rawheader .= 'If-None-Match: ' . $sourceState['etag'] . "\r\n";
304
                }
305
            }
306
        }
307
308
        return ['http' => ['header' => $rawheader]];
309
    }
310
311
312
    /**
313
     * @param array $source
314
     * @return void
315
     */
316
    private function addCachedMetadata(array $source): void
317
    {
318
        if (isset($this->oldMetadataSrc)) {
319
            foreach ($this->types as $type) {
320
                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

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