Passed
Pull Request — master (#11)
by Tim
02:58
created

MetaLoader::writeMetadataFiles()   B

Complexity

Conditions 10
Paths 26

Size

Total Lines 38
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 10
eloc 25
c 1
b 0
f 0
nc 26
nop 1
dl 0
loc 38
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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)) {
171
                    /* Skip non-IdPs */
172
                    continue;
173
                }
174
175
                /* Do a recursive comparison for each whitelist of the attributewhitelist with the idpMetadata for this
176
                 * IdP. At least one of these whitelists should match */
177
                $match = false;
178
                foreach ($source['attributewhitelist'] as $whitelist) {
179
                    if ($this->containsArray($whitelist, $idpMetadata)) {
180
                        $match = true;
181
                        break;
182
                    }
183
                }
184
                if (!$match) {
185
                    /* No match found -> next IdP */
186
                    continue;
187
                }
188
                Logger::debug('Whitelisted entityID: ' . $entity->getEntityID());
189
            }
190
191
            if (array_key_exists('certificates', $source) && ($source['certificates'] !== null)) {
192
                if (!$entity->validateSignature($source['certificates'])) {
193
                    Logger::info(
194
                        'Skipping "' . $entity->getEntityId() . '" - could not verify signature using certificate.' . "\n"
195
                    );
196
                    continue;
197
                }
198
            }
199
200
            $template = null;
201
            if (array_key_exists('template', $source)) {
202
                $template = $source['template'];
203
            }
204
205
            if (in_array('saml20-sp-remote', $this->types, true)) {
206
                $this->addMetadata($source['src'], $entity->getMetadata20SP(), 'saml20-sp-remote', $template);
207
            }
208
            if (in_array('saml20-idp-remote', $this->types, true)) {
209
                $this->addMetadata($source['src'], $entity->getMetadata20IdP(), 'saml20-idp-remote', $template);
210
            }
211
            if (in_array('attributeauthority-remote', $this->types, true)) {
212
                $attributeAuthorities = $entity->getAttributeAuthorities();
213
                if (!empty($attributeAuthorities)) {
214
                    $this->addMetadata(
215
                        $source['src'],
216
                        $attributeAuthorities,
217
                        'attributeauthority-remote',
218
                        $template
219
                    );
220
                }
221
            }
222
        }
223
224
        $this->saveState($source, $responseHeaders);
225
    }
226
227
228
    /*
229
     * Recursively checks whether array $dst contains array $src. If $src
230
     * is not an array, a literal comparison is being performed.
231
     */
232
    private function containsArray($src, $dst): bool
233
    {
234
        if (is_array($src)) {
235
            if (!is_array($dst)) {
236
                return false;
237
            }
238
            $dstKeys = array_keys($dst);
239
240
            /* Loop over all src keys */
241
            foreach ($src as $srcKey => $srcval) {
242
                if (is_int($srcKey)) {
243
                    /* key is number, check that the key appears as one
244
                     * of the destination keys: if not, then src has
245
                     * more keys than dst */
246
                    if (!array_key_exists($srcKey, $dst)) {
247
                        return false;
248
                    }
249
250
                    /* loop over dest keys, to find value: we don't know
251
                     * whether they are in the same order */
252
                    $submatch = false;
253
                    foreach ($dstKeys as $dstKey) {
254
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
255
                            $submatch = true;
256
                            break;
257
                        }
258
                    }
259
                    if (!$submatch) {
260
                        return false;
261
                    }
262
                } else {
263
                    /* key is regexp: find matching keys */
264
                    $matchingDstKeys = preg_grep($srcKey, $dstKeys);
265
                    if (!is_array($matchingDstKeys)) {
266
                        return false;
267
                    }
268
269
                    $match = false;
270
                    foreach ($matchingDstKeys as $dstKey) {
271
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
272
                            /* Found a match */
273
                            $match = true;
274
                            break;
275
                        }
276
                    }
277
                    if (!$match) {
278
                        /* none of the keys has a matching value */
279
                        return false;
280
                    }
281
                }
282
            }
283
            /* each src key/value matches */
284
            return true;
285
        } else {
286
            /* src is not an array, do a regexp match against dst */
287
            return (preg_match($src, $dst) === 1);
288
        }
289
    }
290
291
    /**
292
     * Create HTTP context, with any available caches taken into account
293
     *
294
     * @param array $source
295
     * @return array
296
     */
297
    private function createContext(array $source): array
298
    {
299
        $config = Configuration::getInstance();
300
        $name = $config->getString('technicalcontact_name', null);
301
        $mail = $config->getString('technicalcontact_email', null);
302
303
        $rawheader = "User-Agent: SimpleSAMLphp metarefresh, run by $name <$mail>\r\n";
304
305
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
306
            if (array_key_exists($source['src'], $this->state)) {
307
                $sourceState = $this->state[$source['src']];
308
309
                if (isset($sourceState['last-modified'])) {
310
                    $rawheader .= 'If-Modified-Since: ' . $sourceState['last-modified'] . "\r\n";
311
                }
312
313
                if (isset($sourceState['etag'])) {
314
                    $rawheader .= 'If-None-Match: ' . $sourceState['etag'] . "\r\n";
315
                }
316
            }
317
        }
318
319
        return ['http' => ['header' => $rawheader]];
320
    }
321
322
323
    /**
324
     * @param array $source
325
     * @return void
326
     */
327
    private function addCachedMetadata(array $source): void
328
    {
329
        if (isset($this->oldMetadataSrc)) {
330
            foreach ($this->types as $type) {
331
                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

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