Passed
Push — develop ( d2d0cb...6c46b1 )
by Pieter van der
02:42
created

SecondFactorController::verifySmsSecondFactor()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 76
Code Lines 46

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 46
nc 4
nop 1
dl 0
loc 76
rs 8.867
c 0
b 0
f 0

How to fix   Long Method   

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:

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\SecondFactorService;
40
use Surfnet\StepupGateway\GatewayBundle\Sso2fa\CookieService;
41
use Surfnet\StepupGateway\SamlStepupProviderBundle\Controller\SamlProxyController;
42
use Symfony\Component\Form\FormError;
43
use Symfony\Component\Form\FormInterface;
44
use Symfony\Component\HttpFoundation\RedirectResponse;
45
use Symfony\Component\HttpFoundation\Request;
46
use Symfony\Component\HttpFoundation\Response;
47
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
48
use Symfony\Component\Routing\Attribute\Route;
49
use function is_null;
50
use const FILTER_DEFAULT;
51
use const FILTER_FORCE_ARRAY;
52
53
/**
54
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
55
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
56
 * @SuppressWarnings(PHPMD.TooManyPublicMethods)
57
 */
58
class SecondFactorController extends ContainerController
59
{
60
    public const MODE_SFO = 'sfo';
61
    public const MODE_SSO = 'sso';
62
63
    public function selectSecondFactorForVerificationSso(
64
        Request $request,
65
    ): Response {
66
        return $this->selectSecondFactorForVerification(self::MODE_SSO, $request);
67
    }
68
69
    public function selectSecondFactorForVerificationSfo(
70
        Request $request,
71
    ): Response {
72
        return $this->selectSecondFactorForVerification(self::MODE_SFO, $request);
73
    }
74
75
    /**
76
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
77
     */
78
    public function selectSecondFactorForVerification(
79
        string $authenticationMode,
80
        Request $request,
81
    ): Response|RedirectResponse {
82
        $this->supportsAuthenticationMode($authenticationMode);
83
        $context = $this->getResponseContext($authenticationMode);
84
        $originalRequestId = $context->getInResponseTo();
85
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
86
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
87
        $logger->notice('Determining which second factor to use...');
88
        try {
89
            // Retrieve all requirements to determine the required LoA
90
            $requestedLoa = $context->getRequiredLoa();
91
            $spConfiguredLoas = $context->getServiceProvider()->get('configuredLoas');
92
            $identityNameId = $context->getIdentityNameId();
93
            $normalizedIdpSho = $context->getNormalizedSchacHomeOrganization();
94
            $normalizedUserSho = $this->getStepupService()->getNormalizedUserShoByIdentityNameId($identityNameId);
95
            $requiredLoa = $this
96
                ->getStepupService()
97
                ->resolveHighestRequiredLoa(
98
                    $requestedLoa,
99
                    $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

99
                    /** @scrutinizer ignore-type */ $spConfiguredLoas,
Loading history...
100
                    $normalizedIdpSho,
101
                    $normalizedUserSho,
102
                );
103
        } catch (LoaCannotBeGivenException $e) {
104
            // Log the message of the domain exception, this contains a meaningful message.
105
            $logger->notice($e->getMessage());
106
107
            return $this->forward(
108
                'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
109
                ['authenticationMode' => $authenticationMode],
110
            );
111
        }
112
113
        $logger->notice(sprintf('Determined that the required Loa is "%s"', $requiredLoa));
114
        if ($this->getStepupService()->isIntrinsicLoa($requiredLoa)) {
115
            $this->get('gateway.authentication_logger')->logIntrinsicLoaAuthentication($originalRequestId);
116
117
            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

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

136
                $this->getResponseContext($authenticationMode)->saveSelectedSecondFactor(/** @scrutinizer ignore-type */ $secondFactor);
Loading history...
137
                $this->getResponseContext($authenticationMode)->markSecondFactorVerified();
138
                $this->getResponseContext($authenticationMode)->markVerifiedBySsoOn2faCookie(
139
                    $this->getCookieService()->getCookieFingerprint($request),
140
                );
141
                $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

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

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

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

391
        $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
392
        $context->markSecondFactorVerified();
393
394
        $logger->info(sprintf(
395
            'Marked GSSF "%s" as verified, forwarding to Gateway controller to respond',
396
            $selectedSecondFactor,
397
        ));
398
399
        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

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

456
            $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
457
458
            $logger->info(
459
                sprintf(
460
                    'Marked Yubikey Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
461
                    $selectedSecondFactor,
462
                ),
463
            );
464
465
            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

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

589
                $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
590
591
                $logger->info(
592
                    sprintf(
593
                        'Marked Sms Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
594
                        $selectedSecondFactor,
595
                    ),
596
                );
597
598
                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

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