selectSecondFactorForVerificationSfo()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
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(
239
                'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
240
                ['authenticationMode' => $authenticationMode],
241
            );
242
        }
243
244
        $logger->notice(sprintf('Determined that the required Loa is "%s"', $requiredLoa));
245
246
        $secondFactors = $this
247
            ->getStepupService()
248
            ->determineViableSecondFactors(
249
                $context->getIdentityNameId(),
250
                $requiredLoa,
251
                $this->get('gateway.service.whitelist'),
252
            );
253
254
        $command = new ChooseSecondFactorCommand();
255
        $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...
256
257
        $form = $this
258
            ->createForm(
259
                ChooseSecondFactorType::class,
260
                $command,
261
                [
262
                    'action' => $this->generateUrl(
263
                        'gateway_verify_second_factor_choose_second_factor',
264
                        ['authenticationMode' => $authenticationMode]
265
                    )
266
                ],
267
            )
268
            ->handleRequest($request);
269
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
270
271
        if ($form->isSubmitted() && $form->isValid()) {
272
            $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\Flow\FormFlowInterface or 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

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

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

382
    public function gssfVerified(/** @scrutinizer ignore-unused */ Request $request, string $authenticationMode): Response

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
383
    {
384
        $this->supportsAuthenticationMode($authenticationMode);
385
        $context = $this->getResponseContext($authenticationMode);
386
387
        $originalRequestId = $context->getInResponseTo();
388
389
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
390
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
391
        $logger->info('Attempting to mark GSSF as verified');
392
393
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
394
395
        if (!$this->getGsspFallbackService()->isSecondFactorFallback()) {
396
            /** @var SecondFactor $secondFactor */
397
            $secondFactor = $this->get('gateway.service.second_factor_service')->findByUuid($selectedSecondFactor);
398
        } else {
399
            $secondFactor = $this->getGsspFallbackService()->createSecondFactor();
400
        }
401
402
        if (!$secondFactor) {
403
            throw new RuntimeException(
404
                sprintf(
405
                    'Verification of GSSF "%s" succeeded, however that Second Factor no longer exists',
406
                    $selectedSecondFactor
407
                )
408
            );
409
        }
410
411
        $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

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

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

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

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