LoginController::login()   F
last analyzed

Complexity

Conditions 22
Paths 448

Size

Total Lines 141
Code Lines 70

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 70
c 1
b 0
f 0
dl 0
loc 141
rs 0.7665
cc 22
nc 448
nop 10

How to fix   Long Method    Complexity    Many Parameters   

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:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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