Passed
Pull Request — master (#7)
by
unknown
02:31
created

CAS::casServiceValidate()   B

Complexity

Conditions 8
Paths 9

Size

Total Lines 54
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 8
eloc 28
c 4
b 0
f 0
nc 9
nop 2
dl 0
loc 54
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
25
use function array_merge_recursive;
26
use function preg_split;
27
use function strcmp;
28
use function strval;
29
use function var_export;
30
31
/**
32
 * Authenticate using CAS.
33
 *
34
 * Based on www/auth/login-cas.php by Mads Freek, RUC.
35
 *
36
 * @package SimpleSAMLphp
37
 */
38
39
class CAS extends Auth\Source
40
{
41
    /**
42
     * The string used to identify our states.
43
     */
44
    public const STAGE_INIT = '\SimpleSAML\Module\cas\Auth\Source\CAS.state';
45
46
    /**
47
     * The key of the AuthId field in the state.
48
     */
49
    public const AUTHID = '\SimpleSAML\Module\cas\Auth\Source\CAS.AuthId';
50
51
52
    /**
53
     * @var array<string, mixed> with ldap configuration
54
     */
55
    private array $ldapConfig;
56
57
    /**
58
     * @var array<string, mixed> cas configuration
59
     */
60
    private array $casConfig;
61
62
    /**
63
     * @var string cas chosen validation method
64
     */
65
66
    private string $validationMethod;
67
68
    /**
69
     * @var string cas login method
70
     */
71
    private string $loginMethod;
72
73
    /**
74
     * @var bool flag indicating if slate XML format should be used
75
     */
76
    private bool $useSlate;
77
78
    /**
79
     * HTTP utility class for making requests and handling redirects.
80
     * @var \SimpleSAML\Utils\HTTP
81
     */
82
    private Utils\HTTP $httpUtils;
83
84
85
    /**
86
     * Constructor for this authentication source.
87
     *
88
     * @param array<mixed> $info  Information about this authentication source.
89
     * @param array<mixed> $config  Configuration.
90
     */
91
    public function __construct(array $info, array $config)
92
    {
93
        // Call the parent constructor first, as required by the interface
94
        parent::__construct($info, $config);
95
96
        $authsources = Configuration::loadFromArray($config);
97
98
        $this->casConfig = (array)$authsources->getValue('cas');
99
        $this->ldapConfig = (array)$authsources->getValue('ldap');
100
101
        if (isset($this->casConfig['serviceValidate'])) {
102
            $this->validationMethod = 'serviceValidate';
103
        } elseif (isset($this->casConfig['validate'])) {
104
            $this->validationMethod = 'validate';
105
        } else {
106
            throw new Exception("validate or serviceValidate not specified");
107
        }
108
109
        if (isset($this->casConfig['login'])) {
110
            $this->loginMethod = $this->casConfig['login'];
111
        } else {
112
            throw new Exception("cas login URL not specified");
113
        }
114
115
        $this->useSlate = $this->casConfig['slate.enabled'] ?? false;
116
    }
117
118
119
    /**
120
     * Initialize HTTP utilities instance
121
     *
122
     * @param \SimpleSAML\Utils\HTTP|null $httpUtils Optional HTTP utilities instance to use
123
     * @return void
124
     */
125
    protected function initHttpUtils(?Utils\HTTP $httpUtils = null): void
126
    {
127
        if ($httpUtils !== null) {
128
            $this->httpUtils = $httpUtils;
129
        } else {
130
            $this->httpUtils = $this->httpUtils ?? new Utils\HTTP();
131
        }
132
    }
133
134
135
    /**
136
     * This the most simple version of validating, this provides only authentication validation
137
     *
138
     * @param string $ticket
139
     * @param string $service
140
     *
141
     * @return array<mixed> username and attributes
142
     */
143
    private function casValidate(string $ticket, string $service): array
144
    {
145
        $this->initHttpUtils();
146
        $url = $this->httpUtils->addURLParameters($this->casConfig['validate'], [
147
            'ticket' => $ticket,
148
            'service' => $service,
149
        ]);
150
151
        /** @var string $result */
152
        $result = $this->httpUtils->fetch($url);
153
154
        /** @var list<string> $res */
155
        $res = preg_split("/\r?\n/", $result) ?: [];
156
157
        if (strcmp($res[0], "yes") == 0) {
158
            return [$res[1], []];
159
        } else {
160
            throw new Exception("Failed to validate CAS service ticket: $ticket");
161
        }
162
    }
163
164
165
    /**
166
     * Uses the cas service validate, this provides additional attributes
167
     *
168
     * @param string $ticket
169
     * @param string $service
170
     *
171
     * @return array<mixed> username and attributes
172
     */
173
    private function casServiceValidate(string $ticket, string $service): array
174
    {
175
        $this->initHttpUtils();
176
        $url = $this->httpUtils->addURLParameters(
177
            $this->casConfig['serviceValidate'],
178
            [
179
                'ticket' => $ticket,
180
                'service' => $service,
181
            ],
182
        );
183
        $result = $this->httpUtils->fetch($url);
184
185
        /** @var string $result */
186
        $dom = DOMDocumentFactory::fromString($result);
187
188
        if ($dom->documentElement === null) {
189
            return [];
190
        }
191
192
        if ($this->useSlate) {
193
            $serviceResponse = SlateServiceResponse::fromXML($dom->documentElement);
194
        } else {
195
            $serviceResponse = CasServiceResponse::fromXML($dom->documentElement);
196
        }
197
198
        $message = $serviceResponse->getResponse();
199
        if ($message instanceof AuthenticationFailure) {
200
            throw new Exception(sprintf(
201
                "Error when validating CAS service ticket: %s (%s)",
202
                strval($message->getContent()),
203
                strval($message->getCode()),
204
            ));
205
        } elseif ($message instanceof CasAuthnSuccess || $message instanceof SlateAuthnSuccess) {
206
            [$user, $attributes] = $this->parseAuthenticationSuccess($message);
207
208
            // This will only be parsed if i have an attribute query. If the configuration
209
            // array is empty or not set then an empty array will be returned.
210
            $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom);
211
            if (!empty($attributesFromQueryConfiguration)) {
212
              // Overwrite attributes from parseAuthenticationSuccess with configured
213
              // XPath-based attributes, instead of combining them.
214
                foreach ($attributesFromQueryConfiguration as $name => $values) {
215
                  // Ensure a clean, unique list of string values
216
                    $values = array_values(array_unique(array_map('strval', $values)));
217
218
                  // Configuration wins: replace any existing attribute with the same name
219
                    $attributes[$name] = $values;
220
                }
221
            }
222
223
            return [$user, $attributes];
224
        }
225
226
        throw new Exception("Error parsing serviceResponse.");
227
    }
228
229
230
    /**
231
     * Main validation method, redirects to correct method
232
     * (keeps finalStep clean)
233
     *
234
     * @param string $ticket
235
     * @param string $service
236
     * @return array<mixed> username and attributes
237
     */
238
    protected function casValidation(string $ticket, string $service): array
239
    {
240
        switch ($this->validationMethod) {
241
            case 'validate':
242
                return  $this->casValidate($ticket, $service);
243
            case 'serviceValidate':
244
                return $this->casServiceValidate($ticket, $service);
245
            default:
246
                throw new Exception("validate or serviceValidate not specified");
247
        }
248
    }
249
250
251
    /**
252
     * Called by linkback, to finish validate/ finish logging in.
253
     * @param array<mixed> $state
254
     */
255
    public function finalStep(array &$state): void
256
    {
257
        $ticket = $state['cas:ticket'];
258
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
259
        $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
260
        list($username, $casAttributes) = $this->casValidation($ticket, $service);
261
        $ldapAttributes = [];
262
263
        $config = Configuration::loadFromArray(
264
            $this->ldapConfig,
265
            'Authentication source ' . var_export($this->authId, true),
266
        );
267
        if (!empty($this->ldapConfig['servers'])) {
268
            $ldap = new Ldap(
269
                $config->getString('servers'),
270
                $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

270
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('enable_tls', false),
Loading history...
271
                $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

271
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('debug', false),
Loading history...
272
                $config->getOptionalInteger('timeout', 0),
273
                $config->getOptionalInteger('port', 389),
274
                $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

274
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('referrals', true),
Loading history...
275
            );
276
277
            $ldapAttributes = $ldap->validate($this->ldapConfig, $username);
278
            if ($ldapAttributes === false) {
0 ignored issues
show
introduced by
The condition $ldapAttributes === false is always false.
Loading history...
279
                throw new Exception("Failed to authenticate against LDAP-server.");
280
            }
281
        }
282
        $attributes = array_merge_recursive($casAttributes, $ldapAttributes);
283
        $state['Attributes'] = $attributes;
284
    }
285
286
287
    /**
288
     * Log-in using cas
289
     *
290
     * @param array<mixed> &$state  Information about the current authentication.
291
     */
292
    public function authenticate(array &$state): void
293
    {
294
        // We are going to need the authId in order to retrieve this authentication source later
295
        $state[self::AUTHID] = $this->authId;
296
297
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
298
299
        $serviceUrl = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
300
301
        $this->initHttpUtils();
302
        $this->httpUtils->redirectTrustedURL($this->loginMethod, ['service' => $serviceUrl]);
303
    }
304
305
306
    /**
307
     * Log out from this authentication source.
308
     *
309
     * This function should be overridden if the authentication source requires special
310
     * steps to complete a logout operation.
311
     *
312
     * If the logout process requires a redirect, the state should be saved. Once the
313
     * logout operation is completed, the state should be restored, and completeLogout
314
     * should be called with the state. If this operation can be completed without
315
     * showing the user a page, or redirecting, this function should return.
316
     *
317
     * @param array<mixed> &$state  Information about the current logout operation.
318
     */
319
    public function logout(array &$state): void
320
    {
321
        $logoutUrl = $this->casConfig['logout'];
322
323
        Auth\State::deleteState($state);
324
325
        // we want cas to log us out
326
        $this->initHttpUtils();
327
        $this->httpUtils->redirectTrustedURL($logoutUrl);
328
    }
329
330
331
    /**
332
     * Parse a CAS AuthenticationSuccess into a flat associative array.
333
     *
334
     * Rules:
335
     * - 'user' => content
336
     * - For each attribute element (Chunk):
337
     *   - If prefix is 'cas' or empty => key is localName
338
     *   - Else => key is "prefix:localName"
339
     *   - Value is the element's textContent
340
     *   - If multiple values for the same key, collect into array
341
     *
342
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
343
     *        The authentication success message to parse
344
     * @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...
345
     *   0: \SimpleSAML\XMLSchema\Type\Interface\ValueTypeInterface,
346
     *   1: array<string, list<string>>
347
     * }
348
     */
349
    private function parseAuthenticationSuccess(CasAuthnSuccess|SlateAuthnSuccess $message): array
350
    {
351
        /** @var array<string, list<string>> $result */
352
        $result = [];
353
354
        // user -> content
355
        $user = $message->getUser()->getContent();
356
357
        // attributes -> elements (array of SimpleSAML\XML\Chunk)
358
        $attributes = $message->getAttributes();
359
        /** @var list<\SimpleSAML\XML\Chunk> $elements */
360
        $elements = $attributes->getElements();
361
362
        foreach ($elements as $chunk) {
363
            // Safely extract localName, prefix, and DOMElement from the Chunk
364
            $localName = $chunk->getLocalName();
365
            $prefix = $chunk->getPrefix();
366
            // DOMElement carrying the actual text content
367
            $xmlElement = $chunk->getXML();
368
369
            if (!$localName) {
370
                continue; // skip malformed entries
371
            }
372
373
            // Key selection rule
374
            $key = ($prefix === '' || $prefix === 'cas')
375
                ? $localName
376
                : ($prefix . ':' . $localName);
377
378
            $value = trim($xmlElement->textContent ?? '');
379
380
            // Collect values (single or multi)
381
            $result[$key] ??= [];
382
            $result[$key][] = $value;
383
        }
384
385
        // (DOMElement instances under cas:authenticationSuccess, outside cas:attributes)
386
        $this->parseAuthenticationSuccessMetadata($message, $result);
387
388
        return [$user, $result];
389
    }
390
391
392
    /**
393
     * Parse metadata elements from AuthenticationSuccess message and add them to attributes array
394
     *
395
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
396
     *        The authentication success message
397
     * @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...
398
     * @return void
399
     */
400
    private function parseAuthenticationSuccessMetadata(
401
        CasAuthnSuccess|SlateAuthnSuccess $message,
402
        array &$attributes,
403
    ): void {
404
        if (!method_exists($message, 'getElements')) {
405
            // Either bail out or use a fallback
406
            return;
407
        }
408
409
        $metaElements = $message->getElements();
410
411
        foreach ($metaElements as $element) {
412
            if (!$element instanceof Chunk) {
413
                continue;
414
            }
415
416
            $localName = $element->getLocalName();
417
            $prefix    = $element->getPrefix();
418
419
            if ($localName === '') {
420
                continue;
421
            }
422
423
            // For metadata elements we do NOT special-case 'cas':
424
            // we always use "prefix:localName" when there is a prefix,
425
            // and just localName when there is none.
426
            $key = ($prefix === '')
427
                ? $localName
428
                : ($prefix . ':' . $localName);
429
430
            $value = trim($element->getXML()->textContent ?? '');
431
432
            $attributes[$key] ??= [];
433
            $attributes[$key][] = $value;
434
        }
435
    }
436
437
438
    /**
439
     * Parse metadata attributes from CAS response XML using configured XPath queries
440
     *
441
     * @param \DOMDocument $dom The XML document containing CAS response
442
     * @return array<string,array<string>> Array of metadata attribute names and values
443
     */
444
    private function parseQueryAttributes(DOMDocument $dom): array
445
    {
446
        $root = $dom->documentElement;
447
        if (!$root instanceof DOMElement) {
0 ignored issues
show
introduced by
$root is always a sub-type of DOMElement.
Loading history...
448
            return [];
449
        }
450
451
        $xPath = XPath::getXPath($root, true);
452
453
        $metadata = [];
454
        $casattributes = $this->casConfig['attributes'] ?? null;
455
        if (!is_array($casattributes)) {
456
            return $metadata;
457
        }
458
459
        /** @var list<\DOMElement> $authnNodes */
460
        $authnNodes = XPath::xpQuery($root, 'cas:authenticationSuccess', $xPath);
461
        /** @var \DOMElement|null $authn */
462
        $authn = $authnNodes[0] ?? null;
463
464
        // Some have attributes in the xml - attributes is a list of XPath expressions to get them
465
        foreach ($casattributes as $name => $query) {
466
            $marker = 'cas:authenticationSuccess/';
467
468
            if (isset($query[0]) && $query[0] === '/') {
469
                // Absolute XPath
470
                if (strpos($query, $marker) !== false && $authn instanceof \DOMElement) {
471
                    $originalQuery = $query;
472
                    $query = substr($query, strpos($query, $marker) + strlen($marker));
473
                    Logger::info(sprintf(
474
                        'CAS client: rewriting absolute CAS XPath for "%s" from "%s" to relative "%s"',
475
                        $name,
476
                        $originalQuery,
477
                        $query,
478
                    ));
479
                    $nodes = XPath::xpQuery($authn, $query, $xPath);
480
                } else {
481
                    // Keep absolute; evaluate from document root
482
                    $nodes = XPath::xpQuery($root, $query, $xPath);
483
                }
484
            } else {
485
                // Relative XPath; prefer evaluating under authenticationSuccess if available
486
                $context = $authn instanceof DOMElement ? $authn : $root;
487
                $nodes = XPath::xpQuery($context, $query, $xPath);
488
            }
489
490
            foreach ($nodes as $n) {
491
                $metadata[$name][] = trim($n->textContent);
492
            }
493
494
            Logger::debug(sprintf(
495
                'CAS client: parsed metadata %s => %s',
496
                $name,
497
                json_encode($metadata[$name] ?? []),
498
            ));
499
        }
500
501
        return $metadata;
502
    }
503
}
504