Passed
Push — master ( 682101...575408 )
by Tim
02:52
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 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;
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
        if (!is_null($stateFile) && is_readable($stateFile)) {
57
            include($stateFile);
58
        }
59
60
        if (isset($state)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $state does not exist. Did you maybe mean $stateFile?
Loading history...
61
            $this->state = $state;
62
        }
63
    }
64
65
66
    /**
67
     * Get the types of entities that will be loaded.
68
     *
69
     * @return array The entity types allowed.
70
     */
71
    public function getTypes(): array
72
    {
73
        return $this->types;
74
    }
75
76
77
    /**
78
     * Set the types of entities that will be loaded.
79
     *
80
     * @param string|array $types Either a string with the name of one single type allowed, or an array with a list of
81
     * types. Pass an empty array to reset to all types of entities.
82
     * @return void
83
     */
84
    public function setTypes($types): void
85
    {
86
        if (!is_array($types)) {
87
            $types = [$types];
88
        }
89
        $this->types = $types;
90
    }
91
92
93
    /**
94
     * This function processes a SAML metadata file.
95
     *
96
     * @param $source array
97
     * @return void
98
     */
99
    public function loadSource(array $source): void
100
    {
101
        if (preg_match('@^https?://@i', $source['src'])) {
102
            // Build new HTTP context
103
            $context = $this->createContext($source);
104
105
            // GET!
106
            try {
107
                list($data, $responseHeaders) = \SimpleSAML\Utils\HTTP::fetch($source['src'], $context, true);
108
            } catch (\Exception $e) {
109
                Logger::warning('metarefresh: ' . $e->getMessage());
110
            }
111
112
            // We have response headers, so the request succeeded
113
            if (!isset($responseHeaders)) {
114
                // No response headers, this means the request failed in some way, so re-use old data
115
                Logger::debug('No response from ' . $source['src'] . ' - attempting to re-use cached metadata');
116
                $this->addCachedMetadata($source);
117
                return;
118
            } elseif (preg_match('@^HTTP/1\.[01]\s304\s@', $responseHeaders[0])) {
119
                // 304 response
120
                Logger::debug('Received HTTP 304 (Not Modified) - attempting to re-use cached metadata');
121
                $this->addCachedMetadata($source);
122
                return;
123
            } elseif (!preg_match('@^HTTP/1\.[01]\s200\s@', $responseHeaders[0])) {
124
                // Other error
125
                Logger::debug('Error from ' . $source['src'] . ' - attempting to re-use cached metadata');
126
                $this->addCachedMetadata($source);
127
                return;
128
            }
129
        } else {
130
            // Local file.
131
            $data = file_get_contents($source['src']);
132
            $responseHeaders = null;
133
        }
134
135
        // Everything OK. Proceed.
136
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
137
            // Stale or no metadata, so a fresh copy
138
            Logger::debug('Downloaded fresh copy');
139
        }
140
141
        try {
142
            $entities = $this->loadXML($data, $source);
143
        } catch (\Exception $e) {
144
            Logger::debug('XML parser error when parsing ' . $source['src'] . ' - attempting to re-use cached metadata');
145
            Logger::debug('XML parser returned: ' . $e->getMessage());
146
            $this->addCachedMetadata($source);
147
            return;
148
        }
149
150
        foreach ($entities as $entity) {
151
            if (isset($source['blacklist'])) {
152
                if (!empty($source['blacklist']) && in_array($entity->getEntityId(), $source['blacklist'], true)) {
153
                    Logger::info('Skipping "' . $entity->getEntityId().'" - blacklisted.' . "\n");
154
                    continue;
155
                }
156
            }
157
158
            if (isset($source['whitelist'])) {
159
                if (!empty($source['whitelist']) && !in_array($entity->getEntityId(), $source['whitelist'], true)) {
160
                    Logger::info('Skipping "' . $entity->getEntityId() . '" - not in the whitelist.' . "\n");
161
                    continue;
162
                }
163
            }
164
165
            if (array_key_exists('certificates', $source) && $source['certificates'] !== null) {
166
                if (!$entity->validateSignature($source['certificates'])) {
167
                    Logger::info(
168
                        'Skipping "' . $entity->getEntityId() . '" - could not verify signature using certificate.' . "\n"
169
                    );
170
                    continue;
171
                }
172
            }
173
174
            if (array_key_exists('validateFingerprint', $source) && $source['validateFingerprint'] !== null) {
175
                if (!array_key_exists('certificates', $source) || $source['certificates'] == null) {
176
                    $algo = isset($source['validateFingerprintAlgorithm'])
177
                        ? $source['validateFingerprintAlgorithm']
178
                        : XMLSecurityDSig::SHA1;
179
                    if (!$entity->validateFingerprint($source['validateFingerprint'], $algo)) {
180
                        Logger::info(
181
                            'Skipping "' . $entity->getEntityId() . '" - could not verify signature using fingerprint.' . "\n"
182
                        );
183
                        continue;
184
                    }
185
                } else {
186
                    Logger::info('Skipping validation with fingerprint since option certificate is set.' . "\n");
187
                }
188
            }
189
190
            $template = null;
191
            if (array_key_exists('template', $source)) {
192
                $template = $source['template'];
193
            }
194
195
            if (in_array('saml20-sp-remote', $this->types, true)) {
196
                $this->addMetadata($source['src'], $entity->getMetadata20SP(), 'saml20-sp-remote', $template);
197
            }
198
            if (in_array('saml20-idp-remote', $this->types, true)) {
199
                $this->addMetadata($source['src'], $entity->getMetadata20IdP(), 'saml20-idp-remote', $template);
200
            }
201
            if (in_array('attributeauthority-remote', $this->types, true)) {
202
                $attributeAuthorities = $entity->getAttributeAuthorities();
203
                if (!empty($attributeAuthorities)) {
204
                    $this->addMetadata(
205
                        $source['src'],
206
                        $attributeAuthorities[0],
207
                        'attributeauthority-remote',
208
                        $template
209
                    );
210
                }
211
            }
212
        }
213
214
        $this->saveState($source, $responseHeaders);
215
    }
216
217
218
    /**
219
     * Create HTTP context, with any available caches taken into account
220
     *
221
     * @param array $source
222
     * @return array
223
     */
224
    private function createContext(array $source): array
225
    {
226
        $config = Configuration::getInstance();
227
        $name = $config->getString('technicalcontact_name', null);
228
        $mail = $config->getString('technicalcontact_email', null);
229
230
        $rawheader = "User-Agent: SimpleSAMLphp metarefresh, run by $name <$mail>\r\n";
231
232
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
233
            if (array_key_exists($source['src'], $this->state)) {
234
                $sourceState = $this->state[$source['src']];
235
236
                if (isset($sourceState['last-modified'])) {
237
                    $rawheader .= 'If-Modified-Since: ' . $sourceState['last-modified'] . "\r\n";
238
                }
239
240
                if (isset($sourceState['etag'])) {
241
                    $rawheader .= 'If-None-Match: ' . $sourceState['etag'] . "\r\n";
242
                }
243
            }
244
        }
245
246
        return ['http' => ['header' => $rawheader]];
247
    }
248
249
250
    /**
251
     * @param array $source
252
     * @return void
253
     */
254
    private function addCachedMetadata(array $source): void
255
    {
256
        if (isset($this->oldMetadataSrc)) {
257
            foreach ($this->types as $type) {
258
                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

258
                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...
259
                    if (array_key_exists('metarefresh:src', $entity)) {
260
                        if ($entity['metarefresh:src'] == $source['src']) {
261
                            $this->addMetadata($source['src'], $entity, $type);
262
                        }
263
                    }
264
                }
265
            }
266
        }
267
    }
268
269
270
    /**
271
     * Store caching state data for a source
272
     *
273
     * @param array $source
274
     * @param array|null $responseHeaders
275
     * @return void
276
     */
277
    private function saveState(array $source, ?array $responseHeaders): void
278
    {
279
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
280
            // Headers section
281
            if ($responseHeaders !== null) {
0 ignored issues
show
introduced by
The condition $responseHeaders !== null is always true.
Loading history...
282
                $candidates = ['last-modified', 'etag'];
283
284
                foreach ($candidates as $candidate) {
285
                    if (array_key_exists($candidate, $responseHeaders)) {
286
                        $this->state[$source['src']][$candidate] = $responseHeaders[$candidate];
287
                    }
288
                }
289
            }
290
291
            if (!empty($this->state[$source['src']])) {
292
                // Timestamp when this src was requested.
293
                $this->state[$source['src']]['requested_at'] = $this->getTime();
294
                $this->changed = true;
295
            }
296
        }
297
    }
298
299
300
    /**
301
     * Parse XML metadata and return entities
302
     *
303
     * @param string $data
304
     * @param array $source
305
     * @return \SimpleSAML\Metadata\SAMLParser[]
306
     * @throws \Exception
307
     */
308
    private function loadXML(string $data, array $source): \SimpleSAML\Metadata\SAMLParser
309
    {
310
        try {
311
            $doc = \SAML2\DOMDocumentFactory::fromString($data);
312
        } catch (\Exception $e) {
313
            throw new \Exception('Failed to read XML from ' . $source['src']);
314
        }
315
        if ($doc->documentElement === null) {
316
            throw new \Exception('Opened file is not an XML document: ' . $source['src']);
317
        }
318
        return \SimpleSAML\Metadata\SAMLParser::parseDescriptorsElement($doc->documentElement);
0 ignored issues
show
Bug Best Practice introduced by
The expression return SimpleSAML\Metada...($doc->documentElement) returns the type SimpleSAML\Metadata\SAMLParser[]|array which is incompatible with the type-hinted return SimpleSAML\Metadata\SAMLParser.
Loading history...
319
    }
320
321
322
    /**
323
     * This function writes the state array back to disk
324
     *
325
     * @return void
326
     */
327
    public function writeState(): void
328
    {
329
        if ($this->changed) {
330
            Logger::debug('Writing: '.$this->stateFile);
331
            \SimpleSAML\Utils\System::writeFile(
332
                $this->stateFile,
0 ignored issues
show
Bug introduced by
It seems like $this->stateFile can also be of type null; however, parameter $filename of SimpleSAML\Utils\System::writeFile() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

332
                /** @scrutinizer ignore-type */ $this->stateFile,
Loading history...
333
                "<?php\n/* This file was generated by the metarefresh module at " . $this->getTime() . ".\n" .
334
                " Do not update it manually as it will get overwritten. */\n" .
335
                '$state = ' . var_export($this->state, true) . ";\n?>\n",
336
                0644
337
            );
338
        }
339
    }
340
341
342
    /**
343
     * This function writes the metadata to stdout.
344
     *
345
     * @return void
346
     */
347
    public function dumpMetadataStdOut(): void
348
    {
349
        foreach ($this->metadata as $category => $elements) {
350
            echo '/* The following data should be added to metadata/' . $category . '.php. */' . "\n";
351
352
            foreach ($elements as $m) {
353
                $filename = $m['filename'];
354
                $entityID = $m['metadata']['entityid'];
355
356
                echo "\n";
357
                echo '/* The following metadata was generated from '.$filename.' on ' . $this->getTime() . '. */' . "\n";
358
                echo '$metadata[\'' . addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
359
            }
360
361
            echo "\n";
362
            echo '/* End of data which should be added to metadata/' . $category . '.php. */' . "\n";
363
            echo "\n";
364
        }
365
    }
366
367
368
    /**
369
     * This function adds metadata from the specified file to the list of metadata.
370
     * This function will return without making any changes if $metadata is NULL.
371
     *
372
     * @param string $filename The filename the metadata comes from.
373
     * @param array|null $metadata The metadata.
374
     * @param string $type The metadata type.
375
     * @param array|null $template The template.
376
     * @return void
377
     */
378
    private function addMetadata(string $filename, ?array $metadata, string $type, array $template = null): void
379
    {
380
        if ($metadata === null) {
0 ignored issues
show
introduced by
The condition $metadata === null is always false.
Loading history...
381
            return;
382
        }
383
384
        if (isset($template)) {
385
            $metadata = array_merge($metadata, $template);
386
        }
387
388
        $metadata['metarefresh:src'] = $filename;
389
        if (!array_key_exists($type, $this->metadata)) {
390
            $this->metadata[$type] = [];
391
        }
392
393
        // If expire is defined in constructor...
394
        if (!empty($this->expire)) {
395
            // If expire is already in metadata
396
            if (array_key_exists('expire', $metadata)) {
397
                // Override metadata expire with more restrictive global config
398
                if ($this->expire < $metadata['expire']) {
399
                    $metadata['expire'] = $this->expire;
400
                }
401
402
                // If expire is not already in metadata use global config
403
            } else {
404
                $metadata['expire'] = $this->expire;
405
            }
406
        }
407
        $this->metadata[$type][] = ['filename' => $filename, 'metadata' => $metadata];
408
    }
409
410
411
    /**
412
     * This function writes the metadata to an ARP file
413
     *
414
     * @param \SimpleSAML\Configuration $config
415
     * @return void
416
     */
417
    public function writeARPfile(Configuration $config): void
418
    {
419
        Assert::isInstanceOf($config, Configuration::class);
420
421
        $arpfile = $config->getValue('arpfile');
422
        $types = ['saml20-sp-remote'];
423
424
        $md = [];
425
        foreach ($this->metadata as $category => $elements) {
426
            if (!in_array($category, $types, true)) {
427
                continue;
428
            }
429
            $md = array_merge($md, $elements);
430
        }
431
432
        // $metadata, $attributemap, $prefix, $suffix
433
        $arp = new \SimpleSAML\Module\metarefresh\ARP(
434
            $md,
435
            $config->getValue('attributemap', ''),
436
            $config->getValue('prefix', ''),
437
            $config->getValue('suffix', '')
438
        );
439
440
441
        $arpxml = $arp->getXML();
442
443
        Logger::info('Writing ARP file: ' . $arpfile . "\n");
444
        file_put_contents($arpfile, $arpxml);
445
    }
446
447
448
    /**
449
     * This function writes the metadata to to separate files in the output directory.
450
     *
451
     * @param string $outputDir
452
     * @return void
453
     */
454
    public function writeMetadataFiles(string $outputDir): void
455
    {
456
        while (strlen($outputDir) > 0 && $outputDir[strlen($outputDir) - 1] === '/') {
457
            $outputDir = substr($outputDir, 0, strlen($outputDir) - 1);
458
        }
459
460
        if (!file_exists($outputDir)) {
461
            Logger::info('Creating directory: ' . $outputDir . "\n");
462
            $res = @mkdir($outputDir, 0777, true);
463
            if ($res === false) {
464
                throw new \Exception('Error creating directory: ' . $outputDir);
465
            }
466
        }
467
468
        foreach ($this->types as $type) {
469
            $filename = $outputDir . '/' . $type . '.php';
470
471
            if (array_key_exists($type, $this->metadata)) {
472
                $elements = $this->metadata[$type];
473
                Logger::debug('Writing: ' . $filename);
474
475
                $content  = '<?php' . "\n" . '/* This file was generated by the metarefresh module at ';
476
                $content .= $this->getTime() . "\nDo not update it manually as it will get overwritten\n" . '*/' . "\n";
477
478
                foreach ($elements as $m) {
479
                    $entityID = $m['metadata']['entityid'];
480
                    $content .= "\n" . '$metadata[\'';
481
                    $content .= addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
482
                }
483
484
                $content .= "\n" . '?>';
485
486
                \SimpleSAML\Utils\System::writeFile($filename, $content, 0644);
487
            } elseif (is_file($filename)) {
488
                if (unlink($filename)) {
489
                    Logger::debug('Deleting stale metadata file: ' . $filename);
490
                } else {
491
                    Logger::warning('Could not delete stale metadata file: ' . $filename);
492
                }
493
            }
494
        }
495
    }
496
497
498
    /**
499
     * Save metadata for loading with the 'serialize' metadata loader.
500
     *
501
     * @param string $outputDir  The directory we should save the metadata to.
502
     * @return void
503
     */
504
    public function writeMetadataSerialize(string $outputDir): void
505
    {
506
        $metaHandler = new \SimpleSAML\Metadata\MetaDataStorageHandlerSerialize(['directory' => $outputDir]);
507
508
        // First we add all the metadata entries to the metadata handler
509
        foreach ($this->metadata as $set => $elements) {
510
            foreach ($elements as $m) {
511
                $entityId = $m['metadata']['entityid'];
512
513
                Logger::debug(
514
                    'metarefresh: Add metadata entry ' .
515
                    var_export($entityId, true) . ' in set ' . var_export($set, true) . '.'
516
                );
517
                $metaHandler->saveMetadata($entityId, $set, $m['metadata']);
518
            }
519
        }
520
521
        // Then we delete old entries which should no longer exist
522
        $ct = time();
523
        foreach ($metaHandler->getMetadataSets() as $set) {
524
            foreach ($metaHandler->getMetadataSet($set) as $entityId => $metadata) {
525
                if (!array_key_exists('expire', $metadata)) {
526
                    Logger::warning(
527
                        'metarefresh: Metadata entry without expire timestamp: ' . var_export($entityId, true) .
528
                        ' in set ' . var_export($set, true) . '.'
529
                    );
530
                    continue;
531
                }
532
                if ($metadata['expire'] > $ct) {
533
                    continue;
534
                }
535
                Logger::debug('metarefresh: ' . $entityId . ' expired ' . date('l jS \of F Y h:i:s A', $metadata['expire']));
536
                Logger::debug(
537
                    'metarefresh: Delete expired metadata entry ' .
538
                    var_export($entityId, true) . ' in set ' . var_export($set, true) .
539
                    '. (' . ($ct - $metadata['expire']) . ' sec)'
540
                );
541
                $metaHandler->deleteMetadata($entityId, $set);
542
            }
543
        }
544
    }
545
546
547
    /**
548
     * @return string
549
     */
550
    private function getTime(): string
551
    {
552
        // The current date, as a string
553
        date_default_timezone_set('UTC');
554
        return date('Y-m-d\\TH:i:s\\Z');
555
    }
556
}
557