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

CAS::logout()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 1
dl 0
loc 9
rs 10
c 0
b 0
f 0
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<mixed> with ldap configuration
54
     */
55
    private array $ldapConfig;
56
57
    /**
58
     * @var array<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
    /**
80
     * Constructor for this authentication source.
81
     *
82
     * @param array<mixed> $info  Information about this authentication source.
83
     * @param array<mixed> $config  Configuration.
84
     */
85
    public function __construct(array $info, array $config)
86
    {
87
        // Call the parent constructor first, as required by the interface
88
        parent::__construct($info, $config);
89
90
        $authsources = Configuration::loadFromArray($config);
91
92
        $this->casConfig = $authsources->getValue('cas');
93
        $this->ldapConfig = $authsources->getValue('ldap');
94
95
        if (isset($this->casConfig['serviceValidate'])) {
96
            $this->validationMethod = 'serviceValidate';
97
        } elseif (isset($this->casConfig['validate'])) {
98
            $this->validationMethod = 'validate';
99
        } else {
100
            throw new Exception("validate or serviceValidate not specified");
101
        }
102
103
        if (isset($this->casConfig['login'])) {
104
            $this->loginMethod = $this->casConfig['login'];
105
        } else {
106
            throw new Exception("cas login URL not specified");
107
        }
108
109
        $this->useSlate = $this->casConfig['slate.enabled'] ?? false;
110
    }
111
112
113
    /**
114
     * This the most simple version of validating, this provides only authentication validation
115
     *
116
     * @param string $ticket
117
     * @param string $service
118
     *
119
     * @return array<mixed> username and attributes
120
     */
121
    private function casValidate(string $ticket, string $service): array
122
    {
123
        $httpUtils = new Utils\HTTP();
124
        $url = $httpUtils->addURLParameters($this->casConfig['validate'], [
125
            'ticket' => $ticket,
126
            'service' => $service,
127
        ]);
128
129
        /** @var string $result */
130
        $result = $httpUtils->fetch($url);
131
132
        /** @var list<array{string, int<0, max>}|string> $res */
133
        $res = preg_split("/\r?\n/", $result);
134
135
        if (strcmp($res[0], "yes") == 0) {
136
            return [$res[1], []];
137
        } else {
138
            throw new Exception("Failed to validate CAS service ticket: $ticket");
139
        }
140
    }
141
142
143
    /**
144
     * Uses the cas service validate, this provides additional attributes
145
     *
146
     * @param string $ticket
147
     * @param string $service
148
     *
149
     * @return array<mixed> username and attributes
150
     */
151
    private function casServiceValidate(string $ticket, string $service): array
152
    {
153
        $httpUtils = new Utils\HTTP();
154
        $url = $httpUtils->addURLParameters(
155
            $this->casConfig['serviceValidate'],
156
            [
157
                'ticket' => $ticket,
158
                'service' => $service,
159
            ],
160
        );
161
        $result = $httpUtils->fetch($url);
162
163
        /** @var string $result */
164
        $dom = DOMDocumentFactory::fromString($result);
165
166
        if ($this->useSlate) {
167
            $serviceResponse = SlateServiceResponse::fromXML($dom->documentElement);
168
        } else {
169
            $serviceResponse = CasServiceResponse::fromXML($dom->documentElement);
170
        }
171
172
        $message = $serviceResponse->getResponse();
173
        if ($message instanceof AuthenticationFailure) {
174
            throw new Exception(sprintf(
175
                "Error when validating CAS service ticket: %s (%s)",
176
                strval($message->getContent()),
177
                strval($message->getCode()),
178
            ));
179
        } elseif ($message instanceof CasAuthnSuccess || $message instanceof SlateAuthnSuccess) {
180
            [$user, $attributes] = $this->parseAuthenticationSuccess($message);
181
182
            // This will only be parsed if i have an attribute query. If the configuration
183
            // array is empty or not set then an empty array will be returned.
184
            $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom);
185
            if (!empty($attributesFromQueryConfiguration)) {
186
              // Overwrite attributes from parseAuthenticationSuccess with configured
187
              // XPath-based attributes, instead of combining them.
188
                foreach ($attributesFromQueryConfiguration as $name => $values) {
189
                  // Ensure a clean, unique list of string values
190
                    $values = array_values(array_unique(array_map('strval', $values)));
191
192
                  // Configuration wins: replace any existing attribute with the same name
193
                    $attributes[$name] = $values;
194
                }
195
            }
196
197
            return [$user, $attributes];
198
        }
199
200
        throw new Exception("Error parsing serviceResponse.");
201
    }
202
203
204
    /**
205
     * Main validation method, redirects to correct method
206
     * (keeps finalStep clean)
207
     *
208
     * @param string $ticket
209
     * @param string $service
210
     * @return array<mixed> username and attributes
211
     */
212
    protected function casValidation(string $ticket, string $service): array
213
    {
214
        switch ($this->validationMethod) {
215
            case 'validate':
216
                return  $this->casValidate($ticket, $service);
217
            case 'serviceValidate':
218
                return $this->casServiceValidate($ticket, $service);
219
            default:
220
                throw new Exception("validate or serviceValidate not specified");
221
        }
222
    }
223
224
225
    /**
226
     * Called by linkback, to finish validate/ finish logging in.
227
     * @param array<mixed> $state
228
     */
229
    public function finalStep(array &$state): void
230
    {
231
        $ticket = $state['cas:ticket'];
232
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
233
        $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
234
        list($username, $casAttributes) = $this->casValidation($ticket, $service);
235
        $ldapAttributes = [];
236
237
        $config = Configuration::loadFromArray(
238
            $this->ldapConfig,
239
            'Authentication source ' . var_export($this->authId, true),
240
        );
241
        if (!empty($this->ldapConfig['servers'])) {
242
            $ldap = new Ldap(
243
                $config->getString('servers'),
244
                $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

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

245
                /** @scrutinizer ignore-type */ $config->getOptionalBoolean('debug', false),
Loading history...
246
                $config->getOptionalInteger('timeout', 0),
247
                $config->getOptionalInteger('port', 389),
248
                $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

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