Completed
Pull Request — develop (#225)
by
unknown
09:12 queued 07:20
created

MockGateway::parseRequest()   B

Complexity

Conditions 8
Paths 8

Size

Total Lines 83

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 83
c 0
b 0
f 0
rs 7.1264
cc 8
nc 8
nop 2

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * 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) {
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