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