LoginController::handleScope()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 9
c 1
b 0
f 0
dl 0
loc 21
rs 9.9666
cc 3
nc 3
nop 1
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\casserver\Controller;
6
7
use RuntimeException;
8
use SimpleSAML\Auth\ProcessingChain;
9
use SimpleSAML\Auth\Simple;
10
use SimpleSAML\Configuration;
11
use SimpleSAML\HTTP\RunnableResponse;
12
use SimpleSAML\Logger;
13
use SimpleSAML\Module;
14
use SimpleSAML\Module\casserver\Cas\AttributeExtractor;
15
use SimpleSAML\Module\casserver\Cas\Factories\ProcessingChainFactory;
16
use SimpleSAML\Module\casserver\Cas\Factories\TicketFactory;
17
use SimpleSAML\Module\casserver\Cas\Protocol\Cas20;
18
use SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder;
19
use SimpleSAML\Module\casserver\Cas\ServiceValidator;
20
use SimpleSAML\Module\casserver\Cas\Ticket\TicketStore;
21
use SimpleSAML\Module\casserver\Controller\Traits\TicketValidatorTrait;
22
use SimpleSAML\Module\casserver\Controller\Traits\UrlTrait;
23
use SimpleSAML\Session;
24
use SimpleSAML\Utils;
25
use SimpleSAML\XHTML\Template;
26
use Symfony\Component\HttpFoundation\Request;
27
use Symfony\Component\HttpFoundation\Response;
28
use Symfony\Component\HttpKernel\Attribute\AsController;
29
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
30
31
use function http_build_query;
32
use function in_array;
33
use function var_export;
34
35
#[AsController]
36
class LoginController
37
{
38
    use UrlTrait;
39
    use TicketValidatorTrait;
40
41
    /** @var \SimpleSAML\Logger */
42
    protected Logger $logger;
43
44
    /** @var \SimpleSAML\Configuration */
45
    protected Configuration $casConfig;
46
47
    /** @var \SimpleSAML\Module\casserver\Cas\Factories\TicketFactory */
48
    protected TicketFactory $ticketFactory;
49
50
    /** @var \SimpleSAML\Auth\Simple  */
51
    protected Simple $authSource;
52
53
    /** @var \SimpleSAML\Utils\HTTP */
54
    protected Utils\HTTP $httpUtils;
55
56
    /** @var \SimpleSAML\Module\casserver\Cas\Protocol\Cas20 */
57
    protected Cas20 $cas20Protocol;
58
59
    /** @var \SimpleSAML\Module\casserver\Cas\Ticket\TicketStore */
60
    protected TicketStore $ticketStore;
61
62
    /** @var \SimpleSAML\Module\casserver\Cas\ServiceValidator */
63
    protected ServiceValidator $serviceValidator;
64
65
    /** @var string[] */
66
    protected array $idpList;
67
68
    /** @var string|null */
69
    protected ?string $authProcId = null;
70
71
    /** @var string[] */
72
    protected array $postAuthUrlParameters = [];
73
74
    /** @var string[] */
75
    private const DEBUG_MODES = ['true', 'samlValidate'];
76
77
    /** @var \SimpleSAML\Module\casserver\Cas\AttributeExtractor */
78
    protected AttributeExtractor $attributeExtractor;
79
80
    /** @var \SimpleSAML\Module\casserver\Cas\Protocol\SamlValidateResponder */
81
    private SamlValidateResponder $samlValidateResponder;
82
83
    /**
84
     * @param \SimpleSAML\Configuration $sspConfig
85
     * @param \SimpleSAML\Configuration|null $casConfig
86
     * @param \SimpleSAML\Auth\Simple|null $source
87
     * @param \SimpleSAML\Utils\HTTP|null $httpUtils
88
     *
89
     * @throws \Exception
90
     */
91
    public function __construct(
92
        private readonly Configuration $sspConfig,
93
        // Facilitate testing
94
        ?Configuration $casConfig = null,
95
        ?Simple $source = null,
96
        ?Utils\HTTP $httpUtils = null,
97
    ) {
98
        $this->casConfig = ($casConfig === null || $casConfig === $sspConfig)
99
            ? Configuration::getConfig('module_casserver.php') : $casConfig;
100
        // Saml Validate Responsder
101
        $this->samlValidateResponder = new SamlValidateResponder();
102
        // Service Validator needs the generic casserver configuration.
103
        $this->serviceValidator = new ServiceValidator($this->casConfig);
104
        $this->authSource = $source ?? new Simple($this->casConfig->getValue('authsource'));
105
        $this->httpUtils = $httpUtils ?? new Utils\HTTP();
106
    }
107
108
    /**
109
     *
110
     * @param \Symfony\Component\HttpFoundation\Request $request
111
     * @param bool $renew
112
     * @param bool $gateway
113
     * @param string|null $service
114
     * @param string|null $TARGET  Query parameter name for "service" used by older CAS clients'
115
     * @param string|null $scope
116
     * @param string|null $language
117
     * @param string|null $entityId
118
     * @param string|null $debugMode
119
     * @param string|null $method
120
     *
121
     * @return \SimpleSAML\HTTP\RunnableResponse|\SimpleSAML\XHTML\Template
122
     * @throws \SimpleSAML\Error\ConfigurationError
123
     * @throws \SimpleSAML\Error\NoState
124
     */
125
    public function login(
126
        Request $request,
127
        #[MapQueryParameter] bool $renew = false,
128
        #[MapQueryParameter] bool $gateway = false,
129
        #[MapQueryParameter] ?string $service = null,
130
        #[MapQueryParameter] ?string $TARGET = null,
131
        #[MapQueryParameter] ?string $scope = null,
132
        #[MapQueryParameter] ?string $language = null,
133
        #[MapQueryParameter] ?string $entityId = null,
134
        #[MapQueryParameter] ?string $debugMode = null,
135
        #[MapQueryParameter] ?string $method = null,
136
    ): RunnableResponse|Template {
137
        $forceAuthn = $renew;
138
        $serviceUrl = $service ?? $TARGET ?? null;
139
        $redirect = !(isset($method) && $method === 'POST');
140
141
        // Set initial configurations, or fail
142
        $this->handleServiceConfiguration($serviceUrl);
143
144
        // Instantiate the classes that rely on the override configuration.
145
        // We do not do this in the constructor since we do not have the correct values yet.
146
        $this->instantiateClassDependencies();
147
        $this->handleScope($scope);
148
        $this->handleLanguage($language);
149
150
        // Get the ticket from the session
151
        $session = $this->getSession();
152
        $sessionTicket = $this->ticketStore->getTicket($session->getSessionId());
0 ignored issues
show
Bug introduced by
It seems like $session->getSessionId() can also be of type null; however, parameter $ticketId of SimpleSAML\Module\casser...icketStore::getTicket() does only seem to accept string, 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

152
        $sessionTicket = $this->ticketStore->getTicket(/** @scrutinizer ignore-type */ $session->getSessionId());
Loading history...
153
        $sessionRenewId = $sessionTicket['renewId'] ?? null;
154
        $requestRenewId = $this->getRequestParam($request, 'renewId');
155
156
        // if this parameter is true, single sign-on will be bypassed and authentication will be enforced
157
        $requestForceAuthenticate = $forceAuthn && $sessionRenewId !== $requestRenewId;
158
159
        if ($request->query->has(ProcessingChain::AUTHPARAM)) {
160
            $this->authProcId = $request->query->get(ProcessingChain::AUTHPARAM);
161
        }
162
163
        // Construct the ReturnTo URL
164
        // This will be used to come back from the AuthSource login or from the Processing Chain
165
        $returnToUrl = $this->getReturnUrl($request, $sessionTicket);
166
167
        // renew=true and gateway=true are incompatible → prefer interactive login (disable passive)
168
        if ($gateway && $forceAuthn) {
169
            $gateway = false;
170
        }
171
172
        // Handle passive authentication if service url defined
173
        // Protocol (gateway set): CAS MUST NOT prompt for credentials during this branch.
174
        if ($serviceUrl && $gateway && !$this->authSource->isAuthenticated() && !$requestForceAuthenticate) {
175
            return $this->handleUnauthenticatedGateway(
176
                $serviceUrl,
177
                $entityId,
178
                $returnToUrl,
179
            );
180
        }
181
182
        // Handle interactive authentication
183
        // Protocol: Normal interactive authentication flow (applies when gateway is not in effect).
184
        // Renew semantics: when renew=true, server MUST enforce re-authentication (no SSO reuse).
185
        if (
186
            $requestForceAuthenticate || !$this->authSource->isAuthenticated()
187
        ) {
188
            return $this->handleInteractiveAuthenticate(
189
                forceAuthn: $forceAuthn,
190
                returnToUrl: $returnToUrl,
191
                entityId: $entityId,
192
            );
193
        }
194
195
        // We are Authenticated.
196
197
        $sessionExpiry = $this->authSource->getAuthData('Expire');
198
        // Create a new ticket if we do not have one alreday, or if we are in a forced Authentitcation mode
199
        if (!\is_array($sessionTicket) || $forceAuthn) {
200
            $sessionTicket = $this->ticketFactory->createSessionTicket($session->getSessionId(), $sessionExpiry);
0 ignored issues
show
Bug introduced by
It seems like $session->getSessionId() can also be of type null; however, parameter $sessionId of SimpleSAML\Module\casser...::createSessionTicket() does only seem to accept string, 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

200
            $sessionTicket = $this->ticketFactory->createSessionTicket(/** @scrutinizer ignore-type */ $session->getSessionId(), $sessionExpiry);
Loading history...
201
            $this->ticketStore->addTicket($sessionTicket);
202
        }
203
204
        /* We are done. REDIRECT TO LOGGEDIN */
205
206
        if (!isset($serviceUrl) && $this->authProcId === null) {
207
            $loggedInUrl = Module::getModuleURL('casserver/loggedIn');
208
            return new RunnableResponse(
209
                [$this->httpUtils, 'redirectTrustedURL'],
210
                [$loggedInUrl, $this->postAuthUrlParameters],
211
            );
212
        }
213
214
        // Get the state.
215
        $state = $this->getState();
216
        $state['ReturnTo'] = $returnToUrl;
217
        if ($this->authProcId !== null) {
218
            $state[ProcessingChain::AUTHPARAM] = $this->authProcId;
219
        }
220
221
        // Attribute Handler
222
        $mappedAttributes = $this->attributeExtractor->extractUserAndAttributes($state);
223
        $serviceTicket = $this->ticketFactory->createServiceTicket([
224
            'service' => $serviceUrl,
225
            'forceAuthn' => $forceAuthn,
226
            'userName' => $mappedAttributes['user'],
227
            'attributes' => $mappedAttributes['attributes'],
228
            'proxies' => [],
229
            'sessionId' => $sessionTicket['id'],
230
        ]);
231
        $this->ticketStore->addTicket($serviceTicket);
232
233
        // Check if we are in debug mode.
234
        if ($debugMode !== null && $this->casConfig->getOptionalBoolean('debugMode', false)) {
235
            [$templateName, $statusCode, $DebugModeXmlString] = $this->handleDebugMode(
236
                $request,
237
                $debugMode,
238
                $serviceTicket,
239
            );
240
            $t = new Template($this->sspConfig, (string)$templateName);
241
            $t->data['debugMode'] = $debugMode === 'true' ? 'Default' : $debugMode;
242
            if (!str_contains('error', (string)$templateName)) {
243
                $t->data['DebugModeXml'] = $DebugModeXmlString;
244
            }
245
            $t->data['statusCode'] = $statusCode;
246
            // Return an HTML View that renders the result
247
            return $t;
248
        }
249
250
        // User has SSO or non-interactive auth succeeded → redirect/POST to service WITH a ticket
251
        $ticketName = $this->calculateTicketName($service);
252
        $this->postAuthUrlParameters[$ticketName] = $serviceTicket['id'];
253
254
        // GET
255
        if ($redirect) {
256
            return new RunnableResponse(
257
                [$this->httpUtils, 'redirectTrustedURL'],
258
                [$serviceUrl, $this->postAuthUrlParameters],
259
            );
260
        }
261
262
        // POST
263
        return new RunnableResponse(
264
            [$this->httpUtils, 'submitPOSTData'],
265
            [$serviceUrl, $this->postAuthUrlParameters],
266
        );
267
    }
268
269
    /**
270
     * @param \Symfony\Component\HttpFoundation\Request $request
271
     * @param string|null $debugMode
272
     * @param array $serviceTicket
273
     *
274
     * @return array []
275
     */
276
    public function handleDebugMode(
277
        Request $request,
278
        ?string $debugMode,
279
        array $serviceTicket,
280
    ): array {
281
        // Check if the debugMode is supported
282
        if (!in_array($debugMode, self::DEBUG_MODES, true)) {
283
            return ['casserver:error.twig', Response::HTTP_BAD_REQUEST, 'Invalid/Unsupported Debug Mode'];
284
        }
285
286
        if ($debugMode === 'true') {
287
            // Service validate CAS20
288
            $xmlResponse = $this->validate(
289
                request: $request,
290
                method:  'serviceValidate',
291
                renew:   $request->get('renew', false),
292
                target:  $request->get('target'),
293
                ticket:  $serviceTicket['id'],
294
                service: $request->get('service'),
295
                pgtUrl:  $request->get('pgtUrl'),
296
            );
297
            return ['casserver:validate.twig', $xmlResponse->getStatusCode(), $xmlResponse->getContent()];
298
        }
299
300
        // samlValidate Mode
301
        $samlResponse = $this->samlValidateResponder->convertToSaml($serviceTicket);
302
        return [
303
            'casserver:validate.twig',
304
            Response::HTTP_OK,
305
            (string)$this->samlValidateResponder->wrapInSoap($samlResponse),
306
        ];
307
    }
308
309
    /**
310
     * @return array|null
311
     * @throws \SimpleSAML\Error\NoState
312
     */
313
    public function getState(): ?array
314
    {
315
        // If we come from an authproc filter, we will load the state from the stateId.
316
        // If not, we will get the state from the AuthSource Data
317
318
        return $this->authProcId !== null ?
319
            $this->attributeExtractor->manageState($this->authProcId) :
320
            $this->authSource->getAuthDataArray();
321
    }
322
323
    /**
324
     * Construct the ticket name
325
     *
326
     * @param string|null $service
327
     *
328
     * @return string
329
     */
330
    public function calculateTicketName(?string $service): string
331
    {
332
        $defaultTicketName = $service !== null ? 'ticket' : 'SAMLart';
333
        return $this->casConfig->getOptionalValue('ticketName', $defaultTicketName);
334
    }
335
336
    /**
337
     * @param \Symfony\Component\HttpFoundation\Request $request
338
     * @param array|null $sessionTicket
339
     *
340
     * @return string
341
     */
342
    public function getReturnUrl(Request $request, ?array $sessionTicket): string
343
    {
344
        // Parse the query parameters and return them in an array
345
        $query = $this->parseQueryParameters($request, $sessionTicket);
346
        // Construct the ReturnTo URL
347
        return $this->httpUtils->getSelfURLNoQuery() . '?' . http_build_query($query);
348
    }
349
350
    /**
351
     * @param string|null $serviceUrl
352
     *
353
     * @return void
354
     * @throws \RuntimeException
355
     */
356
    public function handleServiceConfiguration(?string $serviceUrl): void
357
    {
358
        if ($serviceUrl === null) {
359
            return;
360
        }
361
        $serviceCasConfig = $this->serviceValidator->checkServiceURL($this->sanitize($serviceUrl));
0 ignored issues
show
Bug introduced by
Are you sure the assignment to $serviceCasConfig is correct as $this->serviceValidator-...>sanitize($serviceUrl)) targeting SimpleSAML\Module\casser...ator::checkServiceURL() seems to always return null.

This check looks for function or method calls that always return null and whose return value is assigned to a variable.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
$object = $a->getObject();

The method getObject() can return nothing but null, so it makes no sense to assign that value to a variable.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
362
        if (!isset($serviceCasConfig)) {
363
            $message = 'Service parameter provided to CAS server is not listed as a legal service: [service] = ' .
364
                var_export($serviceUrl, true);
365
            Logger::debug('casserver:' . $message);
366
367
            throw new RuntimeException($message);
368
        }
369
370
        // Override the cas configuration to use for this service
371
        $this->casConfig = $serviceCasConfig;
372
    }
373
374
    /**
375
     * @param string|null $language
376
     *
377
     * @return void
378
     */
379
    public function handleLanguage(?string $language): void
380
    {
381
        // If null, do nothing
382
        if ($language === null) {
383
            return;
384
        }
385
386
        $this->postAuthUrlParameters['language'] = $language;
387
    }
388
389
    /**
390
     * @param string|null $scope
391
     *
392
     * @return void
393
     * @throws \RuntimeException
394
     */
395
    public function handleScope(?string $scope): void
396
    {
397
        // If null, do nothing
398
        if ($scope === null) {
399
            return;
400
        }
401
402
        // Get the scopes from the configuration
403
        $scopes = $this->casConfig->getOptionalValue('scopes', []);
404
405
        // Fail
406
        if (!isset($scopes[$scope])) {
407
            $message = 'Scope parameter provided to CAS server is not listed as legal scope: [scope] = ' .
408
                var_export($scope, true);
409
            Logger::debug('casserver:' . $message);
410
411
            throw new RuntimeException($message);
412
        }
413
414
        // Set the idplist from the scopes
415
        $this->idpList = $scopes[$scope];
416
    }
417
418
    /**
419
     * Get the Session
420
     *
421
     * @return \SimpleSAML\Session|null
422
     * @throws \Exception
423
     */
424
    public function getSession(): ?Session
425
    {
426
        return Session::getSessionFromRequest();
427
    }
428
429
    /**
430
     * @return \SimpleSAML\Module\casserver\Cas\Ticket\TicketStore
431
     */
432
    public function getTicketStore(): TicketStore
433
    {
434
        return $this->ticketStore;
435
    }
436
437
    /**
438
     * @return void
439
     * @throws \Exception
440
     */
441
    private function instantiateClassDependencies(): void
442
    {
443
        $this->cas20Protocol = new Cas20($this->casConfig);
444
445
        /* Instantiate ticket factory */
446
        $this->ticketFactory = new TicketFactory($this->casConfig);
447
448
        /* Instantiate ticket store */
449
        $ticketStoreConfig = $this->casConfig->getOptionalValue(
450
            'ticketstore',
451
            ['class' => 'casserver:FileSystemTicketStore'],
452
        );
453
        $ticketStoreClass = Module::resolveClass($ticketStoreConfig['class'], 'Cas\Ticket');
454
455
        // Ticket Store
456
        $this->ticketStore = new $ticketStoreClass($this->casConfig);
457
458
        // Processing Chain Factory
459
        $processingChainFactory = new ProcessingChainFactory($this->casConfig);
460
461
        // Attribute Extractor
462
        $this->attributeExtractor = new AttributeExtractor($this->casConfig, $processingChainFactory);
463
    }
464
465
    /**
466
     * Trigger interactive authentication via the AuthSource.
467
     *
468
     * @param bool        $forceAuthn
469
     * @param string      $returnToUrl
470
     * @param string|null $entityId
471
     *
472
     * @return RunnableResponse
473
     */
474
    private function handleInteractiveAuthenticate(
475
        bool $forceAuthn,
476
        string $returnToUrl,
477
        ?string $entityId,
478
    ): RunnableResponse {
479
        return $this->handleAuthenticate(
480
            forceAuthn: $forceAuthn,
481
            gateway: false,
482
            returnToUrl: $returnToUrl,
483
            entityId: $entityId,
484
        );
485
    }
486
487
    /**
488
     * Handle the gateway flow when the user is NOT authenticated.
489
     * Passive mode is only attempted if 'enable_passive_mode' is enabled in configuration.
490
     *
491
     * Returns: RunnableResponse|null
492
     *  - RunnableResponse for either a passive attempt or a redirect to service without ticket.
493
     *  - null to indicate: proceed with interactive login (non-passive).
494
     */
495
    private function handleUnauthenticatedGateway(
496
        string $serviceUrl,
497
        ?string $entityId,
498
        string $returnToUrl,
499
    ): RunnableResponse {
500
        $passiveAllowed = $this->casConfig->getOptionalBoolean('enable_passive_mode', false);
501
502
        // Passive mode is not enabled by configuration
503
        // CAS MUST redirect to the service URL WITHOUT a ticket parameter.
504
        if (!$passiveAllowed) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $passiveAllowed of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
505
            return new RunnableResponse(
506
                [$this->httpUtils, 'redirectTrustedURL'],
507
                [$serviceUrl, []],
508
            );
509
        }
510
511
        // Passive mode enabled: attempt a passive (non-interactive) authentication.
512
        return $this->handleAuthenticate(
513
            forceAuthn: false,
514
            gateway: true,
515
            returnToUrl: $returnToUrl,
516
            entityId: $entityId,
517
        );
518
    }
519
520
    /**
521
     * Handle authentication request by configuring parameters and triggering login via auth source.
522
     *
523
     * @param bool $forceAuthn Whether to force authentication regardless of existing session
524
     * @param bool $gateway Whether authentication should be passive/non-interactive
525
     * @param string $returnToUrl URL to return to after authentication
526
     * @param string|null $entityId Optional specific IdP entity ID to use
527
     *
528
     * @return RunnableResponse Response containing the login redirect
529
     */
530
    private function handleAuthenticate(
531
        bool $forceAuthn,
532
        bool $gateway,
533
        string $returnToUrl,
534
        ?string $entityId,
535
    ): RunnableResponse {
536
        $params = [
537
            'ForceAuthn' => $forceAuthn,
538
            'isPassive' => $gateway,
539
            'ReturnTo' => $returnToUrl,
540
        ];
541
542
        if (isset($entityId)) {
543
            $params['saml:idp'] = $entityId;
544
        }
545
546
        if (isset($this->idpList)) {
547
            if (sizeof($this->idpList) > 1) {
548
                $params['saml:IDPList'] = $this->idpList;
549
            } else {
550
                $params['saml:idp'] = $this->idpList[0];
551
            }
552
        }
553
554
        return new RunnableResponse(
555
            [$this->authSource, 'login'],
556
            [$params],
557
        );
558
    }
559
}
560