Passed
Push — master ( e403e3...12ce73 )
by Thijs
16:02
created

PowerIdPDisco::processMetadata()   A

Complexity

Conditions 5
Paths 4

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

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