Completed
Push — feature/upgrade-remote-vetting ( 883904 )
by
unknown
65:35
created

MockGateway   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 395
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 1

Importance

Changes 0
Metric Value
wmc 30
lcom 1
cbo 1
dl 0
loc 395
c 0
b 0
f 0
rs 10

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 6 1
A handleSsoSuccess() 0 20 1
A handleSsoFailure() 0 11 1
A createSecondFactorOnlyResponse() 0 17 1
B parseRequest() 0 83 8
A parsePostResponse() 0 4 1
B createFailureResponse() 0 29 6
A createNewAuthnResponse() 0 11 1
A createNewAssertion() 0 18 1
A addSubjectConfirmationFor() 0 14 1
A addAuthenticationStatementTo() 0 6 1
A getTimestamp() 0 10 2
A signAssertion() 0 7 1
A loadPrivateKey() 0 7 1
A getPublicCertificate() 0 4 1
A isValidResponseStatus() 0 9 1
A isValidResponseSubStatus() 0 24 1
1
<?php
2
/**
3
 * Copyright 2019 SURFnet B.V.
4
 *
5
 * Licensed under the Apache License, Version 2.0 (the "License");
6
 * you may not use this file except in compliance with the License.
7
 * You may obtain a copy of the License at
8
 *
9
 *     http://www.apache.org/licenses/LICENSE-2.0
10
 *
11
 * Unless required by applicable law or agreed to in writing, software
12
 * distributed under the License is distributed on an "AS IS" BASIS,
13
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
 * See the License for the specific language governing permissions and
15
 * limitations under the License.
16
 */
17
18
namespace Surfnet\StepupSelfService\SelfServiceBundle\Mock\RemoteVetting;
19
20
use DateInterval;
21
use DateTime;
22
use LogicException;
23
use RobRichards\XMLSecLibs\XMLSecurityKey;
24
use RuntimeException;
25
use SAML2\Assertion;
26
use SAML2\AuthnRequest as SAML2AuthnRequest;
27
use SAML2\Constants;
28
use SAML2\DOMDocumentFactory;
29
use SAML2\Message;
30
use SAML2\Response;
31
use SAML2\XML\saml\SubjectConfirmation;
32
use SAML2\XML\saml\SubjectConfirmationData;
33
use Surfnet\SamlBundle\SAML2\Attribute\Attribute;
34
use Surfnet\SamlBundle\SAML2\Attribute\AttributeDefinition;
35
use Symfony\Component\HttpFoundation\Request;
36
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
37
38
/**
39
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
40
 */
41
class MockGateway
42
{
43
44
    const PARAMETER_REQUEST = 'SAMLRequest';
45
    const PARAMETER_RELAY_STATE = 'RelayState';
46
    const PARAMETER_SIGNATURE = 'Signature';
47
    const PARAMETER_SIGNATURE_ALGORITHM = 'SigAlg';
48
49
    /**
50
     * @var DateTime
51
     */
52
    private $currentTime;
53
54
    /**
55
     * @var MockConfiguration
56
     */
57
    private $gatewayConfiguration;
58
59
    /**
60
     * @param MockConfiguration $gatewayConfiguration
61
     * @throws \Exception
62
     */
63
    public function __construct(
64
        MockConfiguration $gatewayConfiguration
65
    ) {
66
        $this->gatewayConfiguration = $gatewayConfiguration;
67
        $this->currentTime = new DateTime();
68
    }
69
70
    /**
71
     * @param Request $request
72
     * @param string $fullRequestUri
73
     * @return Response
74
     */
75
    public function handleSsoSuccess(Request $request, $fullRequestUri, array $attributes)
76
    {
77
        // parse the authnRequest
78
        $authnRequest = $this->parseRequest($request, $fullRequestUri);
79
80
        // get parameters from authnRequest
81
        $nameId = $authnRequest->getNameId()->value;
82
        $destination = $authnRequest->getAssertionConsumerServiceURL();
83
        $authnContextClassRef = current($authnRequest->getRequestedAuthnContext()['AuthnContextClassRef']);
84
        $requestId = $authnRequest->getId();
85
86
        // handle success
87
        return $this->createSecondFactorOnlyResponse(
88
            $nameId,
89
            $destination,
90
            $authnContextClassRef,
91
            $requestId,
92
            $attributes
93
        );
94
    }
95
96
    /**
97
     * @param Request $request
98
     * @param string $fullRequestUri
99
     * @param string $status
100
     * @param string $subStatus
101
     * @param string $message
102
     * @return Response
103
     */
104
    public function handleSsoFailure(Request $request, $fullRequestUri, $status, $subStatus, $message = '')
105
    {
106
        // parse the authnRequest
107
        $authnRequest = $this->parseRequest($request, $fullRequestUri);
108
109
        // get parameters from authnRequest
110
        $destination = $authnRequest->getAssertionConsumerServiceURL();
111
        $requestId = $authnRequest->getId();
112
113
        return $this->createFailureResponse($destination, $requestId, $status, $subStatus, $message);
114
    }
115
116
117
    /**
118
     * @param string $nameId
119
     * @param string $destination The ACS location
120
     * @param string|null $authnContextClassRef The loa level
121
     * @param string $requestId The requestId
122
     * @param array $attributes All new attributes, as an associative array.
123
     * @return Response
124
     */
125
    private function createSecondFactorOnlyResponse($nameId, $destination, $authnContextClassRef, $requestId, $attributes)
126
    {
127
        $assertion = $this->createNewAssertion(
128
            $nameId,
129
            $authnContextClassRef,
130
            $destination,
131
            $requestId
132
        );
133
134
        $assertion->setAttributes($attributes);
135
136
        return $this->createNewAuthnResponse(
137
            $assertion,
138
            $destination,
139
            $requestId
140
        );
141
    }
142
143
    /**
144
     * @param string $samlRequest
0 ignored issues
show
Documentation introduced by
There is no parameter named $samlRequest. Did you maybe mean $request?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
145
     * @param string $fullRequestUri
146
     * @return SAML2AuthnRequest
147
     * @throws \Exception
148
     */
149
    private function parseRequest(Request $request, $fullRequestUri)
150
    {
151
        // the GET parameter is already urldecoded by Symfony, so we should not do it again.
152
        $requestData = $request->get(self::PARAMETER_REQUEST);
153
154
        if (empty($requestData)) {
155
            throw new BadRequestHttpException('Missing a request, did not receive a request or request was empty');
156
        }
157
158
        $samlRequest = base64_decode($requestData, true);
159
        if ($samlRequest === false) {
160
            throw new BadRequestHttpException('Failed decoding the request, did not receive a valid base64 string');
161
        }
162
163
        // Catch any errors gzinflate triggers
164
        $errorNo = $errorMessage = null;
165
        set_error_handler(function ($number, $message) use (&$errorNo, &$errorMessage) {
166
            $errorNo      = $number;
167
            $errorMessage = $message;
168
        });
169
        $samlRequest = gzinflate($samlRequest);
170
        restore_error_handler();
171
172
        if ($samlRequest === false) {
173
            throw new BadRequestHttpException(sprintf(
174
                'Failed inflating the request; error "%d": "%s"',
175
                $errorNo,
176
                $errorMessage
177
            ));
178
        }
179
180
        // 1. Parse to xml object
181
        // additional security against XXE Processing vulnerability
182
        $previous = libxml_disable_entity_loader(true);
183
        $document = DOMDocumentFactory::fromString($samlRequest);
184
        libxml_disable_entity_loader($previous);
185
186
        // 2. Parse saml request
187
        $authnRequest = Message::fromXML($document->firstChild);
188
189
        if (!$authnRequest instanceof SAML2AuthnRequest) {
0 ignored issues
show
Bug introduced by
The class SAML2\AuthnRequest does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
190
            throw new RuntimeException(sprintf(
191
                'The received request is not an AuthnRequest, "%s" received instead',
192
                substr(get_class($authnRequest), strrpos($authnRequest, '_') + 1)
193
            ));
194
        }
195
196
        // 3. Validate destination
197
        if (!$authnRequest->getDestination() === $fullRequestUri) {
198
            throw new BadRequestHttpException(sprintf(
199
                'Actual Destination "%s" does not match the AuthnRequest Destination "%s"',
200
                $fullRequestUri,
201
                $authnRequest->getDestination()
202
            ));
203
        }
204
205
        // 4. Validate issuer
206
        if (!$this->gatewayConfiguration->getServiceProviderEntityId() === $authnRequest->getIssuer()) {
207
            throw new BadRequestHttpException(sprintf(
208
                'Actual issuer "%s" does not match the AuthnRequest Issuer "%s"',
209
                $this->gatewayConfiguration->getServiceProviderEntityId(),
210
                $authnRequest->getIssuer()
211
            ));
212
        }
213
214
        // 5. Validate key
215
        $key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, array('type' => 'public'));
216
        $key->loadKey($this->gatewayConfiguration->getIdentityProviderPublicKeyCertData());
217
218
        // The query string to validate needs to be urlencoded again because Symfony has already decoded this for us
219
        $query = self::PARAMETER_REQUEST . '=' . urlencode($requestData);
220
        $query .= '&' . self::PARAMETER_SIGNATURE_ALGORITHM . '=' . urlencode($request->get(self::PARAMETER_SIGNATURE_ALGORITHM));
221
222
        $signature = base64_decode($request->get(self::PARAMETER_SIGNATURE));
223
224
        if (!$key->verifySignature($query, $signature)) {
225
            throw new BadRequestHttpException(
226
                'Validation of the signature in the AuthnRequest failed'
227
            );
228
        }
229
230
        return $authnRequest;
231
    }
232
233
    /**
234
     * @param Response $response
235
     * @return string
236
     */
237
    public function parsePostResponse(Response $response)
238
    {
239
        return $response->toUnsignedXML()->ownerDocument->saveXML();
240
    }
241
242
    /**
243
     * @param string $destination The ACS location
244
     * @param string $requestId The requestId
245
     * @param string $status The response status (see \SAML2\Constants)
246
     * @param string|null $subStatus An optional substatus (see \SAML2\Constants)
247
     * @param string|null $message The textual message
248
     * @return Response
249
     */
250
    private function createFailureResponse($destination, $requestId, $status, $subStatus = null, $message = null)
251
    {
252
        $response = new Response();
253
        $response->setDestination($destination);
254
        $response->setIssuer($this->gatewayConfiguration->getIdentityProviderEntityId());
255
        $response->setIssueInstant($this->getTimestamp());
256
        $response->setInResponseTo($requestId);
257
258
259
        if (!$this->isValidResponseStatus($status)) {
260
            throw new LogicException(sprintf('Trying to set invalid Response Status'));
261
        }
262
263
        if ($subStatus && !$this->isValidResponseSubStatus($subStatus)) {
264
            throw new LogicException(sprintf('Trying to set invalid Response SubStatus'));
265
        }
266
267
        $status = ['Code' => $status];
268
        if ($subStatus) {
269
            $status['SubCode'] = $subStatus;
270
        }
271
        if ($message) {
272
            $status['Message'] = $message;
273
        }
274
275
        $response->setStatus($status);
276
277
        return $response;
278
    }
279
280
    /**
281
     * @param Assertion $newAssertion
282
     * @param string $destination The ACS location
283
     * @param string $requestId The requestId
284
     * @return Response
285
     */
286
    private function createNewAuthnResponse(Assertion $newAssertion, $destination, $requestId)
287
    {
288
        $response = new Response();
289
        $response->setAssertions([$newAssertion]);
290
        $response->setIssuer($this->gatewayConfiguration->getIdentityProviderEntityId());
291
        $response->setIssueInstant($this->getTimestamp());
292
        $response->setDestination($destination);
293
        $response->setInResponseTo($requestId);
294
295
        return $response;
296
    }
297
298
    /**
299
     * @param string $nameId
300
     * @param string $authnContextClassRef
301
     * @param string $destination The ACS location
302
     * @param string $requestId The requestId
303
     * @return Assertion
304
     */
305
    private function createNewAssertion($nameId, $authnContextClassRef, $destination, $requestId)
306
    {
307
        $newAssertion = new Assertion();
308
        $newAssertion->setNotBefore($this->currentTime->getTimestamp());
309
        $newAssertion->setNotOnOrAfter($this->getTimestamp('PT5M'));
310
        $newAssertion->setIssuer($this->gatewayConfiguration->getIdentityProviderEntityId());
311
        $newAssertion->setIssueInstant($this->getTimestamp());
312
        $this->signAssertion($newAssertion);
313
        $this->addSubjectConfirmationFor($newAssertion, $destination, $requestId);
314
        $newAssertion->setNameId([
315
            'Format' => Constants::NAMEID_UNSPECIFIED,
316
            'Value' => $nameId,
317
        ]);
318
        $newAssertion->setValidAudiences([$this->gatewayConfiguration->getServiceProviderEntityId()]);
319
        $this->addAuthenticationStatementTo($newAssertion, $authnContextClassRef);
320
321
        return $newAssertion;
322
    }
323
324
    /**
325
     * @param Assertion $newAssertion
326
     * @param string $destination The ACS location
327
     * @param string $requestId The requestId
328
     */
329
    private function addSubjectConfirmationFor(Assertion $newAssertion, $destination, $requestId)
330
    {
331
        $confirmation         = new SubjectConfirmation();
332
        $confirmation->Method = Constants::CM_BEARER;
333
334
        $confirmationData                      = new SubjectConfirmationData();
335
        $confirmationData->InResponseTo        = $requestId;
336
        $confirmationData->Recipient           = $destination;
337
        $confirmationData->NotOnOrAfter        = $newAssertion->getNotOnOrAfter();
338
339
        $confirmation->SubjectConfirmationData = $confirmationData;
340
341
        $newAssertion->setSubjectConfirmation([$confirmation]);
342
    }
343
344
    /**
345
     * @param Assertion $assertion
346
     * @param $authnContextClassRef
347
     */
348
    private function addAuthenticationStatementTo(Assertion $assertion, $authnContextClassRef)
349
    {
350
        $assertion->setAuthnInstant($this->getTimestamp());
351
        $assertion->setAuthnContextClassRef($authnContextClassRef);
352
        $assertion->setAuthenticatingAuthority([$this->gatewayConfiguration->getIdentityProviderEntityId()]);
353
    }
354
355
    /**
356
     * @param string $interval a DateInterval compatible interval to skew the time with
0 ignored issues
show
Documentation introduced by
Should the type for parameter $interval not be string|null?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
357
     * @return int
358
     */
359
    private function getTimestamp($interval = null)
360
    {
361
        $time = clone $this->currentTime;
362
363
        if ($interval) {
364
            $time->add(new DateInterval($interval));
365
        }
366
367
        return $time->getTimestamp();
368
    }
369
370
    /**
371
     * @param Assertion $assertion
372
     * @return Assertion
373
     */
374
    private function signAssertion(Assertion $assertion)
375
    {
376
        $assertion->setSignatureKey($this->loadPrivateKey());
377
        $assertion->setCertificates([$this->getPublicCertificate()]);
378
379
        return $assertion;
380
    }
381
382
    /**
383
     * @return XMLSecurityKey
384
     */
385
    private function loadPrivateKey()
386
    {
387
        $xmlSecurityKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
388
        $xmlSecurityKey->loadKey($this->gatewayConfiguration->getIdentityProviderGetPrivateKeyPem());
389
390
        return $xmlSecurityKey;
391
    }
392
393
    /**
394
     * @return string
395
     */
396
    private function getPublicCertificate()
397
    {
398
        return $this->gatewayConfiguration->getIdentityProviderPublicKeyCertData();
399
    }
400
401
    private function isValidResponseStatus($status)
402
    {
403
        return in_array($status, [
404
            Constants::STATUS_SUCCESS,            // weeee!
405
            Constants::STATUS_REQUESTER,          // Something is wrong with the AuthnRequest
406
            Constants::STATUS_RESPONDER,          // Something went wrong with the Response
407
            Constants::STATUS_VERSION_MISMATCH,   // The version of the request message was incorrect
408
        ]);
409
    }
410
411
    private function isValidResponseSubStatus($subStatus)
412
    {
413
        return in_array($subStatus, [
414
            Constants::STATUS_AUTHN_FAILED,               // failed authentication
415
            Constants::STATUS_INVALID_ATTR,
416
            Constants::STATUS_INVALID_NAMEID_POLICY,
417
            Constants::STATUS_NO_AUTHN_CONTEXT,           // insufficient Loa or Loa cannot be met
418
            Constants::STATUS_NO_AVAILABLE_IDP,
419
            Constants::STATUS_NO_PASSIVE,
420
            Constants::STATUS_NO_SUPPORTED_IDP,
421
            Constants::STATUS_PARTIAL_LOGOUT,
422
            Constants::STATUS_PROXY_COUNT_EXCEEDED,
423
            Constants::STATUS_REQUEST_DENIED,
424
            Constants::STATUS_REQUEST_UNSUPPORTED,
425
            Constants::STATUS_REQUEST_VERSION_DEPRECATED,
426
            Constants::STATUS_REQUEST_VERSION_TOO_HIGH,
427
            Constants::STATUS_REQUEST_VERSION_TOO_LOW,
428
            Constants::STATUS_RESOURCE_NOT_RECOGNIZED,
429
            Constants::STATUS_TOO_MANY_RESPONSES,
430
            Constants::STATUS_UNKNOWN_ATTR_PROFILE,
431
            Constants::STATUS_UNKNOWN_PRINCIPAL,
432
            Constants::STATUS_UNSUPPORTED_BINDING,
433
        ]);
434
    }
435
}
436