Passed
Push — feature/add-azure-mfa-registra... ( feab5b...3767e0 )
by
unknown
01:55
created

SecondFactorController::getResponseContext()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 8
nc 2
nop 1
dl 0
loc 16
rs 10
c 2
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\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
                // todo: handle sso registration bypass.
159
                if ($authenticationMode === self::MODE_SFO) {
160
                    // - the user does not have an active token
161
162
                    // - a LoA1.5 (i.e. self asserted) authentication is requested
163
                    // - a fallback GSSP is configured
164
                    // - this "fallback" option is enabled for the institution that the user belongs to.
165
                    // - the configured user attribute is present in the AuthnRequest
166
                    // - handle authentication by forwarding it to a designated GSSP using the GSSP protocol instead of returning an error.
167
168
//                    var_dump($requiredLoa);
169
//                    die();
170
171
                    $secondFactor = SecondFactor::create('gssp_fallback', 'azuremfa', '');
172
                    return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
173
174
                }
175
176
                return $this->forward(
177
                    'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven',
178
                    ['authenticationMode' => $authenticationMode],
179
                );
180
            case 1:
181
                $secondFactor = $secondFactorCollection->first();
182
                $logger->notice(sprintf(
183
                    'Found "%d" second factors, using second factor of type "%s"',
184
                    count($secondFactorCollection),
185
                    $secondFactor->secondFactorType,
186
                ));
187
188
                return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode);
189
            default:
190
                return $this->forward(
191
                    'Surfnet\StepupGateway\GatewayBundle\Controller\SecondFactorController::chooseSecondFactor',
192
                    ['authenticationMode' => $authenticationMode, 'secondFactors' => $secondFactorCollection],
193
                );
194
        }
195
    }
196
197
    /**
198
     * The main WAYG screen
199
     * - Shows the token selection screen if you own > 1 token
200
     * - Directly goes to SF auth when identity owns 1 token.
201
     *
202
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
203
     */
204
    #[Route(
205
        path: '/choose-second-factor/{authenticationMode}',
206
        name: 'gateway_verify_second_factor_choose_second_factor',
207
        requirements: ['authenticationMode' => 'sso|sfo'],
208
        methods: ['GET', 'POST']
209
    )]
210
    public function chooseSecondFactor(
211
        Request $request,
212
        string $authenticationMode,
213
    ): Response|RedirectResponse|array {
214
        $this->supportsAuthenticationMode($authenticationMode);
215
        $context = $this->getResponseContext($authenticationMode);
216
        $originalRequestId = $context->getInResponseTo();
217
218
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
219
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
220
        $logger->notice('Ask the user which one of his suitable second factor tokens to use...');
221
222
        try {
223
            // Retrieve all requirements to determine the required LoA
224
            $requestedLoa = $context->getRequiredLoa();
225
            $spConfiguredLoas = $context->getServiceProvider()->get('configuredLoas');
226
227
            $normalizedIdpSho = $context->getNormalizedSchacHomeOrganization();
228
            $normalizedUserSho = $this->getStepupService()->getNormalizedUserShoByIdentityNameId($context->getIdentityNameId());
229
230
            $requiredLoa = $this
231
                ->getStepupService()
232
                ->resolveHighestRequiredLoa(
233
                    $requestedLoa,
234
                    $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

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

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

424
            return $this->forward(/** @scrutinizer ignore-type */ $context->getResponseAction());
Loading history...
425
        }
426
427
            /** @var SecondFactor $secondFactor */
428
        $secondFactor = $this->get('gateway.service.second_factor_service')->findByUuid($selectedSecondFactor);
429
        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...
430
            throw new RuntimeException(
431
                sprintf(
432
                    'Verification of GSSF "%s" succeeded, however that Second Factor no longer exists',
433
                    $selectedSecondFactor
434
                )
435
            );
436
        }
437
438
        $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

438
        $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
439
        $context->markSecondFactorVerified();
440
441
        $logger->info(sprintf(
442
            'Marked GSSF "%s" as verified, forwarding to Gateway controller to respond',
443
            $selectedSecondFactor,
444
        ));
445
446
        return $this->forward($context->getResponseAction());
447
    }
448
449
    #[Route(
450
        path: '/verify-second-factor/{authenticationMode}/yubikey',
451
        name: 'gateway_verify_second_factor_yubikey',
452
        requirements: ['authenticationMode' => 'sso|sfo'],
453
        methods: ['GET', 'POST']
454
    )]
455
    public function verifyYubiKeySecondFactor(Request $request): Response
456
    {
457
        if (!$request->get('authenticationMode', false)) {
458
            throw new RuntimeException('Unable to determine the authentication mode in Yubikey verification action');
459
        }
460
        $authenticationMode = $request->get('authenticationMode');
461
        $this->supportsAuthenticationMode($authenticationMode);
462
        $context = $this->getResponseContext($authenticationMode);
463
        $originalRequestId = $context->getInResponseTo();
464
465
        /** @var \Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger $logger */
466
        $logger = $this->get('surfnet_saml.logger')->forAuthentication($originalRequestId);
467
468
        $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger);
469
470
        $logger->notice('Verifying possession of Yubikey second factor');
471
472
        $command = new VerifyYubikeyOtpCommand();
473
        $command->secondFactorId = $selectedSecondFactor;
474
475
        $form = $this->createForm(VerifyYubikeyOtpType::class, $command)->handleRequest($request);
476
        $cancelForm = $this->buildCancelAuthenticationForm($authenticationMode)->handleRequest($request);
477
478
        if ($form->isSubmitted() && $form->isValid()) {
479
            $result = $this->getStepupService()->verifyYubikeyOtp($command);
480
            if ($result->didOtpVerificationFail()) {
481
                $form->addError(
482
                    new FormError($this->get('translator')->trans('gateway.form.verify_yubikey.otp_verification_failed')),
483
                );
484
485
                // OTP field is rendered empty in the template.
486
                return $this->render(
487
                    '@default/second_factor/verify_yubikey_second_factor.html.twig',
488
                    ['form' => $form->createView(), 'cancelForm' => $cancelForm->createView()],
489
                );
490
            } elseif (!$result->didPublicIdMatch()) {
491
                $form->addError(
492
                    new FormError($this->get('translator')->trans('gateway.form.verify_yubikey.public_id_mismatch')),
493
                );
494
495
                // OTP field is rendered empty in the template.
496
                return $this->render(
497
                    '@default/second_factor/verify_yubikey_second_factor.html.twig',
498
                    ['form' => $form->createView(), 'cancelForm' => $cancelForm->createView()],
499
                );
500
            }
501
502
            $this->getResponseContext($authenticationMode)->markSecondFactorVerified();
503
            $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

503
            $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
504
505
            $logger->info(
506
                sprintf(
507
                    'Marked Yubikey Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
508
                    $selectedSecondFactor,
509
                ),
510
            );
511
512
            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

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

636
                $this->getAuthenticationLogger()->logSecondFactorAuthentication(/** @scrutinizer ignore-type */ $originalRequestId, $authenticationMode);
Loading history...
637
638
                $logger->info(
639
                    sprintf(
640
                        'Marked Sms Second Factor "%s" as verified, forwarding to Saml Proxy to respond',
641
                        $selectedSecondFactor,
642
                    ),
643
                );
644
645
                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

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