Passed
Push — feature/add-azure-mfa-registra... ( 39aafb...5b7d89 )
by
unknown
02:14
created

SecondFactorController   D

Complexity

Total Complexity 58

Size/Duplication

Total Lines 702
Duplicated Lines 0 %

Importance

Changes 8
Bugs 0 Features 0
Metric Value
eloc 378
dl 0
loc 702
rs 4.5599
c 8
b 0
f 0
wmc 58

20 Methods

Rating   Name   Duplication   Size   Complexity  
A selectSecondFactorForVerificationSso() 0 4 1
A selectSecondFactorForVerificationSfo() 0 4 1
A gssfVerified() 0 39 3
A cancelAuthentication() 0 8 1
A getGsspFallbackService() 0 3 1
A buildCancelAuthenticationForm() 0 11 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
A selectAndRedirectTo() 0 20 2
B verifyYubiKeySecondFactor() 0 70 6
A getSecondFactorService() 0 3 1
A verifyGssf() 0 49 3
B chooseSecondFactor() 0 122 8
A getResponseContext() 0 16 2
A getSelectedSecondFactor() 0 11 2
B selectSecondFactorForVerification() 0 107 8
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);
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
        )) {
156
            $secondFactor = $this->getGsspFallbackService()->createSecondFactor();
157
            return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
158
        }
159
160
        $secondFactorCollection = $this
161
            ->getStepupService()
162
            ->determineViableSecondFactors(
163
                $context->getIdentityNameId(),
164
                $requiredLoa,
165
                $this->get('gateway.service.whitelist'),
166
            );
167
        switch (count($secondFactorCollection)) {
168
            case 0:
169
                $logger->notice('No second factors can give the determined Loa');
170
                return $this->forward(
171
                    'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
172
                    ['authenticationMode' => $authenticationMode],
173
                );
174
            case 1:
175
                $secondFactor = $secondFactorCollection->first();
176
                $logger->notice(sprintf(
177
                    'Found "%d" second factors, using second factor of type "%s"',
178
                    count($secondFactorCollection),
179
                    $secondFactor->secondFactorType,
180
                ));
181
182
                return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
183
            default:
184
                return $this->forward(
185
                    'Surfnet\StepupGateway\GatewayBundle\Controller\SecondFactorController::chooseSecondFactor',
186
                    ['authenticationMode' => $authenticationMode, 'secondFactors' => $secondFactorCollection],
187
                );
188
        }
189
    }
190
191
    /**
192
     * The main WAYG screen
193
     * - Shows the token selection screen if you own > 1 token
194
     * - Directly goes to SF auth when identity owns 1 token.
195
     *
196
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
197
     */
198
    #[Route(
199
        path: '/choose-second-factor/{authenticationMode}',
200
        name: 'gateway_verify_second_factor_choose_second_factor',
201
        requirements: ['authenticationMode' => 'sso|sfo'],
202
        methods: ['GET', 'POST']
203
    )]
204
    public function chooseSecondFactor(
205
        Request $request,
206
        string $authenticationMode,
207
    ): Response|RedirectResponse|array {
208
        $this->supportsAuthenticationMode($authenticationMode);
209
        $context = $this->getResponseContext($authenticationMode);
210
        $originalRequestId = $context->getInResponseTo();
211
212
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
213
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
214
        $logger->notice('Ask the user which one of his suitable second factor tokens to use...');
215
216
        try {
217
            // Retrieve all requirements to determine the required LoA
218
            $requestedLoa = $context->getRequiredLoa();
219
            $spConfiguredLoas = $context->getServiceProvider()->get('configuredLoas');
220
221
            $normalizedIdpSho = $context->getNormalizedSchacHomeOrganization();
222
            $normalizedUserSho = $this->getStepupService()->getNormalizedUserShoByIdentityNameId($context->getIdentityNameId());
223
224
            $requiredLoa = $this
225
                ->getStepupService()
226
                ->resolveHighestRequiredLoa(
227
                    $requestedLoa,
228
                    $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

228
                    /** @scrutinizer ignore-type */ $spConfiguredLoas,
Loading history...
229
                    $normalizedIdpSho,
230
                    $normalizedUserSho,
231
                );
232
        } catch (LoaCannotBeGivenException $e) {
233
            // Log the message of the domain exception, this contains a meaningful message.
234
            $logger->notice($e->getMessage());
235
236
            return $this->forward('Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven');
237
        }
238
239
        $logger->notice(sprintf('Determined that the required Loa is "%s"', $requiredLoa));
240
241
        $secondFactors = $this
242
            ->getStepupService()
243
            ->determineViableSecondFactors(
244
                $context->getIdentityNameId(),
245
                $requiredLoa,
246
                $this->get('gateway.service.whitelist'),
247
            );
248
249
        $command = new ChooseSecondFactorCommand();
250
        $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...
251
252
        $form = $this
253
            ->createForm(
254
                ChooseSecondFactorType::class,
255
                $command,
256
                [
257
                    'action' => $this->generateUrl(
258
                        'gateway_verify_second_factor_choose_second_factor',
259
                        ['authenticationMode' => $authenticationMode]
260
                    )
261
                ],
262
            )
263
            ->handleRequest($request);
264
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
265
266
        if ($form->isSubmitted() && $form->isValid()) {
267
            $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

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

407
        $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
408
        $context->markSecondFactorVerified();
409
410
        $logger->info(sprintf(
411
            'Marked GSSF "%s" as verified, forwarding to Gateway controller to respond',
412
            $selectedSecondFactor,
413
        ));
414
415
        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

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

472
            $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
473
474
            $logger->info(
475
                sprintf(
476
                    'Marked Yubikey Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
477
                    $selectedSecondFactor,
478
                ),
479
            );
480
481
            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

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

605
                $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
606
607
                $logger->info(
608
                    sprintf(
609
                        'Marked Sms Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
610
                        $selectedSecondFactor,
611
                    ),
612
                );
613
614
                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

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