Passed
Push — master ( f649ee...44a79b )
by Tim
06:00 queued 03:47
created

CAS::casServiceValidate()   B

Complexity

Conditions 8
Paths 9

Size

Total Lines 59
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 8
eloc 28
c 5
b 0
f 0
nc 9
nop 2
dl 0
loc 59
rs 8.4444

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\cas\Auth\Source;
6
7
use DOMDocument;
8
use DOMElement;
9
use Exception;
10
use SimpleSAML\Auth;
11
use SimpleSAML\CAS\Utils\XPath;
12
use SimpleSAML\CAS\XML\AuthenticationFailure;
13
use SimpleSAML\CAS\XML\AuthenticationSuccess as CasAuthnSuccess;
14
use SimpleSAML\CAS\XML\ServiceResponse as CasServiceResponse;
15
use SimpleSAML\Configuration;
16
use SimpleSAML\Logger;
17
use SimpleSAML\Module;
18
use SimpleSAML\Module\ldap\Auth\Ldap;
19
use SimpleSAML\Slate\XML\AuthenticationSuccess as SlateAuthnSuccess;
20
use SimpleSAML\Slate\XML\ServiceResponse as SlateServiceResponse;
21
use SimpleSAML\Utils;
22
use SimpleSAML\XML\Chunk;
23
use SimpleSAML\XML\DOMDocumentFactory;
24
use Symfony\Component\HttpClient\HttpClient;
25
use Symfony\Contracts\HttpClient\HttpClientInterface;
26
27
use function array_merge_recursive;
28
use function preg_split;
29
use function strcmp;
30
use function strval;
31
use function var_export;
32
33
/**
34
 * Authenticate using CAS.
35
 *
36
 * Based on www/auth/login-cas.php by Mads Freek, RUC.
37
 *
38
 * @package SimpleSAMLphp
39
 */
40
41
class CAS extends Auth\Source
42
{
43
    /**
44
     * The string used to identify our states.
45
     */
46
    public const STAGE_INIT = '\SimpleSAML\Module\cas\Auth\Source\CAS.state';
47
48
    /**
49
     * The key of the AuthId field in the state.
50
     */
51
    public const AUTHID = '\SimpleSAML\Module\cas\Auth\Source\CAS.AuthId';
52
53
54
    /**
55
     * @var array<string, mixed> with ldap configuration
56
     */
57
    private array $ldapConfig;
58
59
    /**
60
     * @var array<string, mixed> cas configuration
61
     */
62
    private array $casConfig;
63
64
    /**
65
     * @var string cas chosen validation method
66
     */
67
68
    private string $validationMethod;
69
70
    /**
71
     * @var string cas login method
72
     */
73
    private string $loginMethod;
74
75
    /**
76
     * @var bool flag indicating if slate XML format should be used
77
     */
78
    private bool $useSlate;
79
80
    /**
81
     * HTTP utilities instance for handling redirects and URLs.
82
     */
83
    private Utils\HTTP $httpUtils;
84
85
    /**
86
     * Symfony HTTP client for CAS requests.
87
     */
88
    private HttpClientInterface $httpClient;
89
90
91
    /**
92
     * Constructor for this authentication source.
93
     *
94
     * @param array<mixed> $info  Information about this authentication source.
95
     * @param array<mixed> $config  Configuration.
96
     */
97
    public function __construct(array $info, array $config)
98
    {
99
        // Call the parent constructor first, as required by the interface
100
        parent::__construct($info, $config);
101
102
        $authsources = Configuration::loadFromArray($config);
103
104
        $this->casConfig = (array)$authsources->getValue('cas');
105
        $this->ldapConfig = (array)$authsources->getValue('ldap');
106
107
        if (isset($this->casConfig['serviceValidate'])) {
108
            $this->validationMethod = 'serviceValidate';
109
        } elseif (isset($this->casConfig['validate'])) {
110
            $this->validationMethod = 'validate';
111
        } else {
112
            throw new Exception("validate or serviceValidate not specified");
113
        }
114
115
        if (isset($this->casConfig['login'])) {
116
            $this->loginMethod = $this->casConfig['login'];
117
        } else {
118
            throw new Exception("cas login URL not specified");
119
        }
120
121
        $this->useSlate = $this->casConfig['slate.enabled'] ?? false;
122
    }
123
124
125
    /**
126
     * Initialize HttpClient instance
127
     *
128
     * @param \Symfony\Contracts\HttpClient\HttpClientInterface|null $httpClient Optional HTTP client instance to use
129
     */
130
    protected function initHttpClient(?HttpClientInterface $httpClient = null): void
131
    {
132
        if ($httpClient !== null) {
133
            $this->httpClient = $httpClient;
134
        } else {
135
            $this->httpClient = $this->httpClient ?? HttpClient::create();
136
        }
137
    }
138
139
140
    /**
141
     * Initialize HTTP utilities instance
142
     *
143
     * @param \SimpleSAML\Utils\HTTP|null $httpUtils Optional HTTP utilities instance to use
144
     * @return void
145
     * @deprecated This helper is kept only for the legacy authenticate(array &$state): void
146
     *             flow. Once the Request-based authenticate(Request, array &$state): ?Response
147
     *             API is active in SimpleSAMLphp, this method will be removed and HTTP
148
     *             handling should be done via Symfony responses instead.
149
     */
150
    protected function initHttpUtils(?Utils\HTTP $httpUtils = null): void
151
    {
152
        if ($httpUtils !== null) {
153
            $this->httpUtils = $httpUtils;
154
        } else {
155
            $this->httpUtils = $this->httpUtils ?? new Utils\HTTP();
156
        }
157
    }
158
159
160
    /**
161
     * This the most simple version of validating, this provides only authentication validation
162
     *
163
     * @param string $ticket
164
     * @param string $service
165
     *
166
     * @return array<mixed> username and attributes
167
     */
168
    private function casValidate(string $ticket, string $service): array
169
    {
170
        $this->initHttpClient();
171
172
        $response = $this->httpClient->request('GET', $this->casConfig['validate'], [
173
            'query' => [
174
                'ticket'  => $ticket,
175
                'service' => $service,
176
            ],
177
        ]);
178
179
        $result = $response->getContent();
180
181
        /** @var list<string> $res */
182
        $res = preg_split("/\r?\n/", $result) ?: [];
183
184
        if (strcmp($res[0], "yes") == 0) {
185
            return [$res[1], []];
186
        } else {
187
            throw new Exception("Failed to validate CAS service ticket: $ticket");
188
        }
189
    }
190
191
192
    /**
193
     * Uses the cas service validate, this provides additional attributes
194
     *
195
     * @param string $ticket
196
     * @param string $service
197
     *
198
     * @return array<mixed> username and attributes
199
     */
200
    private function casServiceValidate(string $ticket, string $service): array
201
    {
202
        $this->initHttpClient();
203
204
        $response = $this->httpClient->request('GET', $this->casConfig['serviceValidate'], [
205
            'query' => [
206
                'ticket'  => $ticket,
207
                'service' => $service,
208
            ],
209
        ]);
210
211
        $result = $response->getContent();
212
213
        /** @var string $result */
214
        $dom = DOMDocumentFactory::fromString($result);
215
216
        // In practice that `if (...) return [];` branch is unreachable with the current behavior.
217
        // `DOMDocumentFactory::fromString()`
218
        // PHPStan still flags / cares about it because it only sees
219
        // and has no way to know `null` won’t actually occur here. `DOMElement|null`
220
        if ($dom->documentElement === null) {
221
            return [];
222
        }
223
224
        if ($this->useSlate) {
225
            $serviceResponse = SlateServiceResponse::fromXML($dom->documentElement);
226
        } else {
227
            $serviceResponse = CasServiceResponse::fromXML($dom->documentElement);
228
        }
229
230
        $message = $serviceResponse->getResponse();
231
        if ($message instanceof AuthenticationFailure) {
232
            throw new Exception(sprintf(
233
                "Error when validating CAS service ticket: %s (%s)",
234
                strval($message->getContent()),
235
                strval($message->getCode()),
236
            ));
237
        } elseif ($message instanceof CasAuthnSuccess || $message instanceof SlateAuthnSuccess) {
238
            [$user, $attributes] = $this->parseAuthenticationSuccess($message);
239
240
            // This will only be parsed if i have an attribute query. If the configuration
241
            // array is empty or not set then an empty array will be returned.
242
            $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom);
243
            if (!empty($attributesFromQueryConfiguration)) {
244
              // Overwrite attributes from parseAuthenticationSuccess with configured
245
              // XPath-based attributes, instead of combining them.
246
                foreach ($attributesFromQueryConfiguration as $name => $values) {
247
                  // Ensure a clean, unique list of string values
248
                    $values = array_values(array_unique(array_map('strval', $values)));
249
250
                  // Configuration wins: replace any existing attribute with the same name
251
                    $attributes[$name] = $values;
252
                }
253
            }
254
255
            return [$user, $attributes];
256
        }
257
258
        throw new Exception("Error parsing serviceResponse.");
259
    }
260
261
262
    /**
263
     * Main validation method, redirects to the correct method
264
     * (keeps finalStep clean)
265
     *
266
     * @param string $ticket
267
     * @param string $service
268
     * @return array<mixed> username and attributes
269
     */
270
    protected function casValidation(string $ticket, string $service): array
271
    {
272
        switch ($this->validationMethod) {
273
            case 'validate':
274
                return  $this->casValidate($ticket, $service);
275
            case 'serviceValidate':
276
                return $this->casServiceValidate($ticket, $service);
277
            default:
278
                throw new Exception("validate or serviceValidate not specified");
279
        }
280
    }
281
282
283
    /**
284
     * Called by linkback, to finish validate/ finish logging in.
285
     * @param array<mixed> $state
286
     */
287
    public function finalStep(array &$state): void
288
    {
289
        $ticket = $state['cas:ticket'];
290
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
291
        $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
292
        list($username, $casAttributes) = $this->casValidation($ticket, $service);
293
        $ldapAttributes = [];
294
295
        $config = Configuration::loadFromArray(
296
            $this->ldapConfig,
297
            'Authentication source ' . var_export($this->authId, true),
298
        );
299
        if (!empty($this->ldapConfig['servers'])) {
300
            $ldap = new Ldap(
301
                $config->getString('servers'),
302
                $config->getOptionalBoolean('enable_tls', false),
0 ignored issues
show
Bug introduced by
It seems like $config->getOptionalBoolean('enable_tls', false) can also be of type null; however, parameter $enable_tls of SimpleSAML\Module\ldap\Auth\Ldap::__construct() does only seem to accept boolean, 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

302
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('enable_tls', false),
Loading history...
303
                $config->getOptionalBoolean('debug', false),
0 ignored issues
show
Bug introduced by
It seems like $config->getOptionalBoolean('debug', false) can also be of type null; however, parameter $debug of SimpleSAML\Module\ldap\Auth\Ldap::__construct() does only seem to accept boolean, 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

303
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('debug', false),
Loading history...
304
                $config->getOptionalInteger('timeout', 0),
305
                $config->getOptionalInteger('port', 389),
306
                $config->getOptionalBoolean('referrals', true),
0 ignored issues
show
Bug introduced by
It seems like $config->getOptionalBoolean('referrals', true) can also be of type null; however, parameter $referrals of SimpleSAML\Module\ldap\Auth\Ldap::__construct() does only seem to accept boolean, 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

306
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('referrals', true),
Loading history...
307
            );
308
309
            $ldapAttributes = $ldap->validate($this->ldapConfig, $username);
310
            if ($ldapAttributes === false) {
0 ignored issues
show
introduced by
The condition $ldapAttributes === false is always false.
Loading history...
311
                throw new Exception("Failed to authenticate against LDAP-server.");
312
            }
313
        }
314
        $attributes = array_merge_recursive($casAttributes, $ldapAttributes);
315
        $state['Attributes'] = $attributes;
316
    }
317
318
319
    /**
320
     * Log-in using cas
321
     *
322
     * @param array<mixed> &$state  Information about the current authentication.
323
     */
324
    public function authenticate(array &$state): void
325
    {
326
        // We are going to need the authId in order to retrieve this authentication source later
327
        $state[self::AUTHID] = $this->authId;
328
329
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
330
331
        $serviceUrl = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
332
333
        $this->initHttpUtils();
0 ignored issues
show
Deprecated Code introduced by
The function SimpleSAML\Module\cas\Au...ce\CAS::initHttpUtils() has been deprecated: This helper is kept only for the legacy authenticate(array &$state): void flow. Once the Request-based authenticate(Request, array &$state): ?Response API is active in SimpleSAMLphp, this method will be removed and HTTP handling should be done via Symfony responses instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

333
        /** @scrutinizer ignore-deprecated */ $this->initHttpUtils();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
334
        $this->httpUtils->redirectTrustedURL($this->loginMethod, ['service' => $serviceUrl]);
335
    }
336
337
338
    /**
339
     * Log out from this authentication source.
340
     *
341
     * This function should be overridden if the authentication source requires special
342
     * steps to complete a logout operation.
343
     *
344
     * If the logout process requires a redirect, the state should be saved. Once the
345
     * logout operation is completed, the state should be restored, and completeLogout
346
     * should be called with the state. If this operation can be completed without
347
     * showing the user a page, or redirecting, this function should return.
348
     *
349
     * @param array<mixed> &$state  Information about the current logout operation.
350
     */
351
    public function logout(array &$state): void
352
    {
353
        $logoutUrl = $this->casConfig['logout'];
354
355
        Auth\State::deleteState($state);
356
357
        // we want cas to log us out
358
        $this->initHttpUtils();
0 ignored issues
show
Deprecated Code introduced by
The function SimpleSAML\Module\cas\Au...ce\CAS::initHttpUtils() has been deprecated: This helper is kept only for the legacy authenticate(array &$state): void flow. Once the Request-based authenticate(Request, array &$state): ?Response API is active in SimpleSAMLphp, this method will be removed and HTTP handling should be done via Symfony responses instead. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

358
        /** @scrutinizer ignore-deprecated */ $this->initHttpUtils();

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
359
        $this->httpUtils->redirectTrustedURL($logoutUrl);
360
    }
361
362
363
    /**
364
     * Parse a CAS AuthenticationSuccess into a flat associative array.
365
     *
366
     * Rules:
367
     * - 'user' => content
368
     * - For each attribute element (Chunk):
369
     *   - If prefix is 'cas' or empty => key is localName
370
     *   - Else => key is "prefix:localName"
371
     *   - Value is the element's textContent
372
     *   - If multiple values for the same key, collect into array
373
     *
374
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
375
     *        The authentication success message to parse
376
     * @return array{
0 ignored issues
show
Documentation Bug introduced by
The doc comment array{ at position 2 could not be parsed: the token is null at position 2.
Loading history...
377
     *   0: \SimpleSAML\XMLSchema\Type\Interface\ValueTypeInterface,
378
     *   1: array<string, list<string>>
379
     * }
380
     */
381
    private function parseAuthenticationSuccess(CasAuthnSuccess|SlateAuthnSuccess $message): array
382
    {
383
        /** @var array<string, list<string>> $result */
384
        $result = [];
385
386
        // user -> content
387
        $user = $message->getUser()->getContent();
388
389
        // attributes -> elements (array of SimpleSAML\XML\Chunk)
390
        $attributes = $message->getAttributes();
391
        /** @var list<\SimpleSAML\XML\Chunk> $elements */
392
        $elements = $attributes->getElements();
393
394
        foreach ($elements as $chunk) {
395
            // Safely extract localName, prefix, and DOMElement from the Chunk
396
            $localName = $chunk->getLocalName();
397
            $prefix = $chunk->getPrefix();
398
            // DOMElement carrying the actual text content
399
            $xmlElement = $chunk->getXML();
400
401
            if (!$localName) {
402
                continue; // skip malformed entries
403
            }
404
405
            // Key selection rule
406
            $key = ($prefix === '' || $prefix === 'cas')
407
                ? $localName
408
                : ($prefix . ':' . $localName);
409
410
            $value = trim($xmlElement->textContent ?? '');
411
412
            // Collect values (single or multi)
413
            $result[$key] ??= [];
414
            $result[$key][] = $value;
415
        }
416
417
        // (DOMElement instances under cas:authenticationSuccess, outside cas:attributes)
418
        $this->parseAuthenticationSuccessMetadata($message, $result);
419
420
        return [$user, $result];
421
    }
422
423
424
    /**
425
     * Parse metadata elements from AuthenticationSuccess message and add them to attributes array
426
     *
427
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
428
     *        The authentication success message
429
     * @param array<string,list<string>> &$attributes Reference to attributes array to update
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string,list<string>> at position 4 could not be parsed: Expected '>' at position 4, but found 'list'.
Loading history...
430
     * @return void
431
     */
432
    private function parseAuthenticationSuccessMetadata(
433
        CasAuthnSuccess|SlateAuthnSuccess $message,
434
        array &$attributes,
435
    ): void {
436
        if (!method_exists($message, 'getElements')) {
437
            // Either bail out or use a fallback
438
            return;
439
        }
440
441
        $metaElements = $message->getElements();
442
443
        foreach ($metaElements as $element) {
444
            if (!$element instanceof Chunk) {
445
                continue;
446
            }
447
448
            $localName = $element->getLocalName();
449
            $prefix    = $element->getPrefix();
450
451
            if ($localName === '') {
452
                continue;
453
            }
454
455
            // For metadata elements we do NOT special-case 'cas':
456
            // we always use "prefix:localName" when there is a prefix,
457
            // and just localName when there is none.
458
            $key = ($prefix === '')
459
                ? $localName
460
                : ($prefix . ':' . $localName);
461
462
            $value = trim($element->getXML()->textContent ?? '');
463
464
            $attributes[$key] ??= [];
465
            $attributes[$key][] = $value;
466
        }
467
    }
468
469
470
    /**
471
     * Parse metadata attributes from CAS response XML using configured XPath queries
472
     *
473
     * @param \DOMDocument $dom The XML document containing CAS response
474
     * @return array<string,array<string>> Array of metadata attribute names and values
475
     */
476
    private function parseQueryAttributes(DOMDocument $dom): array
477
    {
478
        $root = $dom->documentElement;
479
        if (!$root instanceof DOMElement) {
0 ignored issues
show
introduced by
$root is always a sub-type of DOMElement.
Loading history...
480
            return [];
481
        }
482
483
        $xPath = XPath::getXPath($root, true);
484
485
        $metadata = [];
486
        $casattributes = $this->casConfig['attributes'] ?? null;
487
        if (!is_array($casattributes)) {
488
            return $metadata;
489
        }
490
491
        /** @var list<\DOMElement> $authnNodes */
492
        $authnNodes = XPath::xpQuery($root, 'cas:authenticationSuccess', $xPath);
493
        /** @var \DOMElement|null $authn */
494
        $authn = $authnNodes[0] ?? null;
495
496
        // Some have attributes in the xml - attributes is a list of XPath expressions to get them
497
        foreach ($casattributes as $name => $query) {
498
            $marker = 'cas:authenticationSuccess/';
499
500
            if (isset($query[0]) && $query[0] === '/') {
501
                // Absolute XPath
502
                if (strpos($query, $marker) !== false && $authn instanceof \DOMElement) {
503
                    $originalQuery = $query;
504
                    $query = substr($query, strpos($query, $marker) + strlen($marker));
505
                    Logger::info(sprintf(
506
                        'CAS client: rewriting absolute CAS XPath for "%s" from "%s" to relative "%s"',
507
                        $name,
508
                        $originalQuery,
509
                        $query,
510
                    ));
511
                    $nodes = XPath::xpQuery($authn, $query, $xPath);
512
                } else {
513
                    // Keep absolute; evaluate from document root
514
                    $nodes = XPath::xpQuery($root, $query, $xPath);
515
                }
516
            } else {
517
                // Relative XPath; prefer evaluating under authenticationSuccess if available
518
                $context = $authn instanceof DOMElement ? $authn : $root;
519
                $nodes = XPath::xpQuery($context, $query, $xPath);
520
            }
521
522
            foreach ($nodes as $n) {
523
                $metadata[$name][] = trim($n->textContent);
524
            }
525
526
            Logger::debug(sprintf(
527
                'CAS client: parsed metadata %s => %s',
528
                $name,
529
                json_encode($metadata[$name] ?? []),
530
            ));
531
        }
532
533
        return $metadata;
534
    }
535
}
536