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