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

SamlProxyController::consumeAssertionAction()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 43
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 28
nc 5
nop 2
dl 0
loc 43
rs 9.1608
c 2
b 0
f 0
1
<?php
2
3
/**
4
 * Copyright 2015 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\SamlStepupProviderBundle\Controller;
20
21
use DateTime;
22
use Exception;
23
use SAML2\Constants;
24
use SAML2\Response as SAMLResponse;
25
use SAML2\XML\saml\Issuer;
26
use Surfnet\SamlBundle\Http\XMLResponse;
27
use Surfnet\SamlBundle\Metadata\MetadataFactory;
28
use Surfnet\StepupGateway\GatewayBundle\Container\ContainerController;
29
use Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController;
30
use Surfnet\StepupGateway\GatewayBundle\Exception\ResponseFailureException;
31
use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext;
32
use Surfnet\StepupGateway\SamlStepupProviderBundle\Exception\InvalidSubjectException;
33
use Surfnet\StepupGateway\SamlStepupProviderBundle\Exception\NotConnectedServiceProviderException;
34
use Surfnet\StepupGateway\SamlStepupProviderBundle\Exception\RuntimeException;
35
use Surfnet\StepupGateway\SamlStepupProviderBundle\Exception\SecondfactorVerificationRequiredException;
36
use Surfnet\StepupGateway\SamlStepupProviderBundle\Provider\Provider;
37
use Surfnet\StepupGateway\SamlStepupProviderBundle\Provider\ProviderRepository;
38
use Surfnet\StepupGateway\SamlStepupProviderBundle\Saml\ProxyResponseFactory;
39
use Surfnet\StepupGateway\SamlStepupProviderBundle\Saml\StateHandler;
40
use Surfnet\StepupGateway\SamlStepupProviderBundle\Service\Gateway\ConsumeAssertionService;
41
use Surfnet\StepupGateway\SamlStepupProviderBundle\Service\Gateway\LoginService;
42
use Surfnet\StepupGateway\SamlStepupProviderBundle\Service\Gateway\SecondFactorVerificationService;
43
use Surfnet\StepupGateway\SecondFactorOnlyBundle\Adfs\ResponseHelper;
44
use Symfony\Component\HttpFoundation\RedirectResponse;
45
use Symfony\Component\HttpFoundation\Request;
46
use Symfony\Component\HttpFoundation\Response;
47
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
48
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
49
use Symfony\Component\Routing\Attribute\Route;
50
51
/**
52
 * Handling of GSSP registration and verification.
53
 *
54
 * See docs/GatewayState.md for a high-level diagram on how this controller
55
 * interacts with outside actors and other parts of Stepup.
56
 *
57
 * Should be refactored, {@see https://www.pivotaltracker.com/story/show/90169776}
58
 *
59
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
60
 * @SuppressWarnings(PHPMD.NPathComplexity)
61
 */
62
class SamlProxyController extends ContainerController
63
{
64
    /**
65
     * Proxy a GSSP authentication request to the remote GSSP SSO endpoint.
66
     *
67
     * The user is about to be sent to the remote GSSP application for
68
     * registration. Verification is not initiated with a SAML AUthnRequest,
69
     * see sendSecondFactorVerificationAuthnRequestAction().
70
     *
71
     * The service provider in this context is SelfService (when registering
72
     * a token) or RA (when vetting a token).
73
     */
74
    #[Route(
75
        path: '/gssp/{provider}/single-sign-on',
76
        name: 'gssp_verify',
77
        methods: ['GET']
78
    )]
79
    public function singleSignOn(
80
        string  $provider,
81
        Request $httpRequest,
82
    ): RedirectResponse {
83
        $provider = $this->getProvider($provider);
84
85
        /** @var \Surfnet\SamlBundle\Http\RedirectBinding $redirectBinding */
86
        $redirectBinding = $this->get('surfnet_saml.http.redirect_binding');
87
        $gsspLoginService = $this->getGsspLoginService();
88
89
        $logger = $this->get('logger');
90
        $logger->notice('Received AuthnRequest, started processing');
91
92
        try {
93
            $proxyRequest = $gsspLoginService->singleSignOn($provider, $httpRequest);
94
        } catch (NotConnectedServiceProviderException) {
95
            throw new AccessDeniedHttpException();
96
        }
97
98
        return $redirectBinding->createResponseFor($proxyRequest);
99
    }
100
101
    /**
102
     * Start a GSSP single sign-on.
103
     *
104
     * The user has selected a second factor token and the token happens to be
105
     * a GSSP token. The SecondFactorController therefor did an internal
106
     * redirect (see SecondFactorController::verifyGssfAction) to this method.
107
     *
108
     * In this method, an authn request is created. This authn request is sent
109
     * directly to the remote GSSP SSO URL, and the response is handled in
110
     * consumeAssertionAction().
111
     */
112
    public function sendSecondFactorVerificationAuthnRequest(
113
        string $provider,
114
        string $subjectNameId,
115
        string $responseContextServiceId,
116
        string $relayState,
117
    ): RedirectResponse {
118
119
        $provider = $this->getProvider($provider);
120
        $gsspSecondFactorVerificationService = $this->getGsspSecondFactorVerificationService();
121
        $authnRequest = $gsspSecondFactorVerificationService->sendSecondFactorVerificationAuthnRequest(
122
            $provider,
123
            $subjectNameId,
124
            $responseContextServiceId,
125
        );
126
        $provider->getStateHandler()->setRelayState($relayState);
127
128
        $redirectBinding = $this->get('surfnet_saml.http.redirect_binding');
129
130
        return $redirectBinding->createResponseFor($authnRequest);
131
    }
132
133
    /**
134
     * Process an assertion received from the remote GSSP application.
135
     *
136
     * The GSSP application sent an assertion back to the gateway. When
137
     * successful, the user is sent back to:
138
     *
139
     *  1. in case of registration: back to the originating SP (SelfService or RA)
140
     *  2. in case of verification: internal redirect to SecondFactorController
141
     *
142
     * @throws Exception
143
     */
144
    #[Route(
145
        path: '/gssp/{provider}/consume-assertion',
146
        name: 'gssp_consume_assertion',
147
        methods: ['POST']
148
    )]
149
    public function consumeAssertion(string $provider, Request $httpRequest): Response
150
    {
151
        $provider = $this->getProvider($provider);
152
153
        $consumeAssertionService = $this->getGsspConsumeAssertionService();
154
        $proxyResponseFactory = $this->getProxyResponseFactory($provider);
155
156
        try {
157
            $response = $consumeAssertionService->consumeAssertion($provider, $httpRequest, $proxyResponseFactory);
158
        } catch (ResponseFailureException $e) {
159
            $response = $this->createResponseFailureResponse(
160
                $provider,
161
                $this->getDestination($provider->getStateHandler()),
162
                $this->getIssuer($provider->getStateHandler()),
163
                $e->getMessage(),
164
            );
165
            return $this->renderSamlResponse('consume_assertion', $provider->getStateHandler(), $response);
166
        } catch (InvalidSubjectException) {
167
            return $this->renderSamlResponse(
168
                'recoverable_error',
169
                $provider->getStateHandler(),
170
                $this->createAuthnFailedResponse(
171
                    $provider,
172
                    $this->getDestination($provider->getStateHandler()),
173
                ),
174
            );
175
        } catch (SecondfactorVerificationRequiredException) {
176
            // The provider state handler has no access to the session object,
177
            // hence we use the proxy state handler
178
            $stateHandler = $this->get('gateway.proxy.sso.state_handler');
179
180
            return $this->forward(
181
                'Surfnet\StepupGateway\GatewayBundle\Controller\SecondFactorController::gssfVerified',
182
                [
183
                    // The authentication mode is loaded from session, based on the request id
184
                    'authenticationMode' => $stateHandler->getAuthenticationModeForRequestId(
185
                        $consumeAssertionService->getReceivedRequestId(),
186
                    ),
187
                ],
188
            );
189
        } catch (Exception $e) {
190
            throw $e;
191
        }
192
193
        return $this->renderSamlResponse('consume_assertion', $provider->getStateHandler(), $response);
194
    }
195
196
197
    #[Route(
198
        path: '/gssp/{provider}/metadata',
199
        name: 'gssp_saml_metadata',
200
        methods: ['GET']
201
    )]
202
    public function metadata(string $provider): XMLResponse
203
    {
204
        $provider = $this->getProvider($provider);
205
206
        /** @var MetadataFactory $factory */
207
        $factory = $this->get('gssp.provider.' . $provider->getName() . '.metadata.factory');
208
209
        return new XMLResponse($factory->generate());
210
    }
211
212
    private function getProvider(string $provider): Provider
213
    {
214
        /** @var ProviderRepository $providerRepository */
215
        $providerRepository = $this->get('gssp.provider_repository');
216
217
        if (!$providerRepository->has($provider)) {
218
            throw new NotFoundHttpException(
219
                sprintf('Requested GSSP "%s" does not exist or is not registered', $provider),
220
            );
221
        }
222
223
        return $providerRepository->get($provider);
224
    }
225
226
    private function getDestination(StateHandler $stateHandler): string
227
    {
228
        if ($stateHandler->secondFactorVerificationRequested()) {
229
            // This can either be an SFO or 'regular' SSO authentication.
230
            // Both use a ResponseContext service of their own
231
            $responseContextServiceId = $stateHandler->getResponseContextServiceId();
232
            // GSSP verification action, return to SP from GatewayController state!
233
            $destination = $this->get($responseContextServiceId)->getDestination();
0 ignored issues
show
Bug introduced by
It seems like $responseContextServiceId can also be of type null; however, parameter $serviceName of Surfnet\StepupGateway\Ga...tainerController::get() 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

233
            $destination = $this->get(/** @scrutinizer ignore-type */ $responseContextServiceId)->getDestination();
Loading history...
234
        } else {
235
            // GSSP registration action, return to SP remembered in ssoAction().
236
            $serviceProvider = $this->getServiceProvider(
237
                $stateHandler->getRequestServiceProvider(),
238
            );
239
240
            $destination = $serviceProvider->determineAcsLocation(
241
                $stateHandler->getRequestAssertionConsumerServiceUrl(),
242
                $this->get('logger'),
243
            );
244
        }
245
246
        return $destination;
247
    }
248
249
    private function getIssuer(StateHandler $stateHandler): Issuer
250
    {
251
        // This can either be a SFO or 'regular' SSO authentication. Both use a ResponseContext service of their own
252
        $responseContextServiceId = $stateHandler->getResponseContextServiceId();
253
        if (!$responseContextServiceId) {
254
            throw new RuntimeException(
255
                sprintf(
256
                    'Unable to find the ResponseContext service-id for this authentication or registration, ' .
257
                    'service-id provided was: "%s"',
258
                    $responseContextServiceId,
259
                ),
260
            );
261
        }
262
        // GSSP verification action, return to SP from GatewayController state!
263
        /** @var ResponseContext $responseService */
264
        $responseService = $this->get($responseContextServiceId);
265
        return $responseService->getIssuer();
266
    }
267
268
    public function renderSamlResponse(string $view, StateHandler $stateHandler, SAMLResponse $response): Response
269
    {
270
        /** @var ResponseHelper $responseHelper */
271
        $responseHelper = $this->get('second_factor_only.adfs.response_helper');
272
        $logger = $this->get('logger');
273
        $logger->notice(sprintf('Rendering SAML Response with view "%s"', $view));
274
275
        $parameters = [
276
            'acu' => $response->getDestination(),
277
            'response' => $this->getResponseAsXML($response),
278
            'relayState' => $stateHandler->getRelayState(),
279
        ];
280
        $responseContext = $this->getResponseContext('gateway.proxy.sfo.state_handler');
281
282
        // Test if we should add ADFS response parameters
283
        $inResponseTo = $responseContext->getInResponseTo();
284
        $isAdfsResponse = $responseHelper->isAdfsResponse($inResponseTo);
285
        $logger->notice(sprintf('Responding to "%s" an ADFS response? %s', $inResponseTo, $isAdfsResponse ? 'yes' : 'no'));
286
        if ($isAdfsResponse) {
287
            $adfsParameters = $responseHelper->retrieveAdfsParameters();
288
            $logMessage = 'Responding with additional ADFS parameters, in response to request: "%s", with view: "%s"';
289
            if (!$response->isSuccess()) {
290
                $logMessage = 'Responding with an AuthnFailed SamlResponse with ADFS parameters, in response to AR: "%s", with view: "%s"';
291
            }
292
            $logger->notice(sprintf($logMessage, $inResponseTo, $view));
293
            $parameters['adfs'] = $adfsParameters;
294
            $parameters['acu'] = $responseContext->getDestinationForAdfs();
295
        }
296
297
        $response = parent::render(
298
            '@default/saml_proxy/' . $view . '.html.twig',
299
            $parameters,
300
        );
301
302
        // clear the state so we can call again :)
303
        $stateHandler->clear();
304
305
        return $response;
306
    }
307
308
    /**
309
     * @return string
310
     */
311
    private function getResponseAsXML(SAMLResponse $response): string
312
    {
313
        return base64_encode($response->toUnsignedXML()->ownerDocument->saveXML());
0 ignored issues
show
Bug introduced by
The method saveXML() does not exist on null. ( Ignorable by Annotation )

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

313
        return base64_encode($response->toUnsignedXML()->ownerDocument->/** @scrutinizer ignore-call */ saveXML());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
314
    }
315
316
    /**
317
     * Response that indicates that an error occurred in the responder
318
     * (the gateway). Used to indicate that we could not process the
319
     * response we received from the upstream GSSP
320
     *
321
     * The correct Destination (where did the SAMLResponse originate from.
322
     * And the Issuer (who issued the response) are explicitly set on the response
323
     * allowing for correctly setting them.
324
     */
325
    private function createResponseFailureResponse(
326
        Provider $provider,
327
        string $destination,
328
        Issuer $issuer,
329
        string $message,
330
    ): SAMLResponse {
331
        $response = $this->createResponse($provider, $destination);
332
        // Overwrite the issuer with the correct issuer for the saml failed response
333
        $response->setIssuer($issuer);
334
        $response->setStatus([
335
            'Code' => Constants::STATUS_RESPONDER,
336
            'SubCode' => Constants::STATUS_AUTHN_FAILED,
337
            'Message' => $message
338
        ]);
339
340
        return $response;
341
    }
342
343
    /**
344
     * Response that indicates that the authentication could not be performed correctly. In this context it means
345
     * that the upstream GSSP did not responsd with the same NameID as we request to authenticate in the AuthnRequest
346
     */
347
    private function createAuthnFailedResponse(Provider $provider, ?string $destination): SAMLResponse
348
    {
349
        $response = $this->createResponse($provider, $destination);
350
        $response->setStatus(
351
            [
352
                'Code' => Constants::STATUS_RESPONDER,
353
                'SubCode' => Constants::STATUS_AUTHN_FAILED,
354
            ],
355
        );
356
357
        return $response;
358
    }
359
360
    /**
361
     * Creates a standard response with default status Code (success)
362
     */
363
    private function createResponse(Provider $provider, ?string $destination): SAMLResponse
364
    {
365
        $context = $this->getResponseContext();
366
        $response = new SAMLResponse();
367
        $response->setDestination($destination);
368
        $response->setIssuer($context->getIssuer());
369
        $response->setIssueInstant((new DateTime('now'))->getTimestamp());
370
        $response->setInResponseTo($provider->getStateHandler()->getRequestId());
371
372
        return $response;
373
    }
374
    /**
375
     * @return LoginService
376
     */
377
    private function getGsspLoginService()
378
    {
379
        return $this->get('gssp.service.gssp.login');
380
    }
381
382
    /**
383
     * @return SecondFactorVerificationService
384
     */
385
    private function getGsspSecondFactorVerificationService()
386
    {
387
        return $this->get('gssp.service.gssp.second_factor_verification');
388
    }
389
390
    /**
391
     * @return ConsumeAssertionService
392
     */
393
    private function getGsspConsumeAssertionService()
394
    {
395
        return $this->get('gssp.service.gssp.consume_assertion');
396
    }
397
398
    /**
399
     * @param Provider $provider
400
     * @return ProxyResponseFactory
401
     */
402
    private function getProxyResponseFactory(Provider $provider)
403
    {
404
        return $this->get('gssp.provider.' . $provider->getName() . '.response_proxy');
405
    }
406
407
    /**
408
     * @return \Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext
409
     */
410
    public function getResponseContext($mode = 'gateway.proxy.sso.state_handler')
411
    {
412
        $stateHandler = $this->get($mode);
413
414
        $responseContextServiceId = $stateHandler->getResponseContextServiceId();
415
416
        if (!$responseContextServiceId) {
417
            return $this->get(GatewayController::RESPONSE_CONTEXT_SERVICE_ID);
418
        }
419
420
        return $this->get($responseContextServiceId);
421
    }
422
423
    private function getServiceProvider(?string $serviceProvider)
424
    {
425
        $connectedServiceProviders = $this->get('gssp.connected_service_providers');
426
        return $connectedServiceProviders->getConfigurationOf($serviceProvider);
427
    }
428
}
429