SecondFactorController   D
last analyzed

Complexity

Total Complexity 58

Size/Duplication

Total Lines 704
Duplicated Lines 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 380
c 7
b 0
f 0
dl 0
loc 704
rs 4.5599
wmc 58

20 Methods

Rating   Name   Duplication   Size   Complexity  
A buildCancelAuthenticationForm() 0 11 1
A selectAndRedirectTo() 0 20 2
A selectSecondFactorForVerificationSso() 0 4 1
A selectSecondFactorForVerificationSfo() 0 4 1
A getSelectedSecondFactor() 0 11 2
A gssfVerified() 0 39 3
A cancelAuthentication() 0 8 1
A getGsspFallbackService() 0 3 1
B verifySmsSecondFactorChallenge() 0 69 7
A getAuthenticationLogger() 0 3 1
A getStepupService() 0 3 1
B verifySmsSecondFactor() 0 76 5
A getCookieService() 0 3 1
B verifyYubiKeySecondFactor() 0 70 6
A getSecondFactorService() 0 3 1
A verifyGssf() 0 49 3
B chooseSecondFactor() 0 122 8
B selectSecondFactorForVerification() 0 109 8
A getResponseContext() 0 16 2
A supportsAuthenticationMode() 0 4 3

How to fix   Complexity   

Complex Class

Complex classes like SecondFactorController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SecondFactorController, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Copyright 2014 SURFnet bv
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 *     http://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18
19
namespace Surfnet\StepupGateway\GatewayBundle\Controller;
20
21
use Psr\Log\LoggerInterface;
22
use Surfnet\StepupBundle\Command\VerifyPossessionOfPhoneCommand;
23
use Surfnet\StepupBundle\Value\PhoneNumber\InternationalPhoneNumber;
24
use Surfnet\StepupBundle\Value\SecondFactorType;
25
use Surfnet\StepupGateway\GatewayBundle\Command\ChooseSecondFactorCommand;
26
use Surfnet\StepupGateway\GatewayBundle\Command\SendSmsChallengeCommand;
27
use Surfnet\StepupGateway\GatewayBundle\Command\VerifyYubikeyOtpCommand;
28
use Surfnet\StepupGateway\GatewayBundle\Container\ContainerController;
29
use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactor;
30
use Surfnet\StepupGateway\GatewayBundle\Exception\InvalidArgumentException;
31
use Surfnet\StepupGateway\GatewayBundle\Exception\LoaCannotBeGivenException;
32
use Surfnet\StepupGateway\GatewayBundle\Exception\RuntimeException;
33
use Surfnet\StepupGateway\GatewayBundle\Form\Type\CancelAuthenticationType;
34
use Surfnet\StepupGateway\GatewayBundle\Form\Type\ChooseSecondFactorType;
35
use Surfnet\StepupGateway\GatewayBundle\Form\Type\SendSmsChallengeType;
36
use Surfnet\StepupGateway\GatewayBundle\Form\Type\VerifySmsChallengeType;
37
use Surfnet\StepupGateway\GatewayBundle\Form\Type\VerifyYubikeyOtpType;
38
use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext;
39
use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactor\SecondFactorInterface;
40
use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService;
41
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\CookieService;
42
use Surfnet\StepupGateway\SamlStepupProviderBundle\Controller\SamlProxyController;
43
use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\GsspFallbackService;
44
use Symfony\Component\Form\FormError;
45
use Symfony\Component\Form\FormInterface;
46
use Symfony\Component\HttpFoundation\RedirectResponse;
47
use Symfony\Component\HttpFoundation\Request;
48
use Symfony\Component\HttpFoundation\Response;
49
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
50
use Symfony\Component\Routing\Attribute\Route;
51
use function is_null;
52
use const FILTER_DEFAULT;
53
use const FILTER_FORCE_ARRAY;
54
55
/**
56
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
57
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
58
 * @SuppressWarnings(PHPMD.TooManyPublicMethods)
59
 */
60
class SecondFactorController extends ContainerController
61
{
62
    public const MODE_SFO = 'sfo';
63
    public const MODE_SSO = 'sso';
64
65
    public function selectSecondFactorForVerificationSso(
66
        Request $request,
67
    ): Response {
68
        return $this->selectSecondFactorForVerification(self::MODE_SSO, $request);
69
    }
70
71
    public function selectSecondFactorForVerificationSfo(
72
        Request $request,
73
    ): Response {
74
        return $this->selectSecondFactorForVerification(self::MODE_SFO, $request);
75
    }
76
77
    /**
78
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
79
     */
80
    public function selectSecondFactorForVerification(
81
        string $authenticationMode,
82
        Request $request,
83
    ): Response|RedirectResponse {
84
        $this->supportsAuthenticationMode($authenticationMode);
85
        $context = $this->getResponseContext($authenticationMode);
86
        $originalRequestId = $context->getInResponseTo();
87
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
88
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
89
        $logger->notice('Determining which second factor to use...');
90
        try {
91
            // Retrieve all requirements to determine the required LoA
92
            $requestedLoa = $context->getRequiredLoa();
93
            $spConfiguredLoas = $context->getServiceProvider()->get('configuredLoas');
94
            $identityNameId = $context->getIdentityNameId();
95
            $normalizedIdpSho = $context->getNormalizedSchacHomeOrganization();
96
            $normalizedUserSho = $this->getStepupService()->getNormalizedUserShoByIdentityNameId($identityNameId);
97
            $requiredLoa = $this
98
                ->getStepupService()
99
                ->resolveHighestRequiredLoa(
100
                    $requestedLoa,
101
                    $spConfiguredLoas,
0 ignored issues
show
Bug introduced by
It seems like $spConfiguredLoas can also be of type null; however, parameter $spConfiguredLoas of Surfnet\StepupGateway\Ga...lveHighestRequiredLoa() does only seem to accept array, 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

101
                    /** @scrutinizer ignore-type */ $spConfiguredLoas,
Loading history...
102
                    $normalizedIdpSho,
103
                    $normalizedUserSho,
104
                );
105
        } catch (LoaCannotBeGivenException $e) {
106
            // Log the message of the domain exception, this contains a meaningful message.
107
            $logger->notice($e->getMessage());
108
109
            return $this->forward(
110
                'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
111
                ['authenticationMode' => $authenticationMode],
112
            );
113
        }
114
115
        $logger->notice(sprintf('Determined that the required Loa is "%s"', $requiredLoa));
116
        if ($this->getStepupService()->isIntrinsicLoa($requiredLoa)) {
117
            $this->get('gateway.authentication_logger')->logIntrinsicLoaAuthentication($originalRequestId, $authenticationMode);
118
119
            return $this->forward($context->getResponseAction());
0 ignored issues
show
Bug introduced by
It seems like $context->getResponseAction() can also be of type null; however, parameter $controller of Symfony\Bundle\Framework...ctController::forward() 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

119
            return $this->forward(/** @scrutinizer ignore-type */ $context->getResponseAction());
Loading history...
120
        }
121
122
        // The preconditions must be met in order to give SSO on 2FA
123
        // 1: AuthNRequest is not force authn. 2: The SP allows SSO on 2FA.
124
        if ($this->getCookieService()->preconditionsAreMet($context)) {
125
            // Now read the SSO cookie
126
            $ssoCookie = $this->getCookieService()->read($request);
127
            // Test if the SSO cookie can satisfy the second factor authentication requirements
128
            if ($this->getCookieService()->maySkipAuthentication($requiredLoa->getLevel(), $identityNameId, $ssoCookie)) {
129
                $logger->notice(
130
                    'Skipping second factor authentication. Required LoA was met by the LoA recorded in the cookie',
131
                    [
132
                        'required-loa' => $requiredLoa->getLevel(),
133
                        'cookie-loa' => $ssoCookie->getLoa(),
134
                    ],
135
                );
136
                // We use the SF from the cookie as the SF that was used for authenticating the second factor authentication
137
                $secondFactor = $this->getSecondFactorService()->findByUuid($ssoCookie->secondFactorId());
138
                $this->getResponseContext($authenticationMode)->saveSelectedSecondFactor($secondFactor);
139
                $this->getResponseContext($authenticationMode)->markSecondFactorVerified();
140
                $this->getResponseContext($authenticationMode)->markVerifiedBySsoOn2faCookie(
141
                    $this->getCookieService()->getCookieFingerprint($request),
142
                );
143
                $this->getAuthenticationLogger()->logSecondFactorAuthentication($originalRequestId, $authenticationMode);
0 ignored issues
show
Bug introduced by
It seems like $originalRequestId can also be of type null; however, parameter $requestId of Surfnet\StepupGateway\Ga...dFactorAuthentication() 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

143
                $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
144
145
                return $this->forward($context->getResponseAction());
146
            }
147
        }
148
149
        // Determine if the GSSP fallback flow is allowed so we can continue without a previous registered token
150
        if ($this->getGsspFallbackService()->determineGsspFallbackNeeded(
151
            $identityNameId,
152
            $authenticationMode,
153
            $requiredLoa,
154
            $this->get('gateway.service.whitelist'),
155
            $logger,
156
            $request->getLocale(),
157
        )) {
158
            $secondFactor = $this->getGsspFallbackService()->createSecondFactor();
159
            return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
160
        }
161
162
        $secondFactorCollection = $this
163
            ->getStepupService()
164
            ->determineViableSecondFactors(
165
                $context->getIdentityNameId(),
166
                $requiredLoa,
167
                $this->get('gateway.service.whitelist'),
168
            );
169
        switch (count($secondFactorCollection)) {
170
            case 0:
171
                $logger->notice('No second factors can give the determined Loa');
172
                return $this->forward(
173
                    'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
174
                    ['authenticationMode' => $authenticationMode],
175
                );
176
            case 1:
177
                $secondFactor = $secondFactorCollection->first();
178
                $logger->notice(sprintf(
179
                    'Found "%d" second factors, using second factor of type "%s"',
180
                    count($secondFactorCollection),
181
                    $secondFactor->secondFactorType,
182
                ));
183
184
                return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
185
            default:
186
                return $this->forward(
187
                    'Surfnet\StepupGateway\GatewayBundle\Controller\SecondFactorController::chooseSecondFactor',
188
                    ['authenticationMode' => $authenticationMode, 'secondFactors' => $secondFactorCollection],
189
                );
190
        }
191
    }
192
193
    /**
194
     * The main WAYG screen
195
     * - Shows the token selection screen if you own > 1 token
196
     * - Directly goes to SF auth when identity owns 1 token.
197
     *
198
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
199
     */
200
    #[Route(
201
        path: '/choose-second-factor/{authenticationMode}',
202
        name: 'gateway_verify_second_factor_choose_second_factor',
203
        requirements: ['authenticationMode' => 'sso|sfo'],
204
        methods: ['GET', 'POST']
205
    )]
206
    public function chooseSecondFactor(
207
        Request $request,
208
        string $authenticationMode,
209
    ): Response|RedirectResponse|array {
210
        $this->supportsAuthenticationMode($authenticationMode);
211
        $context = $this->getResponseContext($authenticationMode);
212
        $originalRequestId = $context->getInResponseTo();
213
214
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
215
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
216
        $logger->notice('Ask the user which one of his suitable second factor tokens to use...');
217
218
        try {
219
            // Retrieve all requirements to determine the required LoA
220
            $requestedLoa = $context->getRequiredLoa();
221
            $spConfiguredLoas = $context->getServiceProvider()->get('configuredLoas');
222
223
            $normalizedIdpSho = $context->getNormalizedSchacHomeOrganization();
224
            $normalizedUserSho = $this->getStepupService()->getNormalizedUserShoByIdentityNameId($context->getIdentityNameId());
225
226
            $requiredLoa = $this
227
                ->getStepupService()
228
                ->resolveHighestRequiredLoa(
229
                    $requestedLoa,
230
                    $spConfiguredLoas,
0 ignored issues
show
Bug introduced by
It seems like $spConfiguredLoas can also be of type null; however, parameter $spConfiguredLoas of Surfnet\StepupGateway\Ga...lveHighestRequiredLoa() does only seem to accept array, 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

230
                    /** @scrutinizer ignore-type */ $spConfiguredLoas,
Loading history...
231
                    $normalizedIdpSho,
232
                    $normalizedUserSho,
233
                );
234
        } catch (LoaCannotBeGivenException $e) {
235
            // Log the message of the domain exception, this contains a meaningful message.
236
            $logger->notice($e->getMessage());
237
238
            return $this->forward('Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven');
239
        }
240
241
        $logger->notice(sprintf('Determined that the required Loa is "%s"', $requiredLoa));
242
243
        $secondFactors = $this
244
            ->getStepupService()
245
            ->determineViableSecondFactors(
246
                $context->getIdentityNameId(),
247
                $requiredLoa,
248
                $this->get('gateway.service.whitelist'),
249
            );
250
251
        $command = new ChooseSecondFactorCommand();
252
        $command->secondFactors = $secondFactors;
0 ignored issues
show
Documentation Bug introduced by
It seems like $secondFactors of type Doctrine\Common\Collections\Collection is incompatible with the declared type Surfnet\StepupGateway\Ga...e\Entity\SecondFactor[] of property $secondFactors.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
253
254
        $form = $this
255
            ->createForm(
256
                ChooseSecondFactorType::class,
257
                $command,
258
                [
259
                    'action' => $this->generateUrl(
260
                        'gateway_verify_second_factor_choose_second_factor',
261
                        ['authenticationMode' => $authenticationMode]
262
                    )
263
                ],
264
            )
265
            ->handleRequest($request);
266
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
267
268
        if ($form->isSubmitted() && $form->isValid()) {
269
            $buttonName = $form->getClickedButton()->getName();
0 ignored issues
show
Bug introduced by
The method getClickedButton() does not exist on Symfony\Component\Form\FormInterface. It seems like you code against a sub-type of Symfony\Component\Form\FormInterface such as Symfony\Component\Form\Form. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

269
            $buttonName = $form->/** @scrutinizer ignore-call */ getClickedButton()->getName();
Loading history...
270
            $formResults = $request->request->filter(
271
                'gateway_choose_second_factor',
272
                false,
273
                FILTER_DEFAULT,
274
                ['flags' => FILTER_FORCE_ARRAY]
275
            );
276
            if (!isset($formResults[$buttonName])) {
277
                throw new InvalidArgumentException(
278
                    sprintf(
279
                        'Second factor type "%s" could not be found in the posted form results.',
280
                        $buttonName
281
                    )
282
                );
283
            }
284
285
            $secondFactorType = $formResults[$buttonName];
286
287
            // Filter the selected second factor from the array collection
288
            $secondFactorFiltered = $secondFactors->filter(
289
                fn ($secondFactor): bool => $secondFactorType === $secondFactor->secondFactorType,
290
            );
291
292
            if ($secondFactorFiltered->isEmpty()) {
293
                throw new InvalidArgumentException(
294
                    sprintf(
295
                        'Second factor type "%s" could not be found in the collection of available second factors.',
296
                        $secondFactorType
297
                    )
298
                );
299
            }
300
301
            $secondFactor = $secondFactorFiltered->first();
302
303
            $logger->notice(sprintf('User chose "%s" to use as second factor', $secondFactorType));
304
305
            // Forward to action to verify possession of second factor
306
            return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
307
        } elseif ($form->isSubmitted() && !$form->isValid()) {
308
            $form->addError(
309
                new FormError(
310
                    $this->get('translator')
311
                      ->trans('gateway.form.gateway_choose_second_factor.unknown_second_factor_type'),
312
                ),
313
            );
314
        }
315
316
        return $this->render(
317
            '@default/second_factor/choose_second_factor.html.twig',
318
            [
319
                'form' => $form->createView(),
320
                'cancelForm' => $cancelForm->createView(),
321
                'secondFactors' => $secondFactors,
322
            ]
323
        );
324
    }
325
326
    #[Route(
327
        path: '/verify-second-factor/gssf',
328
        name: 'gateway_verify_second_factor_gssf',
329
        methods: ['GET']
330
    )]
331
    public function verifyGssf(Request $request): Response
332
    {
333
        if (!$request->get('authenticationMode', false)) {
334
            throw new RuntimeException('Unable to determine the authentication mode in the GSSP verification action');
335
        }
336
        $authenticationMode = $request->get('authenticationMode');
337
        $this->supportsAuthenticationMode($authenticationMode);
338
        $context = $this->getResponseContext($authenticationMode);
339
340
        $originalRequestId = $context->getInResponseTo();
341
342
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
343
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
344
        $logger->info('Received request to verify GSSF');
345
346
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
347
348
        $logger->info(sprintf(
349
            'Selected GSSF "%s" for verfication, forwarding to Saml handling',
350
            $selectedSecondFactor,
351
        ));
352
353
        /** @var SecondFactorService $secondFactorService */
354
        $secondFactorService = $this->get('gateway.service.second_factor_service');
355
        $secondFactor = $secondFactorService->findByUuid($selectedSecondFactor);
356
        if (!$secondFactor) {
357
            throw new RuntimeException(
358
                sprintf(
359
                    'Requested verification of GSSF "%s", however that Second Factor no longer exists',
360
                    $selectedSecondFactor
361
                )
362
            );
363
        }
364
365
        // Also send the response context service id, as later we need to know if this is regular SSO or SFO authn.
366
        $responseContextServiceId = $context->getResponseContextServiceId();
367
368
        return $this->forward(
369
            SamlProxyController::class . '::sendSecondFactorVerificationAuthnRequest',
370
            [
371
                'provider' => $secondFactor->getSecondFactorType(),
372
                'subjectNameId' => $secondFactor->getSecondFactorIdentifier(),
373
                'responseContextServiceId' => $responseContextServiceId,
374
                'relayState' => $context->getRelayState(),
375
            ],
376
        );
377
    }
378
379
    public function gssfVerified(Request $request): Response
380
    {
381
        $authenticationMode = $request->get('authenticationMode');
382
        $this->supportsAuthenticationMode($authenticationMode);
383
        $context = $this->getResponseContext($authenticationMode);
384
385
        $originalRequestId = $context->getInResponseTo();
386
387
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
388
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
389
        $logger->info('Attempting to mark GSSF as verified');
390
391
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
392
393
        if (!$this->getGsspFallbackService()->isSecondFactorFallback()) {
394
            /** @var SecondFactor $secondFactor */
395
            $secondFactor = $this->get('gateway.service.second_factor_service')->findByUuid($selectedSecondFactor);
396
        } else {
397
            $secondFactor = $this->getGsspFallbackService()->createSecondFactor();
398
        }
399
400
        if (!$secondFactor) {
401
            throw new RuntimeException(
402
                sprintf(
403
                    'Verification of GSSF "%s" succeeded, however that Second Factor no longer exists',
404
                    $selectedSecondFactor
405
                )
406
            );
407
        }
408
409
        $this->getAuthenticationLogger()->logSecondFactorAuthentication($originalRequestId, $authenticationMode);
0 ignored issues
show
Bug introduced by
It seems like $originalRequestId can also be of type null; however, parameter $requestId of Surfnet\StepupGateway\Ga...dFactorAuthentication() 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

409
        $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
410
        $context->markSecondFactorVerified();
411
412
        $logger->info(sprintf(
413
            'Marked GSSF "%s" as verified, forwarding to Gateway controller to respond',
414
            $selectedSecondFactor,
415
        ));
416
417
        return $this->forward($context->getResponseAction());
0 ignored issues
show
Bug introduced by
It seems like $context->getResponseAction() can also be of type null; however, parameter $controller of Symfony\Bundle\Framework...ctController::forward() 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

417
        return $this->forward(/** @scrutinizer ignore-type */ $context->getResponseAction());
Loading history...
418
    }
419
420
    #[Route(
421
        path: '/verify-second-factor/{authenticationMode}/yubikey',
422
        name: 'gateway_verify_second_factor_yubikey',
423
        requirements: ['authenticationMode' => 'sso|sfo'],
424
        methods: ['GET', 'POST']
425
    )]
426
    public function verifyYubiKeySecondFactor(Request $request): Response
427
    {
428
        if (!$request->get('authenticationMode', false)) {
429
            throw new RuntimeException('Unable to determine the authentication mode in Yubikey verification action');
430
        }
431
        $authenticationMode = $request->get('authenticationMode');
432
        $this->supportsAuthenticationMode($authenticationMode);
433
        $context = $this->getResponseContext($authenticationMode);
434
        $originalRequestId = $context->getInResponseTo();
435
436
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
437
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
438
439
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
440
441
        $logger->notice('Verifying possession of Yubikey second factor');
442
443
        $command = new VerifyYubikeyOtpCommand();
444
        $command->secondFactorId = $selectedSecondFactor;
445
446
        $form = $this->createForm(VerifyYubikeyOtpType::class, $command)->handleRequest($request);
447
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
448
449
        if ($form->isSubmitted() && $form->isValid()) {
450
            $result = $this->getStepupService()->verifyYubikeyOtp($command);
451
            if ($result->didOtpVerificationFail()) {
452
                $form->addError(
453
                    new FormError($this->get('translator')->trans('gateway.form.verify_yubikey.otp_verification_failed')),
454
                );
455
456
                // OTP field is rendered empty in the template.
457
                return $this->render(
458
                    '@default/second_factor/verify_yubikey_second_factor.html.twig',
459
                    ['form' => $form->createView(), 'cancelForm' => $cancelForm->createView()],
460
                );
461
            } elseif (!$result->didPublicIdMatch()) {
462
                $form->addError(
463
                    new FormError($this->get('translator')->trans('gateway.form.verify_yubikey.public_id_mismatch')),
464
                );
465
466
                // OTP field is rendered empty in the template.
467
                return $this->render(
468
                    '@default/second_factor/verify_yubikey_second_factor.html.twig',
469
                    ['form' => $form->createView(), 'cancelForm' => $cancelForm->createView()],
470
                );
471
            }
472
473
            $this->getResponseContext($authenticationMode)->markSecondFactorVerified();
474
            $this->getAuthenticationLogger()->logSecondFactorAuthentication($originalRequestId, $authenticationMode);
0 ignored issues
show
Bug introduced by
It seems like $originalRequestId can also be of type null; however, parameter $requestId of Surfnet\StepupGateway\Ga...dFactorAuthentication() 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

474
            $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
475
476
            $logger->info(
477
                sprintf(
478
                    'Marked Yubikey Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
479
                    $selectedSecondFactor,
480
                ),
481
            );
482
483
            return $this->forward($context->getResponseAction());
0 ignored issues
show
Bug introduced by
It seems like $context->getResponseAction() can also be of type null; however, parameter $controller of Symfony\Bundle\Framework...ctController::forward() 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

483
            return $this->forward(/** @scrutinizer ignore-type */ $context->getResponseAction());
Loading history...
484
        }
485
486
        // OTP field is rendered empty in the template.
487
        return $this->render(
488
            '@default/second_factor/verify_yubikey_second_factor.html.twig',
489
            ['form' => $form->createView(), 'cancelForm' => $cancelForm->createView()],
490
        );
491
    }
492
493
    #[Route(
494
        path: '/verify-second-factor/sms/send-challenge',
495
        name: 'gateway_verify_second_factor_sms',
496
        methods: ['GET', 'POST']
497
    )]
498
    public function verifySmsSecondFactor(
499
        Request $request,
500
    ): Response|RedirectResponse {
501
        if (!$request->get('authenticationMode', false)) {
502
            throw new RuntimeException('Unable to determine the authentication mode in the SMS verification action');
503
        }
504
        $authenticationMode = $request->get('authenticationMode');
505
        $this->supportsAuthenticationMode($authenticationMode);
506
        $context = $this->getResponseContext($authenticationMode);
507
        $originalRequestId = $context->getInResponseTo();
508
509
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
510
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
511
512
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
513
514
        $logger->notice('Verifying possession of SMS second factor, preparing to send');
515
516
        $command = new SendSmsChallengeCommand();
517
        $command->secondFactorId = $selectedSecondFactor;
518
519
        $form = $this->createForm(SendSmsChallengeType::class, $command)->handleRequest($request);
520
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
521
522
        $stepupService = $this->getStepupService();
523
        $phoneNumber = InternationalPhoneNumber::fromStringFormat(
524
            $stepupService->getSecondFactorIdentifier($selectedSecondFactor),
525
        );
526
527
        $otpRequestsRemaining = $stepupService->getSmsOtpRequestsRemainingCount($selectedSecondFactor);
528
        $maximumOtpRequests = $stepupService->getSmsMaximumOtpRequestsCount();
529
        $viewVariables = ['otpRequestsRemaining' => $otpRequestsRemaining, 'maximumOtpRequests' => $maximumOtpRequests];
530
531
        if ($form->isSubmitted() && !$form->isValid()) {
532
            return $this->render(
533
                '@default/second_factor/verify_sms_second_factor.html.twig',
534
                array_merge(
535
                    $viewVariables,
536
                    [
537
                        'phoneNumber' => $phoneNumber,
538
                        'form' => $form->createView(),
539
                        'cancelForm' => $cancelForm->createView(),
540
                    ],
541
                )
542
            );
543
        }
544
545
        $logger->notice('Verifying possession of SMS second factor, sending challenge per SMS');
546
547
        if (!$stepupService->sendSmsChallenge($command)) {
548
            $form->addError(
549
                new FormError($this->get('translator')->trans('gateway.form.send_sms_challenge.sms_sending_failed')),
550
            );
551
552
            return $this->render(
553
                '@default/second_factor/verify_sms_second_factor.html.twig',
554
                array_merge(
555
                    $viewVariables,
556
                    [
557
                        'phoneNumber' => $phoneNumber,
558
                        'form' => $form->createView(),
559
                        'cancelForm' => $cancelForm->createView(),
560
                    ],
561
                )
562
            );
563
        }
564
565
        return $this->redirect(
566
            $this->generateUrl(
567
                'gateway_verify_second_factor_sms_verify_challenge',
568
                ['authenticationMode' => $authenticationMode],
569
            ),
570
        );
571
    }
572
573
    #[Route(
574
        path: '/verify-second-factor/sms/verify-challenge',
575
        name: 'gateway_verify_second_factor_sms_verify_challenge',
576
        methods: ['GET', 'POST']
577
    )]
578
    public function verifySmsSecondFactorChallenge(
579
        Request $request,
580
    ): Response|array {
581
        if (!$request->get('authenticationMode', false)) {
582
            throw new RuntimeException('Unable to determine the authentication mode in the SMS challenge action');
583
        }
584
        $authenticationMode = $request->get('authenticationMode');
585
        $this->supportsAuthenticationMode($authenticationMode);
586
        $context = $this->getResponseContext($authenticationMode);
587
        $originalRequestId = $context->getInResponseTo();
588
589
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
590
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
591
592
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
593
594
        $command = new VerifyPossessionOfPhoneCommand();
595
        $form = $this->createForm(VerifySmsChallengeType::class, $command)->handleRequest($request);
596
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
597
598
        if ($form->isSubmitted() && $form->isValid()) {
599
            $logger->notice('Verifying input SMS challenge matches');
600
            $command->secondFactorId = $selectedSecondFactor;
601
            $verification = $this->getStepupService()->verifySmsChallenge($command);
602
603
            if ($verification->wasSuccessful()) {
604
                $this->getStepupService()->clearSmsVerificationState($selectedSecondFactor);
605
606
                $this->getResponseContext($authenticationMode)->markSecondFactorVerified();
607
                $this->getAuthenticationLogger()->logSecondFactorAuthentication($originalRequestId, $authenticationMode);
0 ignored issues
show
Bug introduced by
It seems like $originalRequestId can also be of type null; however, parameter $requestId of Surfnet\StepupGateway\Ga...dFactorAuthentication() 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

607
                $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
608
609
                $logger->info(
610
                    sprintf(
611
                        'Marked Sms Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
612
                        $selectedSecondFactor,
613
                    ),
614
                );
615
616
                return $this->forward($context->getResponseAction());
0 ignored issues
show
Bug introduced by
It seems like $context->getResponseAction() can also be of type null; however, parameter $controller of Symfony\Bundle\Framework...ctController::forward() 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

616
                return $this->forward(/** @scrutinizer ignore-type */ $context->getResponseAction());
Loading history...
617
            } elseif ($verification->didOtpExpire()) {
618
                $logger->notice('SMS challenge expired');
619
                $form->addError(
620
                    new FormError($this->get('translator')->trans('gateway.form.send_sms_challenge.challenge_expired')),
621
                );
622
            } elseif ($verification->wasAttemptedTooManyTimes()) {
623
                $logger->notice('SMS challenge verification was attempted too many times');
624
                $form->addError(
625
                    new FormError($this->get('translator')->trans('gateway.form.send_sms_challenge.too_many_attempts')),
626
                );
627
            } else {
628
                $logger->notice('SMS challenge did not match');
629
                $form->addError(
630
                    new FormError(
631
                        $this->get('translator')->trans('gateway.form.send_sms_challenge.sms_challenge_incorrect'),
632
                    ),
633
                );
634
            }
635
        }
636
637
        return $this->render(
638
            '@default/second_factor/verify_sms_second_factor_challenge.html.twig',
639
            [
640
                'form' => $form->createView(),
641
                'cancelForm' => $cancelForm->createView(),
642
            ],
643
        );
644
    }
645
646
    #[Route(
647
        path: '/authentication/cancel',
648
        name: 'gateway_cancel_authentication',
649
        methods: ['POST']
650
    )]
651
    public function cancelAuthentication(): Response
652
    {
653
        return $this->forward(GatewayController::class . '::sendAuthenticationCancelledByUser');
654
    }
655
656
    /**
657
     * @return \Surfnet\StepupGateway\GatewayBundle\Service\StepupAuthenticationService
658
     */
659
    private function getStepupService()
660
    {
661
        return $this->get('gateway.service.stepup_authentication');
662
    }
663
664
    /**
665
     * @return ResponseContext
666
     */
667
    private function getResponseContext($authenticationMode)
668
    {
669
        // Select the state handler that matches the current authentication mode
670
        $stateHandlerServiceId = match ($authenticationMode) {
671
            self::MODE_SFO => 'gateway.proxy.sfo.state_handler',
672
            self::MODE_SSO => 'gateway.proxy.sso.state_handler',
673
            default => throw new InvalidArgumentException('Invalid authentication mode requested'),
674
        };
675
676
        // We then load the correct state handler service. And retrieve the ResponseContext service id that was set on it
677
        $responseContextServiceId = $this->get($stateHandlerServiceId)->getResponseContextServiceId();
678
        if (is_null($responseContextServiceId)) {
679
            throw new RuntimeException('The RequestContext service id is not set on the state handler %s');
680
        }
681
        // Finally return the ResponseContext
682
        return $this->get($responseContextServiceId);
683
    }
684
685
    /**
686
     * @return \Surfnet\StepupGateway\GatewayBundle\Monolog\Logger\AuthenticationLogger
687
     */
688
    private function getAuthenticationLogger()
689
    {
690
        return $this->get('gateway.authentication_logger');
691
    }
692
693
    private function getCookieService(): CookieService
694
    {
695
        return $this->get('gateway.service.sso_2fa_cookie');
696
    }
697
698
    private function getSecondFactorService(): SecondFactorService
699
    {
700
        return $this->get('gateway.service.second_factor_service');
701
    }
702
703
    private function getGsspFallbackService(): GsspFallbackService
704
    {
705
        return $this->get('second_factor_only.gssp_fallback_service');
706
    }
707
708
    private function getSelectedSecondFactor(ResponseContext $context, LoggerInterface $logger): string
709
    {
710
        $selectedSecondFactor = $context->getSelectedSecondFactor();
711
712
        if (!$selectedSecondFactor) {
713
            $logger->error('Cannot verify possession of an unknown second factor');
714
715
            throw new BadRequestHttpException('Cannot verify possession of an unknown second factor.');
716
        }
717
718
        return $selectedSecondFactor;
719
    }
720
721
    private function selectAndRedirectTo(
722
        SecondFactorInterface $secondFactor,
723
        ResponseContext $context,
724
        $authenticationMode,
725
    ): RedirectResponse {
726
        $context->saveSelectedSecondFactor($secondFactor);
727
728
        $this->getStepupService()->clearSmsVerificationState($secondFactor->getSecondFactorId());
729
730
        $secondFactorTypeService = $this->get('surfnet_stepup.service.second_factor_type');
731
        $secondFactorType = new SecondFactorType($secondFactor->getSecondFactorType());
732
733
        $route = 'gateway_verify_second_factor_';
734
        if ($secondFactorTypeService->isGssf($secondFactorType)) {
735
            $route .= 'gssf';
736
        } else {
737
            $route .= strtolower($secondFactor->secondFactorType);
0 ignored issues
show
Bug introduced by
Accessing secondFactorType on the interface Surfnet\StepupGateway\Ga...r\SecondFactorInterface suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
738
        }
739
740
        return $this->redirect($this->generateUrl($route, ['authenticationMode' => $authenticationMode]));
741
    }
742
743
    /**
744
     * @param string $authenticationMode
745
     */
746
    private function buildCancelAuthenticationForm($authenticationMode): FormInterface
747
    {
748
        $cancelFormAction = $this->generateUrl(
749
            'gateway_cancel_authentication',
750
            ['authenticationMode' => $authenticationMode],
751
        );
752
753
        return $this->createForm(
754
            CancelAuthenticationType::class,
755
            null,
756
            ['action' => $cancelFormAction],
757
        );
758
    }
759
760
    private function supportsAuthenticationMode($authenticationMode): void
761
    {
762
        if (self::MODE_SSO !== $authenticationMode && self::MODE_SFO !== $authenticationMode) {
763
            throw new InvalidArgumentException('Invalid authentication mode requested');
764
        }
765
    }
766
}
767