PowerIdPDisco::getCDC()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 17
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 9
nc 4
nop 0
dl 0
loc 17
rs 9.9666
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\discopower;
6
7
use Exception;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Configuration;
11
use SimpleSAML\Logger;
12
use SimpleSAML\Module;
13
use SimpleSAML\Session;
14
use SimpleSAML\Utils;
15
use SimpleSAML\XHTML\IdPDisco;
16
use SimpleSAML\XHTML\Template;
17
18
/**
19
 * This class implements a generic IdP discovery service, for use in various IdP discovery service pages. This should
20
 * reduce code duplication.
21
 *
22
 * This module extends the basic IdP disco handler, and add features like filtering and tabs.
23
 *
24
 * @package SimpleSAMLphp
25
 */
26
class PowerIdPDisco extends IdPDisco
27
{
28
    /**
29
     * The configuration for this instance.
30
     *
31
     * @var \SimpleSAML\Configuration
32
     */
33
    private Configuration $discoconfig;
34
35
    /**
36
     * The domain to use when saving common domain cookies. This is null if support for common domain cookies is
37
     * disabled.
38
     *
39
     * @var string|null
40
     */
41
    private ?string $cdcDomain;
42
43
    /**
44
     * The lifetime of the CDC cookie, in seconds. If set to null, it will only be valid until the browser is closed.
45
     *
46
     * @var int|null
47
     */
48
    private ?int $cdcLifetime;
49
50
51
    /**
52
     * The default sort weight for entries without 'discopower.weight'.
53
     *
54
     * @var int|null
55
     */
56
    private static ?int $defaultWeight = 100;
57
58
    /**
59
     * Initializes this discovery service.
60
     *
61
     * The constructor does the parsing of the request. If this is an invalid request, it will throw an exception.
62
     *
63
     * @param array  $metadataSets Array with metadata sets we find remote entities in.
64
     * @param string $instance The name of this instance of the discovery service.
65
     */
66
    public function __construct(array $metadataSets, string $instance)
67
    {
68
        parent::__construct($metadataSets, $instance);
69
70
        $this->discoconfig = Configuration::getConfig('module_discopower.php');
71
72
        $this->cdcDomain = $this->discoconfig->getOptionalString('cdc.domain', null);
73
        if ($this->cdcDomain !== null && $this->cdcDomain[0] !== '.') {
74
            // ensure that the CDC domain starts with a dot ('.') as required by the spec
75
            $this->cdcDomain = '.' . $this->cdcDomain;
76
        }
77
78
        $this->cdcLifetime = $this->discoconfig->getOptionalInteger('cdc.lifetime', null);
79
80
        self::$defaultWeight = $this->discoconfig->getOptionalInteger('defaultweight', 100);
81
    }
82
83
84
    /**
85
     * Log a message.
86
     *
87
     * This is an helper function for logging messages. It will prefix the messages with our discovery service type.
88
     *
89
     * @param string $message The message which should be logged.
90
     */
91
    protected function log(string $message): void
92
    {
93
        Logger::info('PowerIdPDisco.' . $this->instance . ': ' . $message);
94
    }
95
96
97
    /**
98
     * Compare two entities.
99
     *
100
     * This function is used to sort the entity list. It sorts based on weights,
101
     * and where those aren't available, English name. It puts larger weights
102
     * higher, and will always put IdP's with names configured before those with
103
     * only an entityID.
104
     *
105
     * @param array $a The metadata of the first entity.
106
     * @param array $b The metadata of the second entity.
107
     *
108
     * @return int How $a compares to $b.
109
     */
110
    public static function mcmp(array $a, array $b): int
111
    {
112
        // default weights
113
        if (!isset($a['discopower.weight']) || !is_int($a['discopower.weight'])) {
114
            $a['discopower.weight'] = self::$defaultWeight;
115
        }
116
        if (!isset($b['discopower.weight']) || !is_int($b['discopower.weight'])) {
117
            $b['discopower.weight'] = self::$defaultWeight;
118
        }
119
        if ($a['discopower.weight'] > $b['discopower.weight']) {
120
            return -1; // higher weights further up
121
        } elseif ($b['discopower.weight'] > $a['discopower.weight']) {
122
            return 1; // lower weights further down
123
        } elseif (isset($a['name']['en']) && isset($b['name']['en'])) {
124
            return strcasecmp($a['name']['en'], $b['name']['en']);
125
        } elseif (isset($a['name']['en'])) {
126
            return -1; // place name before entity ID
127
        } elseif (isset($b['name']['en'])) {
128
            return 1; // Place entity ID after name
129
        } else {
130
            return strcasecmp($a['entityid'], $b['entityid']);
131
        }
132
    }
133
134
135
    /**
136
     * Structure the list of IdPs in a hierarchy based upon the tags.
137
     *
138
     * @param array $list A list of IdPs.
139
     *
140
     * @return array The list of IdPs structured accordingly.
141
     */
142
    protected function idplistStructured(array $list): array
143
    {
144
        $slist = [];
145
146
        $order = $this->discoconfig->getOptionalArray('taborder', []);
147
        foreach ($order as $oe) {
148
            $slist[$oe] = [];
149
        }
150
151
        $enableTabs = $this->discoconfig->getOptionalArray('tabs', []);
152
153
        foreach ($list as $key => $val) {
154
            $tags = ['misc'];
155
            if (array_key_exists('tags', $val)) {
156
                $tags = $val['tags'];
157
            }
158
159
160
            foreach ($tags as $tag) {
161
                if (!empty($enableTabs) && !in_array($tag, $enableTabs)) {
162
                    continue;
163
                }
164
                $slist[$tag][$key] = $val;
165
            }
166
        }
167
168
        foreach ($slist as $tab => $tbslist) {
169
            uasort($slist[$tab], [self::class, 'mcmp']);
170
            // reorder with a hook if one exists
171
            Module::callHooks('discosort', $slist[$tab]);
172
        }
173
174
        return $slist;
175
    }
176
177
178
    /**
179
     * Do the actual filtering according the rules defined.
180
     *
181
     * @param array   $filter A set of rules regarding filtering.
182
     * @param array   $entry An entry to be evaluated by the filters.
183
     * @param boolean $default What to do in case the entity does not match any rules. Defaults to true.
184
     *
185
     * @return boolean True if the entity should be kept, false if it should be discarded according to the filters.
186
     */
187
    private function processFilter(array $filter, array $entry, bool $default = true): bool
188
    {
189
        if (in_array($entry['entityid'], $filter['entities.include'])) {
190
            return true;
191
        }
192
        if (in_array($entry['entityid'], $filter['entities.exclude'])) {
193
            return false;
194
        }
195
196
        if (array_key_exists('tags', $entry)) {
197
            foreach ($filter['tags.include'] as $fe) {
198
                if (in_array($fe, $entry['tags'])) {
199
                    return true;
200
                }
201
            }
202
            foreach ($filter['tags.exclude'] as $fe) {
203
                if (in_array($fe, $entry['tags'])) {
204
                    return false;
205
                }
206
            }
207
        }
208
        return $default;
209
    }
210
211
212
    /**
213
     * Filter a list of entities according to any filters defined in the parent class, plus discopower configuration
214
     * options regarding filtering.
215
     *
216
     * @param array $list A list of entities to filter.
217
     *
218
     * @return array The list in $list after filtering entities.
219
     */
220
    protected function filterList(array $list): array
221
    {
222
        $list = parent::filterList($list);
223
224
        try {
225
            $spmd = $this->metadata->getMetaData($this->spEntityId, 'saml20-sp-remote');
226
        } catch (Exception $e) {
227
            if (
228
                $this->discoconfig->getOptionalBoolean('useunsafereturn', false)
229
                && array_key_exists('return', $_GET)
230
            ) {
231
                /*
232
                 * Get the SP metadata from the other side of the protocol bridge by retrieving the state.
233
                 * Because the disco is not explicitly passed the state ID, we can use a crude hack to
234
                 * infer it from the return parameter. This should be relatively safe because we're not
235
                 * going to trust it for anything other than finding the `discopower.filter` elements,
236
                 * and because the SP could bypass all of this anyway by specifying a known IdP in scoping.
237
                 */
238
                try {
239
                    parse_str(parse_url($_GET['return'], PHP_URL_QUERY), $returnState);
240
                    $state = Auth\State::loadState($returnState['AuthID'], 'saml:sp:sso');
241
                    if ($state && array_key_exists('SPMetadata', $state)) {
242
                        $spmd = $state['SPMetadata'];
243
                        $this->log('Updated SP metadata from ' . $this->spEntityId . ' to ' . $spmd['entityid']);
244
                    }
245
                } catch (Exception $e) {
246
                    return $list;
247
                }
248
            } else {
249
                return $list;
250
            }
251
        }
252
253
        if (!isset($spmd) || !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');
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->getOptionalInteger('defaulttab', 0);
310
311
        $idpList = $this->processMetadata($t, $idpList);
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 (isset($t->data['faventry'])) {
323
            $t->data['autofocus'] = 'favouritesubmit';
324
        }
325
326
        /* store the tab list in the session */
327
        $session = Session::getSessionFromRequest();
328
        if (array_key_exists('faventry', $t->data)) {
329
            $session->setData('discopower:tabList', 'faventry', $t->data['faventry']);
330
        }
331
        $session->setData('discopower:tabList', 'tabs', array_keys($idpList));
332
        $session->setData('discopower:tabList', 'defaulttab', $t->data['defaulttab']);
333
334
        $httpUtils = new Utils\HTTP();
335
        $t->data['score'] = $this->discoconfig->getOptionalString('score', 'quicksilver');
336
        $t->data['preferredidp'] = $preferredIdP;
337
        $t->data['urlpattern'] = htmlspecialchars($httpUtils->getSelfURLNoQuery());
338
        $t->data['rememberenabled'] = $this->config->getOptionalBoolean('idpdisco.enableremember', false);
339
        $t->data['rememberchecked'] = $this->config->getOptionalBoolean('idpdisco.rememberchecked', false);
340
        foreach (array_keys($idpList) as $tab) {
341
            Assert::regex(
342
                $tab,
343
                '/^[a-z_][a-z0-9_-]+$/i',
344
                'Tags can contain alphanumeric characters, hyphens and underscores.'
345
                . ' They must start with a A-Z or an underscore (supplied: \'%s\')',
346
            );
347
348
            $translatableTag = "{discopower:tabs:$tab}";
349
            if ($translator::translateSingularGettext($translatableTag) === $translatableTag) {
350
                $t->data['tabNames'][$tab] = $translator::noop($tab);
351
            } else {
352
                $t->data['tabNames'][$tab] = $translator::noop($translatableTag);
353
            }
354
        }
355
        $t->send();
356
    }
357
358
359
    /**
360
     * @param \SimpleSAML\XHTML\Template $t
361
     * @param array $metadata
362
     * @return array
363
     */
364
    private function processMetadata(Template $t, array $metadata): array
365
    {
366
        $basequerystring = '?' .
367
            'entityID=' . urlencode($t->data['entityID']) . '&' .
368
            'return=' . urlencode($t->data['return']) . '&' .
369
            'returnIDParam=' . urlencode($t->data['returnIDParam']) . '&idpentityid=';
370
371
        $httpUtils = new Utils\HTTP();
372
        foreach ($metadata as $tab => $idps) {
373
            foreach ($idps as $entityid => $entity) {
374
                $entity['actionUrl'] = $basequerystring . urlencode($entity['entityid']);
375
                if (array_key_exists('icon', $entity) && $entity['icon'] !== null) {
376
                    $entity['iconUrl'] = $httpUtils->resolveURL($entity['icon']);
377
                }
378
                $entity['keywords'] = implode(
379
                    ' ',
380
                    $t->getEntityPropertyTranslation('Keywords', $entity['UIInfo'] ?? []) ?? [],
0 ignored issues
show
Bug introduced by
It seems like $t->getEntityPropertyTra... ?? array()) ?? array() can also be of type string; however, parameter $pieces of implode() does only seem to accept array, 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

380
                    /** @scrutinizer ignore-type */ $t->getEntityPropertyTranslation('Keywords', $entity['UIInfo'] ?? []) ?? [],
Loading history...
381
                );
382
                $metadata[$tab][$entityid] = $entity;
383
            }
384
        }
385
        return $metadata;
386
    }
387
388
389
    /**
390
     * Get the IdP entities saved in the common domain cookie.
391
     *
392
     * @return array List of IdP entities.
393
     */
394
    private function getCDC(): array
395
    {
396
        if (!isset($_COOKIE['_saml_idp'])) {
397
            return [];
398
        }
399
400
        $ret = (string) $_COOKIE['_saml_idp'];
401
        $ret = explode(' ', $ret);
402
        foreach ($ret as &$idp) {
403
            $idp = base64_decode($idp);
404
            if ($idp === false) {
405
                // not properly base64 encoded
406
                return [];
407
            }
408
        }
409
410
        return $ret;
411
    }
412
413
414
    /**
415
     * Save the current IdP choice to a cookie.
416
     *
417
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
418
     *
419
     * @param string $idp The entityID of the IdP.
420
     */
421
    protected function setPreviousIdP(string $idp): void
422
    {
423
        if ($this->cdcDomain === null) {
424
            parent::setPreviousIdP($idp);
425
            return;
426
        }
427
428
        $list = $this->getCDC();
429
430
        $prevIndex = array_search($idp, $list, true);
431
        if ($prevIndex !== false) {
432
            unset($list[$prevIndex]);
433
        }
434
        $list[] = $idp;
435
436
        foreach ($list as &$value) {
437
            $value = base64_encode($value);
438
        }
439
        $newCookie = implode(' ', $list);
440
441
        while (strlen($newCookie) > 4000) {
442
            // the cookie is too long. Remove the oldest elements until it is short enough
443
            $tmp = explode(' ', $newCookie, 2);
444
            if (count($tmp) === 1) {
445
                // we are left with a single entityID whose base64 representation is too long to fit in a cookie
446
                break;
447
            }
448
            $newCookie = $tmp[1];
449
        }
450
451
        $params = [
452
            'lifetime' => $this->cdcLifetime,
453
            'domain'   => $this->cdcDomain,
454
            'secure'   => true,
455
            'httponly' => false,
456
        ];
457
458
        $httpUtils = new Utils\HTTP();
459
        $httpUtils->setCookie('_saml_idp', $newCookie, $params, false);
460
    }
461
462
463
    /**
464
     * Retrieve the previous IdP the user used.
465
     *
466
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
467
     *
468
     * @return string|null The entity id of the previous IdP the user used, or null if this is the first time.
469
     */
470
    protected function getPreviousIdP(): ?string
471
    {
472
        if ($this->cdcDomain === null) {
473
            return parent::getPreviousIdP();
474
        }
475
476
        $prevIdPs = $this->getCDC();
477
        while (count($prevIdPs) > 0) {
478
            $idp = array_pop($prevIdPs);
479
            $idp = $this->validateIdP($idp);
480
            if ($idp !== null) {
481
                return $idp;
482
            }
483
        }
484
485
        return null;
486
    }
487
}
488