Passed
Branch master (5599da)
by Tim
21:15 queued 06:52
created

CAS::initHttpClient()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 4
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 6
rs 10
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 string STAGE_INIT = '\SimpleSAML\Module\cas\Auth\Source\CAS.state';
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 46 at column 24
Loading history...
47
48
    /**
49
     * The key of the AuthId field in the state.
50
     */
51
    public const string 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
    private string $validationMethod;
68
69
    /**
70
     * @var string cas login method
71
     */
72
    private string $loginMethod;
73
74
    /**
75
     * @var bool flag indicating if slate XML format should be used
76
     */
77
    private bool $useSlate;
78
79
    /**
80
     * HTTP utilities instance for handling redirects and URLs.
81
     */
82
    private Utils\HTTP $httpUtils;
83
84
    /**
85
     * Symfony HTTP client for CAS requests.
86
     */
87
    private HttpClientInterface $httpClient;
88
89
90
    /**
91
     * Constructor for this authentication source.
92
     *
93
     * @param array<mixed> $info  Information about this authentication source.
94
     * @param array<mixed> $config  Configuration.
95
     */
96
    public function __construct(array $info, array $config)
97
    {
98
        // Call the parent constructor first, as required by the interface
99
        parent::__construct($info, $config);
100
101
        $authsources = Configuration::loadFromArray($config);
102
103
        $this->casConfig = (array)$authsources->getValue('cas');
104
        $this->ldapConfig = (array)$authsources->getValue('ldap');
105
106
        if (isset($this->casConfig['serviceValidate'])) {
107
            $this->validationMethod = 'serviceValidate';
108
        } elseif (isset($this->casConfig['validate'])) {
109
            $this->validationMethod = 'validate';
110
        } else {
111
            throw new Exception("validate or serviceValidate not specified");
112
        }
113
114
        if (isset($this->casConfig['login'])) {
115
            $this->loginMethod = $this->casConfig['login'];
116
        } else {
117
            throw new Exception("cas login URL not specified");
118
        }
119
120
        $this->useSlate = $this->casConfig['slate.enabled'] ?? false;
121
    }
122
123
124
    /**
125
     * Initialize HttpClient instance
126
     *
127
     * @param \Symfony\Contracts\HttpClient\HttpClientInterface|null $httpClient Optional HTTP client instance to use
128
     */
129
    protected function initHttpClient(?HttpClientInterface $httpClient = null): void
130
    {
131
        if ($httpClient !== null) {
132
            $this->httpClient = $httpClient;
133
        } else {
134
            $this->httpClient = $this->httpClient ?? HttpClient::create();
135
        }
136
    }
137
138
139
    /**
140
     * Initialize HTTP utilities instance
141
     *
142
     * @param \SimpleSAML\Utils\HTTP|null $httpUtils Optional HTTP utilities instance to use
143
     * @return void
144
     * @deprecated This helper is kept only for the legacy authenticate(array &$state): void
145
     *             flow. Once the Request-based authenticate(Request, array &$state): ?Response
146
     *             API is active in SimpleSAMLphp, this method will be removed and HTTP
147
     *             handling should be done via Symfony responses instead.
148
     */
149
    protected function initHttpUtils(?Utils\HTTP $httpUtils = null): void
150
    {
151
        if ($httpUtils !== null) {
152
            $this->httpUtils = $httpUtils;
153
        } else {
154
            $this->httpUtils = $this->httpUtils ?? new Utils\HTTP();
155
        }
156
    }
157
158
159
    /**
160
     * This the most simple version of validating, this provides only authentication validation
161
     *
162
     * @param string $ticket
163
     * @param string $service
164
     *
165
     * @return array<mixed> username and attributes
166
     */
167
    private function casValidate(string $ticket, string $service): array
168
    {
169
        $this->initHttpClient();
170
171
        $response = $this->httpClient->request('GET', $this->casConfig['validate'], [
172
            'query' => [
173
                'ticket'  => $ticket,
174
                'service' => $service,
175
            ],
176
        ]);
177
178
        $result = $response->getContent();
179
180
        /** @var list<string> $res */
181
        $res = preg_split("/\r?\n/", $result) ?: [];
182
183
        if (strcmp($res[0], "yes") == 0) {
184
            return [$res[1], []];
185
        } else {
186
            throw new Exception("Failed to validate CAS service ticket: $ticket");
187
        }
188
    }
189
190
191
    /**
192
     * Uses the cas service validate, this provides additional attributes
193
     *
194
     * @param string $ticket
195
     * @param string $service
196
     *
197
     * @return array<mixed> username and attributes
198
     */
199
    private function casServiceValidate(string $ticket, string $service): array
200
    {
201
        $this->initHttpClient();
202
203
        $response = $this->httpClient->request('GET', $this->casConfig['serviceValidate'], [
204
            'query' => [
205
                'ticket'  => $ticket,
206
                'service' => $service,
207
            ],
208
        ]);
209
210
        $result = $response->getContent();
211
212
        /** @var string $result */
213
        $dom = DOMDocumentFactory::fromString($result);
214
215
        // In practice that `if (...) return [];` branch is unreachable with the current behavior.
216
        // `DOMDocumentFactory::fromString()`
217
        // PHPStan still flags / cares about it because it only sees
218
        // and has no way to know `null` won’t actually occur here. `DOMElement|null`
219
        if ($dom->documentElement === null) {
220
            return [];
221
        }
222
223
        if ($this->useSlate) {
224
            $serviceResponse = SlateServiceResponse::fromXML($dom->documentElement);
225
        } else {
226
            $serviceResponse = CasServiceResponse::fromXML($dom->documentElement);
227
        }
228
229
        $message = $serviceResponse->getResponse();
230
        if ($message instanceof AuthenticationFailure) {
231
            throw new Exception(sprintf(
232
                "Error when validating CAS service ticket: %s (%s)",
233
                strval($message->getContent()),
234
                strval($message->getCode()),
235
            ));
236
        } elseif ($message instanceof CasAuthnSuccess || $message instanceof SlateAuthnSuccess) {
237
            [$user, $attributes] = $this->parseAuthenticationSuccess($message);
238
239
            // This will only be parsed if i have an attribute query. If the configuration
240
            // array is empty or not set then an empty array will be returned.
241
            $attributesFromQueryConfiguration = $this->parseQueryAttributes($dom);
242
            if (!empty($attributesFromQueryConfiguration)) {
243
              // Overwrite attributes from parseAuthenticationSuccess with configured
244
              // XPath-based attributes, instead of combining them.
245
                foreach ($attributesFromQueryConfiguration as $name => $values) {
246
                  // Ensure a clean, unique list of string values
247
                    $values = array_values(array_unique(array_map('strval', $values)));
248
249
                  // Configuration wins: replace any existing attribute with the same name
250
                    $attributes[$name] = $values;
251
                }
252
            }
253
254
            return [$user, $attributes];
255
        }
256
257
        throw new Exception("Error parsing serviceResponse.");
258
    }
259
260
261
    /**
262
     * Main validation method, redirects to the correct method
263
     * (keeps finalStep clean)
264
     *
265
     * @param string $ticket
266
     * @param string $service
267
     * @return array<mixed> username and attributes
268
     */
269
    protected function casValidation(string $ticket, string $service): array
270
    {
271
        switch ($this->validationMethod) {
272
            case 'validate':
273
                return  $this->casValidate($ticket, $service);
274
            case 'serviceValidate':
275
                return $this->casServiceValidate($ticket, $service);
276
            default:
277
                throw new Exception("validate or serviceValidate not specified");
278
        }
279
    }
280
281
282
    /**
283
     * Called by linkback, to finish validate/ finish logging in.
284
     * @param array<mixed> $state
285
     */
286
    public function finalStep(array &$state): void
287
    {
288
        $ticket = $state['cas:ticket'];
289
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
290
        $service = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
291
        list($username, $casAttributes) = $this->casValidation($ticket, $service);
292
        $ldapAttributes = [];
293
294
        $config = Configuration::loadFromArray(
295
            $this->ldapConfig,
296
            'Authentication source ' . var_export($this->authId, true),
297
        );
298
        if (!empty($this->ldapConfig['servers'])) {
299
            $ldap = new Ldap(
300
                $config->getString('servers'),
301
                $config->getOptionalBoolean('enable_tls', false),
302
                $config->getOptionalBoolean('debug', false),
303
                $config->getOptionalInteger('timeout', 0),
304
                $config->getOptionalInteger('port', 389),
305
                $config->getOptionalBoolean('referrals', true),
306
            );
307
308
            $ldapAttributes = $ldap->validate($this->ldapConfig, $username);
309
            if ($ldapAttributes === false) {
310
                throw new Exception("Failed to authenticate against LDAP-server.");
311
            }
312
        }
313
        $attributes = array_merge_recursive($casAttributes, $ldapAttributes);
314
        $state['Attributes'] = $attributes;
315
    }
316
317
318
    /**
319
     * Log-in using cas
320
     *
321
     * @param array<mixed> &$state  Information about the current authentication.
322
     */
323
    public function authenticate(array &$state): void
324
    {
325
        // We are going to need the authId in order to retrieve this authentication source later
326
        $state[self::AUTHID] = $this->authId;
327
328
        $stateId = Auth\State::saveState($state, self::STAGE_INIT);
329
330
        $serviceUrl = Module::getModuleURL('cas/linkback.php', ['stateId' => $stateId]);
331
332
        $this->initHttpUtils();
333
        $this->httpUtils->redirectTrustedURL($this->loginMethod, ['service' => $serviceUrl]);
334
    }
335
336
337
    /**
338
     * Log out from this authentication source.
339
     *
340
     * This function should be overridden if the authentication source requires special
341
     * steps to complete a logout operation.
342
     *
343
     * If the logout process requires a redirect, the state should be saved. Once the
344
     * logout operation is completed, the state should be restored, and completeLogout
345
     * should be called with the state. If this operation can be completed without
346
     * showing the user a page, or redirecting, this function should return.
347
     *
348
     * @param array<mixed> &$state  Information about the current logout operation.
349
     */
350
    public function logout(array &$state): void
351
    {
352
        $logoutUrl = $this->casConfig['logout'];
353
354
        Auth\State::deleteState($state);
355
356
        // we want cas to log us out
357
        $this->initHttpUtils();
358
        $this->httpUtils->redirectTrustedURL($logoutUrl);
359
    }
360
361
362
    /**
363
     * Parse a CAS AuthenticationSuccess into a flat associative array.
364
     *
365
     * Rules:
366
     * - 'user' => content
367
     * - For each attribute element (Chunk):
368
     *   - If prefix is 'cas' or empty => key is localName
369
     *   - Else => key is "prefix:localName"
370
     *   - Value is the element's textContent
371
     *   - If multiple values for the same key, collect into array
372
     *
373
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
374
     *        The authentication success message to parse
375
     * @return array{
376
     *   0: \SimpleSAML\XMLSchema\Type\Interface\ValueTypeInterface,
377
     *   1: array<string, list<string>>
378
     * }
379
     */
380
    private function parseAuthenticationSuccess(CasAuthnSuccess|SlateAuthnSuccess $message): array
381
    {
382
        /** @var array<string, list<string>> $result */
383
        $result = [];
384
385
        // user -> content
386
        $user = $message->getUser()->getContent();
387
388
        // attributes -> elements (array of SimpleSAML\XML\Chunk)
389
        $attributes = $message->getAttributes();
390
        /** @var list<\SimpleSAML\XML\Chunk> $elements */
391
        $elements = $attributes->getElements();
392
393
        foreach ($elements as $chunk) {
394
            // Safely extract localName, prefix, and DOMElement from the Chunk
395
            $localName = $chunk->getLocalName();
396
            $prefix = $chunk->getPrefix();
397
            // DOMElement carrying the actual text content
398
            $xmlElement = $chunk->getXML();
399
400
            if (!$localName) {
401
                continue; // skip malformed entries
402
            }
403
404
            // Key selection rule
405
            $key = ($prefix === '' || $prefix === 'cas')
406
                ? $localName
407
                : ($prefix . ':' . $localName);
408
409
            $value = trim($xmlElement->textContent ?? '');
410
411
            // Collect values (single or multi)
412
            $result[$key] ??= [];
413
            $result[$key][] = $value;
414
        }
415
416
        // (DOMElement instances under cas:authenticationSuccess, outside cas:attributes)
417
        $this->parseAuthenticationSuccessMetadata($message, $result);
418
419
        return [$user, $result];
420
    }
421
422
423
    /**
424
     * Parse metadata elements from AuthenticationSuccess message and add them to attributes array
425
     *
426
     * @param \SimpleSAML\CAS\XML\AuthenticationSuccess|\SimpleSAML\Slate\XML\AuthenticationSuccess $message
427
     *        The authentication success message
428
     * @param array<string,list<string>> &$attributes Reference to attributes array to update
429
     * @return void
430
     */
431
    private function parseAuthenticationSuccessMetadata(
432
        CasAuthnSuccess|SlateAuthnSuccess $message,
433
        array &$attributes,
434
    ): void {
435
        if (!method_exists($message, 'getElements')) {
436
            // Either bail out or use a fallback
437
            return;
438
        }
439
440
        $metaElements = $message->getElements();
441
442
        foreach ($metaElements as $element) {
443
            if (!$element instanceof Chunk) {
444
                continue;
445
            }
446
447
            $localName = $element->getLocalName();
448
            $prefix    = $element->getPrefix();
449
450
            if ($localName === '') {
451
                continue;
452
            }
453
454
            // For metadata elements we do NOT special-case 'cas':
455
            // we always use "prefix:localName" when there is a prefix,
456
            // and just localName when there is none.
457
            $key = ($prefix === '')
458
                ? $localName
459
                : ($prefix . ':' . $localName);
460
461
            $value = trim($element->getXML()->textContent ?? '');
462
463
            $attributes[$key] ??= [];
464
            $attributes[$key][] = $value;
465
        }
466
    }
467
468
469
    /**
470
     * Parse metadata attributes from CAS response XML using configured XPath queries
471
     *
472
     * @param \DOMDocument $dom The XML document containing CAS response
473
     * @return array<string,array<string>> Array of metadata attribute names and values
474
     */
475
    private function parseQueryAttributes(DOMDocument $dom): array
476
    {
477
        $root = $dom->documentElement;
478
        if (!$root instanceof DOMElement) {
479
            return [];
480
        }
481
482
        $xPath = XPath::getXPath($root, true);
483
484
        $metadata = [];
485
        $casattributes = $this->casConfig['attributes'] ?? null;
486
        if (!is_array($casattributes)) {
487
            return $metadata;
488
        }
489
490
        /** @var list<\DOMElement> $authnNodes */
491
        $authnNodes = XPath::xpQuery($root, 'cas:authenticationSuccess', $xPath);
492
        /** @var \DOMElement|null $authn */
493
        $authn = $authnNodes[0] ?? null;
494
495
        // Some have attributes in the xml - attributes is a list of XPath expressions to get them
496
        foreach ($casattributes as $name => $query) {
497
            $marker = 'cas:authenticationSuccess/';
498
499
            if (isset($query[0]) && $query[0] === '/') {
500
                // Absolute XPath
501
                if (strpos($query, $marker) !== false && $authn instanceof \DOMElement) {
502
                    $originalQuery = $query;
503
                    $query = substr($query, strpos($query, $marker) + strlen($marker));
504
                    Logger::info(sprintf(
505
                        'CAS client: rewriting absolute CAS XPath for "%s" from "%s" to relative "%s"',
506
                        $name,
507
                        $originalQuery,
508
                        $query,
509
                    ));
510
                    $nodes = XPath::xpQuery($authn, $query, $xPath);
511
                } else {
512
                    // Keep absolute; evaluate from document root
513
                    $nodes = XPath::xpQuery($root, $query, $xPath);
514
                }
515
            } else {
516
                // Relative XPath; prefer evaluating under authenticationSuccess if available
517
                $context = $authn instanceof DOMElement ? $authn : $root;
518
                $nodes = XPath::xpQuery($context, $query, $xPath);
519
            }
520
521
            foreach ($nodes as $n) {
522
                $metadata[$name][] = trim($n->textContent);
523
            }
524
525
            Logger::debug(sprintf(
526
                'CAS client: parsed metadata %s => %s',
527
                $name,
528
                json_encode($metadata[$name] ?? []),
529
            ));
530
        }
531
532
        return $metadata;
533
    }
534
}
535