Passed
Push — master ( b6c529...19c67d )
by Tim
02:20
created

PowerIdPDisco::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 7
c 2
b 0
f 0
nc 2
nop 2
dl 0
loc 15
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\discopower;
6
7
use SimpleSAML\Assert\Assert;
8
use SimpleSAML\Configuration;
9
use SimpleSAML\Locale\Translate;
10
use SimpleSAML\Logger;
11
use SimpleSAML\Session;
12
use SimpleSAML\Utils\HTTP;
13
use SimpleSAML\XHTML\Template;
14
15
/**
16
 * This class implements a generic IdP discovery service, for use in various IdP discovery service pages. This should
17
 * reduce code duplication.
18
 *
19
 * This module extends the basic IdP disco handler, and add features like filtering and tabs.
20
 *
21
 * @package SimpleSAMLphp
22
 */
23
class PowerIdPDisco extends \SimpleSAML\XHTML\IdPDisco
24
{
25
    /**
26
     * The configuration for this instance.
27
     *
28
     * @var \SimpleSAML\Configuration
29
     */
30
    private Configuration $discoconfig;
31
32
    /**
33
     * The domain to use when saving common domain cookies. This is null if support for common domain cookies is
34
     * disabled.
35
     *
36
     * @var string|null
37
     */
38
    private ?string $cdcDomain;
39
40
    /**
41
     * The lifetime of the CDC cookie, in seconds. If set to null, it will only be valid until the browser is closed.
42
     *
43
     * @var int|null
44
     */
45
    private ?int $cdcLifetime;
46
47
48
    /**
49
     * The default sort weight for entries without 'discopower.weight'.
50
     *
51
     * @var int|null
52
     */
53
    private static ?int $defaultWeight = 100;
54
55
    /**
56
     * Initializes this discovery service.
57
     *
58
     * The constructor does the parsing of the request. If this is an invalid request, it will throw an exception.
59
     *
60
     * @param array  $metadataSets Array with metadata sets we find remote entities in.
61
     * @param string $instance The name of this instance of the discovery service.
62
     */
63
    public function __construct(array $metadataSets, string $instance)
64
    {
65
        parent::__construct($metadataSets, $instance);
66
67
        $this->discoconfig = Configuration::getConfig('module_discopower.php');
68
69
        $this->cdcDomain = $this->discoconfig->getString('cdc.domain', null);
70
        if ($this->cdcDomain !== null && $this->cdcDomain[0] !== '.') {
71
            // ensure that the CDC domain starts with a dot ('.') as required by the spec
72
            $this->cdcDomain = '.' . $this->cdcDomain;
73
        }
74
75
        $this->cdcLifetime = $this->discoconfig->getInteger('cdc.lifetime', null);
76
77
        self::$defaultWeight = $this->discoconfig->getInteger('defaultweight', 100);
78
    }
79
80
81
    /**
82
     * Log a message.
83
     *
84
     * This is an helper function for logging messages. It will prefix the messages with our discovery service type.
85
     *
86
     * @param string $message The message which should be logged.
87
     */
88
    protected function log(string $message): void
89
    {
90
        Logger::info('PowerIdPDisco.' . $this->instance . ': ' . $message);
91
    }
92
93
94
    /**
95
     * Compare two entities.
96
     *
97
     * This function is used to sort the entity list. It sorts based on weights,
98
     * and where those aren't available, English name. It puts larger weights
99
     * higher, and will always put IdP's with names configured before those with
100
     * only an entityID.
101
     *
102
     * @param array $a The metadata of the first entity.
103
     * @param array $b The metadata of the second entity.
104
     *
105
     * @return int How $a compares to $b.
106
     */
107
    public static function mcmp(array $a, array $b): int
108
    {
109
        // default weights
110
        if (!isset($a['discopower.weight']) || !is_int($a['discopower.weight'])) {
111
            $a['discopower.weight'] = self::$defaultWeight;
112
        }
113
        if (!isset($b['discopower.weight']) || !is_int($b['discopower.weight'])) {
114
            $b['discopower.weight'] = self::$defaultWeight;
115
        }
116
        if ($a['discopower.weight'] > $b['discopower.weight']) {
117
            return -1; // higher weights further up
118
        } elseif ($b['discopower.weight'] > $a['discopower.weight']) {
119
            return 1; // lower weights further down
120
        } elseif (isset($a['name']['en']) && isset($b['name']['en'])) {
121
            return strcasecmp($a['name']['en'], $b['name']['en']);
122
        } elseif (isset($a['name']['en'])) {
123
            return -1; // place name before entity ID
124
        } elseif (isset($b['name']['en'])) {
125
            return 1; // Place entity ID after name
126
        } else {
127
            return strcasecmp($a['entityid'], $b['entityid']);
128
        }
129
    }
130
131
132
    /**
133
     * Structure the list of IdPs in a hierarchy based upon the tags.
134
     *
135
     * @param array $list A list of IdPs.
136
     *
137
     * @return array The list of IdPs structured accordingly.
138
     */
139
    protected function idplistStructured(array $list): array
140
    {
141
        $slist = [];
142
143
        $order = $this->discoconfig->getValue('taborder');
144
        if (is_array($order)) {
145
            foreach ($order as $oe) {
146
                $slist[$oe] = [];
147
            }
148
        }
149
150
        $enableTabs = $this->discoconfig->getValue('tabs', null);
151
152
        foreach ($list as $key => $val) {
153
            $tags = ['misc'];
154
            if (array_key_exists('tags', $val)) {
155
                $tags = $val['tags'];
156
            }
157
            foreach ($tags as $tag) {
158
                if (!empty($enableTabs) && !in_array($tag, $enableTabs)) {
159
                    continue;
160
                }
161
                $slist[$tag][$key] = $val;
162
            }
163
        }
164
165
        foreach ($slist as $tab => $tbslist) {
166
            uasort($slist[$tab], [self::class, 'mcmp']);
167
            // reorder with a hook if one exists
168
            \SimpleSAML\Module::callHooks('discosort', $slist[$tab]);
169
        }
170
171
        return $slist;
172
    }
173
174
175
    /**
176
     * Do the actual filtering according the rules defined.
177
     *
178
     * @param array   $filter A set of rules regarding filtering.
179
     * @param array   $entry An entry to be evaluated by the filters.
180
     * @param boolean $default What to do in case the entity does not match any rules. Defaults to true.
181
     *
182
     * @return boolean True if the entity should be kept, false if it should be discarded according to the filters.
183
     */
184
    private function processFilter(array $filter, array $entry, bool $default = true): bool
185
    {
186
        if (in_array($entry['entityid'], $filter['entities.include'])) {
187
            return true;
188
        }
189
        if (in_array($entry['entityid'], $filter['entities.exclude'])) {
190
            return false;
191
        }
192
193
        if (array_key_exists('tags', $entry)) {
194
            foreach ($filter['tags.include'] as $fe) {
195
                if (in_array($fe, $entry['tags'])) {
196
                    return true;
197
                }
198
            }
199
            foreach ($filter['tags.exclude'] as $fe) {
200
                if (in_array($fe, $entry['tags'])) {
201
                    return false;
202
                }
203
            }
204
        }
205
        return $default;
206
    }
207
208
209
    /**
210
     * Filter a list of entities according to any filters defined in the parent class, plus discopower configuration
211
     * options regarding filtering.
212
     *
213
     * @param array $list A list of entities to filter.
214
     *
215
     * @return array The list in $list after filtering entities.
216
     */
217
    protected function filterList(array $list): array
218
    {
219
        $list = parent::filterList($list);
220
221
        try {
222
            $spmd = $this->metadata->getMetaData($this->spEntityId, 'saml20-sp-remote');
223
        } catch (\Exception $e) {
224
            if (
225
                $this->discoconfig->getBoolean('useunsafereturn', false)
226
                && array_key_exists('return', $_GET)
227
            ) {
228
                /*
229
                 * Get the SP metadata from the other side of the protocol bridge by retrieving the state.
230
                 * Because the disco is not explicitly passed the state ID, we can use a crude hack to
231
                 * infer it from the return parameter. This should be relatively safe because we're not
232
                 * going to trust it for anything other than finding the `discopower.filter` elements,
233
                 * and because the SP could bypass all of this anyway by specifying a known IdP in scoping.
234
                 */
235
                try {
236
                    parse_str(parse_url($_GET['return'], PHP_URL_QUERY), $returnState);
237
                    $state = \SimpleSAML\Auth\State::loadState($returnState['AuthID'], 'saml:sp:sso');
238
                    if ($state && array_key_exists('SPMetadata', $state)) {
239
                        $spmd = $state['SPMetadata'];
240
                        $this->log('Updated SP metadata from ' . $this->spEntityId . ' to ' . $spmd['entityid']);
241
                    }
242
                } catch (\Exception $e) {
243
                    return $list;
244
                }
245
            } else {
246
                return $list;
247
            }
248
        }
249
250
        if (!isset($spmd)) {
251
            return $list;
252
        }
253
        if (!array_key_exists('discopower.filter', $spmd)) {
254
            return $list;
255
        }
256
        $filter = $spmd['discopower.filter'];
257
258
        if (!array_key_exists('entities.include', $filter)) {
259
            $filter['entities.include'] = [];
260
        }
261
        if (!array_key_exists('entities.exclude', $filter)) {
262
            $filter['entities.exclude'] = [];
263
        }
264
        if (!array_key_exists('tags.include', $filter)) {
265
            $filter['tags.include'] = [];
266
        }
267
        if (!array_key_exists('tags.exclude', $filter)) {
268
            $filter['tags.exclude'] = [];
269
        }
270
271
        $defaultrule = true;
272
        if (
273
            array_key_exists('entities.include', $spmd['discopower.filter'])
274
            || array_key_exists('tags.include', $spmd['discopower.filter'])
275
        ) {
276
            $defaultrule = false;
277
        }
278
279
        $returnlist = [];
280
        foreach ($list as $key => $entry) {
281
            if ($this->processFilter($filter, $entry, $defaultrule)) {
282
                $returnlist[$key] = $entry;
283
            }
284
        }
285
        return $returnlist;
286
    }
287
288
289
    /**
290
     * Handles a request to this discovery service.
291
     *
292
     * The IdP disco parameters should be set before calling this function.
293
     */
294
    public function handleRequest(): void
295
    {
296
        $this->start();
297
298
        // no choice made. Show discovery service page
299
        $idpList = $this->getIdPList();
300
        $idpList = $this->idplistStructured($this->filterList($idpList));
301
        $preferredIdP = $this->getRecommendedIdP();
302
303
        $t = new Template($this->config, 'discopower:disco.twig', 'disco');
304
        $translator = $t->getTranslator();
305
306
        $t->data['return'] = $this->returnURL;
307
        $t->data['returnIDParam'] = $this->returnIdParam;
308
        $t->data['entityID'] = $this->spEntityId;
309
        $t->data['defaulttab'] = $this->discoconfig->getValue('defaulttab', 0);
310
311
        $idpList = $this->processMetadata($t, $idpList, $preferredIdP);
312
313
        $t->data['idplist'] = $idpList;
314
        $t->data['faventry'] = null;
315
        foreach ($idpList as $tab => $slist) {
316
            if (!empty($preferredIdP) && array_key_exists($preferredIdP, $slist)) {
317
                $t->data['faventry'] = $slist[$preferredIdP];
318
                break;
319
            }
320
        }
321
322
        if (!is_null($t->data['faventry'])) {
323
            $t->data['autofocus'] = 'favouritesubmit';
324
        }
325
326
        /* store the tab list in the session */
327
        $session = Session::getSessionFromRequest();
328
        $session->setData('discopower:tabList', 'faventry', $t->data['faventry']);
329
        $session->setData('discopower:tabList', 'tabs', array_keys($idpList));
330
        $session->setData('discopower:tabList', 'defaulttab', $t->data['defaulttab']);
331
332
        $t->data['score'] = $this->discoconfig->getValue('score', 'quicksilver');
333
        $t->data['preferredidp'] = $preferredIdP;
334
        $t->data['urlpattern'] = htmlspecialchars(HTTP::getSelfURLNoQuery());
335
        $t->data['rememberenabled'] = $this->config->getBoolean('idpdisco.enableremember', false);
336
        $t->data['rememberchecked'] = $this->config->getBoolean('idpdisco.rememberchecked', false);
337
        foreach (array_keys($idpList) as $tab) {
338
            if ($translator->getTag('{discopower:tabs:' . $tab . '}') === null) {
339
                $translator->includeInlineTranslation('{discopower:tabs:' . $tab . '}', $tab);
340
            }
341
            $t->data['tabNames'][$tab] = $translator::noop('{discopower:tabs:' . $tab . '}');
342
        }
343
        $t->send();
344
    }
345
346
347
    /**
348
     * @param \SimpleSAML\XHTML\Template $t
349
     * @param array $metadata
350
     * @param string|null $favourite
351
     * @return array
352
     */
353
    private function processMetadata(Template $t, array $metadata, ?string $favourite): array
354
    {
355
        $basequerystring = '?' .
356
            'entityID=' . urlencode($t->data['entityID']) . '&amp;' .
357
            'return=' . urlencode($t->data['return']) . '&amp;' .
358
            'returnIDParam=' . urlencode($t->data['returnIDParam']) . '&amp;idpentityid=';
359
360
        foreach ($metadata as $tab => $idps) {
361
            foreach ($idps as $entityid => $entity) {
362
                $translation = false;
363
364
                // Translate name
365
                if (isset($entity['UIInfo']['DisplayName'])) {
366
                    $displayName = $entity['UIInfo']['DisplayName'];
367
368
                    // Should always be an array of language code -> translation
369
                    Assert::isArray($displayName);
370
371
                    if (!empty($displayName)) {
372
                        $translation = $t->getTranslator()->getPreferredTranslation($displayName);
373
                    }
374
                }
375
376
                if (($translation === false) && array_key_exists('name', $entity)) {
377
                    if (is_array($entity['name'])) {
378
                        $translation = $t->getTranslator()->getPreferredTranslation($entity['name']);
379
                    } else {
380
                        $translation = $entity['name'];
381
                    }
382
                }
383
384
                if ($translation === false) {
385
                    $translation = $entity['entityid'];
386
                }
387
                $entity['translated'] = $translation;
388
389
                // HTML output
390
                if ($entity['entityid'] === $favourite) {
391
                    $html = '<a class="metaentry favourite" href="' .
392
                        $basequerystring . urlencode($entity['entityid']) . '">';
393
                } else {
394
                    $html = '<a class="metaentry" href="' .
395
                        $basequerystring . urlencode($entity['entityid']) . '">';
396
                }
397
                $html .= $entity['translated'];
398
                if (array_key_exists('icon', $entity) && $entity['icon'] !== null) {
399
                    $iconUrl = HTTP::resolveURL($entity['icon']);
400
                    $html .= '<img alt="Icon for identity provider" class="entryicon" src="' .
401
                        htmlspecialchars($iconUrl) . '" />';
402
                }
403
                $html .= '</a>';
404
                $entity['html'] = $html;
405
406
                // Save processed data
407
                $metadata[$tab][$entityid] = $entity;
408
            }
409
        }
410
        return $metadata;
411
    }
412
413
414
    /**
415
     * Get the IdP entities saved in the common domain cookie.
416
     *
417
     * @return array List of IdP entities.
418
     */
419
    private function getCDC(): array
420
    {
421
        if (!isset($_COOKIE['_saml_idp'])) {
422
            return [];
423
        }
424
425
        $ret = (string) $_COOKIE['_saml_idp'];
426
        $ret = explode(' ', $ret);
427
        foreach ($ret as &$idp) {
428
            $idp = base64_decode($idp);
429
            if ($idp === false) {
430
                // not properly base64 encoded
431
                return [];
432
            }
433
        }
434
435
        return $ret;
436
    }
437
438
439
    /**
440
     * Save the current IdP choice to a cookie.
441
     *
442
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
443
     *
444
     * @param string $idp The entityID of the IdP.
445
     */
446
    protected function setPreviousIdP(string $idp): void
447
    {
448
        if ($this->cdcDomain === null) {
449
            parent::setPreviousIdP($idp);
450
            return;
451
        }
452
453
        $list = $this->getCDC();
454
455
        $prevIndex = array_search($idp, $list, true);
456
        if ($prevIndex !== false) {
457
            unset($list[$prevIndex]);
458
        }
459
        $list[] = $idp;
460
461
        foreach ($list as &$value) {
462
            $value = base64_encode($value);
463
        }
464
        $newCookie = implode(' ', $list);
465
466
        while (strlen($newCookie) > 4000) {
467
            // the cookie is too long. Remove the oldest elements until it is short enough
468
            $tmp = explode(' ', $newCookie, 2);
469
            if (count($tmp) === 1) {
470
                // we are left with a single entityID whose base64 representation is too long to fit in a cookie
471
                break;
472
            }
473
            $newCookie = $tmp[1];
474
        }
475
476
        $params = [
477
            'lifetime' => $this->cdcLifetime,
478
            'domain'   => $this->cdcDomain,
479
            'secure'   => true,
480
            'httponly' => false,
481
        ];
482
        HTTP::setCookie('_saml_idp', $newCookie, $params, false);
483
    }
484
485
486
    /**
487
     * Retrieve the previous IdP the user used.
488
     *
489
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
490
     *
491
     * @return string|null The entity id of the previous IdP the user used, or null if this is the first time.
492
     */
493
    protected function getPreviousIdP(): ?string
494
    {
495
        if ($this->cdcDomain === null) {
496
            return parent::getPreviousIdP();
497
        }
498
499
        $prevIdPs = $this->getCDC();
500
        while (count($prevIdPs) > 0) {
501
            $idp = array_pop($prevIdPs);
502
            $idp = $this->validateIdP($idp);
503
            if ($idp !== null) {
504
                return $idp;
505
            }
506
        }
507
508
        return null;
509
    }
510
}
511