Passed
Push — master ( 22ef39...b9d93e )
by Tim
02:17
created

Consent::getAttributeHash()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 14
rs 9.9332
1
<?php
2
3
/**
4
 * Consent Authentication Processing filter
5
 *
6
 * Filter for requesting the user to give consent before attributes are
7
 * released to the SP.
8
 *
9
 * @package SimpleSAMLphp
10
 */
11
12
namespace SimpleSAML\Module\consent\Auth\Process;
13
14
use SimpleSAML\Logger;
15
use SimpleSAML\Module;
16
use SimpleSAML\Stats;
17
use SimpleSAML\Utils;
18
use Webmozart\Assert\Assert;
19
20
class Consent extends \SimpleSAML\Auth\ProcessingFilter
21
{
22
    /**
23
     * Button to receive focus
24
     *
25
     * @var string|null
26
     */
27
    private $focus = null;
28
29
    /**
30
     * Include attribute values
31
     *
32
     * @var bool
33
     */
34
    private $includeValues = false;
35
36
    /**
37
     * Check remember consent
38
     *
39
     * @var bool
40
     */
41
    private $checked = false;
42
43
    /**
44
     * Consent backend storage configuration
45
     *
46
     * @var \SimpleSAML\Module\consent\Store|null
47
     */
48
    private $store = null;
49
50
    /**
51
     * Attributes where the value should be hidden
52
     *
53
     * @var array
54
     */
55
    private $hiddenAttributes = [];
56
57
    /**
58
     * Attributes which should not require consent
59
     *
60
     * @var array
61
     */
62
    private $noconsentattributes = [];
63
64
    /**
65
     * Whether we should show the "about service"-link on the no consent page.
66
     *
67
     * @var bool
68
     */
69
    private $showNoConsentAboutService = true;
70
71
72
    /**
73
     * Initialize consent filter.
74
     *
75
     * Validates and parses the configuration.
76
     *
77
     * @param array $config Configuration information.
78
     * @param mixed $reserved For future use.
79
     *
80
     * @throws \SimpleSAML\Error\Exception if the configuration is not valid.
81
     */
82
    public function __construct(array $config, array $reserved)
83
    {
84
        parent::__construct($config, $reserved);
85
86
        if (array_key_exists('includeValues', $config)) {
87
            if (!is_bool($config['includeValues'])) {
88
                throw new \SimpleSAML\Error\Exception(
89
                    'Consent: includeValues must be boolean. ' .
90
                    var_export($config['includeValues'], true) . ' given.'
91
                );
92
            }
93
            $this->includeValues = $config['includeValues'];
94
        }
95
96
        if (array_key_exists('checked', $config)) {
97
            if (!is_bool($config['checked'])) {
98
                throw new \SimpleSAML\Error\Exception(
99
                    'Consent: checked must be boolean. ' .
100
                    var_export($config['checked'], true) . ' given.'
101
                );
102
            }
103
            $this->checked = $config['checked'];
104
        }
105
106
        if (array_key_exists('focus', $config)) {
107
            if (!in_array($config['focus'], ['yes', 'no'], true)) {
108
                throw new \SimpleSAML\Error\Exception(
109
                    'Consent: focus must be a string with values `yes` or `no`. ' .
110
                    var_export($config['focus'], true) . ' given.'
111
                );
112
            }
113
            $this->focus = $config['focus'];
114
        }
115
116
        if (array_key_exists('hiddenAttributes', $config)) {
117
            if (!is_array($config['hiddenAttributes'])) {
118
                throw new \SimpleSAML\Error\Exception(
119
                    'Consent: hiddenAttributes must be an array. ' .
120
                    var_export($config['hiddenAttributes'], true) . ' given.'
121
                );
122
            }
123
            $this->hiddenAttributes = $config['hiddenAttributes'];
124
        }
125
126
        if (array_key_exists('attributes.exclude', $config)) {
127
            if (!is_array($config['attributes.exclude'])) {
128
                throw new \SimpleSAML\Error\Exception(
129
                    'Consent: attributes.exclude must be an array. ' .
130
                    var_export($config['attributes.exclude'], true) . ' given.'
131
                );
132
            }
133
            $this->noconsentattributes = $config['attributes.exclude'];
134
        } elseif (array_key_exists('noconsentattributes', $config)) {
135
            Logger::warning("The 'noconsentattributes' option has been deprecated in favour of 'attributes.exclude'.");
136
            if (!is_array($config['noconsentattributes'])) {
137
                throw new \SimpleSAML\Error\Exception(
138
                    'Consent: noconsentattributes must be an array. ' .
139
                    var_export($config['noconsentattributes'], true) . ' given.'
140
                );
141
            }
142
            $this->noconsentattributes = $config['noconsentattributes'];
143
        }
144
145
        if (array_key_exists('store', $config)) {
146
            try {
147
                $this->store = \SimpleSAML\Module\consent\Store::parseStoreConfig($config['store']);
148
            } catch (\Exception $e) {
149
                Logger::error(
150
                    'Consent: Could not create consent storage: ' .
151
                    $e->getMessage()
152
                );
153
            }
154
        }
155
156
        if (array_key_exists('showNoConsentAboutService', $config)) {
157
            if (!is_bool($config['showNoConsentAboutService'])) {
158
                throw new \SimpleSAML\Error\Exception('Consent: showNoConsentAboutService must be a boolean.');
159
            }
160
            $this->showNoConsentAboutService = $config['showNoConsentAboutService'];
161
        }
162
    }
163
164
165
    /**
166
     * Helper function to check whether consent is disabled.
167
     *
168
     * @param mixed  $option The consent.disable option. Either an array of array, an array or a boolean.
169
     * @param string $entityId The entityID of the SP/IdP.
170
     *
171
     * @return boolean True if disabled, false if not.
172
     */
173
    private static function checkDisable($option, string $entityId): bool
174
    {
175
        if (is_array($option)) {
176
            // Check if consent.disable array has one element that is an array
177
            if (count($option) === count($option, COUNT_RECURSIVE)) {
178
                // Array is not multidimensional.  Simple in_array search suffices
179
                return in_array($entityId, $option, true);
180
            }
181
182
            // Array contains at least one element that is an array, verify both possibilities
183
            if (in_array($entityId, $option, true)) {
184
                return true;
185
            }
186
187
            // Search in multidimensional arrays
188
            foreach ($option as $optionToTest) {
189
                if (!is_array($optionToTest)) {
190
                    continue; // bad option
191
                }
192
193
                if (!array_key_exists('type', $optionToTest)) {
194
                    continue; // option has no type
195
                }
196
197
                // Option has a type - switch processing depending on type value :
198
                if ($optionToTest['type'] === 'regex') {
199
                    // regex-based consent disabling
200
201
                    if (!array_key_exists('pattern', $optionToTest)) {
202
                        continue; // no pattern defined
203
                    }
204
205
                    if (preg_match($optionToTest['pattern'], $entityId) === 1) {
206
                        return true;
207
                    }
208
                } else {
209
                    // option type is not supported
210
                    continue;
211
                }
212
            } // end foreach
213
214
            // Base case : no match
215
            return false;
216
        } else {
217
            return (bool) $option;
218
        }
219
    }
220
221
222
    /**
223
     * Process a authentication response
224
     *
225
     * This function saves the state, and redirects the user to the page where the user can authorize the release of
226
     * the attributes. If storage is used and the consent has already been given the user is passed on.
227
     *
228
     * @param array &$state The state of the response.
229
     *
230
     * @return void
231
     *
232
     * @throws \SimpleSAML\Error\NoPassive if the request was passive and consent is needed.
233
     */
234
    public function process(array &$state): void
235
    {
236
        Assert::keyExists($state, 'UserID');
237
        Assert::keyExists($state, 'Destination');
238
        Assert::keyExists($state['Destination'], 'entityid');
239
        Assert::keyExists($state['Destination'], 'metadata-set');
240
        Assert::keyExists($state['Source'], 'entityid');
241
        Assert::keyExists($state['Source'], 'metadata-set');
242
243
        $spEntityId = $state['Destination']['entityid'];
244
        $idpEntityId = $state['Source']['entityid'];
245
246
        $metadata = \SimpleSAML\Metadata\MetaDataStorageHandler::getMetadataHandler();
247
248
        /**
249
         * If the consent module is active on a bridge $state['saml:sp:IdP']
250
         * will contain an entry id for the remote IdP. If not, then the
251
         * consent module is active on a local IdP and nothing needs to be
252
         * done.
253
         */
254
        if (isset($state['saml:sp:IdP'])) {
255
            $idpEntityId = $state['saml:sp:IdP'];
256
            $idpmeta = $metadata->getMetaData($idpEntityId, 'saml20-idp-remote');
257
            $state['Source'] = $idpmeta;
258
        }
259
260
        $statsData = ['spEntityID' => $spEntityId];
261
262
        // Do not use consent if disabled
263
        if (
264
            isset($state['Source']['consent.disable']) &&
265
            self::checkDisable($state['Source']['consent.disable'], $spEntityId)
266
        ) {
267
            Logger::debug('Consent: Consent disabled for entity ' . $spEntityId . ' with IdP ' . $idpEntityId);
268
            Stats::log('consent:disabled', $statsData);
269
            return;
270
        }
271
        if (
272
            isset($state['Destination']['consent.disable']) &&
273
            self::checkDisable($state['Destination']['consent.disable'], $idpEntityId)
274
        ) {
275
            Logger::debug('Consent: Consent disabled for entity ' . $spEntityId . ' with IdP ' . $idpEntityId);
276
            Stats::log('consent:disabled', $statsData);
277
            return;
278
        }
279
280
        if ($this->store !== null) {
281
            $source = $state['Source']['metadata-set'] . '|' . $idpEntityId;
282
            $destination = $state['Destination']['metadata-set'] . '|' . $spEntityId;
283
            $attributes = $state['Attributes'];
284
285
            // Remove attributes that do not require consent
286
            foreach ($attributes as $attrkey => $attrval) {
287
                if (in_array($attrkey, $this->noconsentattributes, true)) {
288
                    unset($attributes[$attrkey]);
289
                }
290
            }
291
292
            Logger::debug('Consent: userid: ' . $state['UserID']);
293
            Logger::debug('Consent: source: ' . $source);
294
            Logger::debug('Consent: destination: ' . $destination);
295
296
            $userId = self::getHashedUserID($state['UserID'], $source);
297
            $targetedId = self::getTargetedID($state['UserID'], $source, $destination);
298
            $attributeSet = self::getAttributeHash($attributes, $this->includeValues);
299
300
            Logger::debug(
301
                'Consent: hasConsent() [' . $userId . '|' . $targetedId . '|' . $attributeSet . ']'
302
            );
303
304
            try {
305
                if ($this->store->hasConsent($userId, $targetedId, $attributeSet)) {
306
                    // Consent already given
307
                    Logger::stats('consent found');
308
                    Stats::log('consent:found', $statsData);
309
                    return;
310
                }
311
312
                Logger::stats('consent notfound');
313
                Stats::log('consent:notfound', $statsData);
314
315
                $state['consent:store'] = $this->store;
316
                $state['consent:store.userId'] = $userId;
317
                $state['consent:store.destination'] = $targetedId;
318
                $state['consent:store.attributeSet'] = $attributeSet;
319
            } catch (\Exception $e) {
320
                Logger::error('Consent: Error reading from storage: ' . $e->getMessage());
321
                Logger::stats('Consent failed');
322
                Stats::log('consent:failed', $statsData);
323
            }
324
        } else {
325
            Logger::stats('consent nostorage');
326
            Stats::log('consent:nostorage', $statsData);
327
        }
328
329
        $state['consent:focus'] = $this->focus;
330
        $state['consent:checked'] = $this->checked;
331
        $state['consent:hiddenAttributes'] = $this->hiddenAttributes;
332
        $state['consent:noconsentattributes'] = $this->noconsentattributes;
333
        $state['consent:showNoConsentAboutService'] = $this->showNoConsentAboutService;
334
335
        // user interaction necessary. Throw exception on isPassive request
336
        if (isset($state['isPassive']) && $state['isPassive'] === true) {
337
            Stats::log('consent:nopassive', $statsData);
338
            throw new Module\saml\Error\NoPassive(
339
                \SAML2\Constants::STATUS_REQUESTER,
340
                'Unable to give consent on passive request.'
341
            );
342
        }
343
344
        // Save state and redirect
345
        $id = \SimpleSAML\Auth\State::saveState($state, 'consent:request');
346
        $url = Module::getModuleURL('consent/getconsent.php');
347
        Utils\HTTP::redirectTrustedURL($url, ['StateId' => $id]);
348
    }
349
350
351
    /**
352
     * Generate a unique identifier of the user.
353
     *
354
     * @param string $userid The user id.
355
     * @param string $source The source id.
356
     *
357
     * @return string SHA1 of the user id, source id and salt.
358
     */
359
    public static function getHashedUserID(string $userid, string $source): string
360
    {
361
        return hash('sha1', $userid . '|' . Utils\Config::getSecretSalt() . '|' . $source);
362
    }
363
364
365
    /**
366
     * Generate a unique targeted identifier.
367
     *
368
     * @param string $userid The user id.
369
     * @param string $source The source id.
370
     * @param string $destination The destination id.
371
     *
372
     * @return string SHA1 of the user id, source id, destination id and salt.
373
     */
374
    public static function getTargetedID(string $userid, string $source, string $destination): string
375
    {
376
        return hash('sha1', $userid . '|' . Utils\Config::getSecretSalt() . '|' . $source . '|' . $destination);
377
    }
378
379
380
    /**
381
     * Generate unique identifier for attributes.
382
     *
383
     * Create a hash value for the attributes that changes when attributes are added or removed. If the attribute
384
     * values are included in the hash, the hash will change if the values change.
385
     *
386
     * @param array  $attributes The attributes.
387
     * @param bool   $includeValues Whether or not to include the attribute value in the generation of the hash.
388
     *
389
     * @return string SHA1 of the user id, source id, destination id and salt.
390
     */
391
    public static function getAttributeHash(array $attributes, bool $includeValues = false): string
392
    {
393
        if ($includeValues) {
394
            foreach ($attributes as &$values) {
395
                sort($values);
396
            }
397
            ksort($attributes);
398
            $hashBase = serialize($attributes);
399
        } else {
400
            $names = array_keys($attributes);
401
            sort($names);
402
            $hashBase = implode('|', $names);
403
        }
404
        return hash('sha1', $hashBase);
405
    }
406
}
407