Issues (99)

Branch: main

src/Controller/IdentityProviderController.php (7 issues)

1
<?php
2
3
/**
4
 * Copyright 2020 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\Behat\Controller;
20
21
use RobRichards\XMLSecLibs\XMLSecurityKey;
22
use SAML2\Assertion;
23
use SAML2\Certificate\KeyLoader;
24
use SAML2\Certificate\PrivateKeyLoader;
25
use SAML2\Configuration\PrivateKey;
26
use SAML2\Constants;
27
use SAML2\Response as SAMLResponse;
28
use SAML2\XML\saml\NameID;
29
use SAML2\XML\saml\SubjectConfirmation;
30
use SAML2\XML\saml\SubjectConfirmationData;
31
use Surfnet\SamlBundle\Http\Exception\UnsignedRequestException;
32
use Surfnet\SamlBundle\Http\ReceivedAuthnRequestQueryString;
33
use Surfnet\SamlBundle\SAML2\ReceivedAuthnRequest;
34
use Surfnet\StepupGateway\Behat\Command\LoginCommand;
35
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
36
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
37
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
38
use Symfony\Component\Form\Extension\Core\Type\TextType;
39
use Symfony\Component\HttpFoundation\Request;
40
use Symfony\Component\HttpFoundation\Response;
41
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
42
43
class IdentityProviderController extends AbstractController
44
{
45
    /**
46
     * Handles a SSO request
47
     * @param Request $request
48
     */
49
    public function ssoAction(Request $request): Response
50
    {
51
        // Receives the AuthnRequest and sends a SAML response
52
        $authnRequest = $this->receiveSignedAuthnRequestFrom($request);
53
        // By default render the username form
54
        $loginData = new LoginCommand();
55
        if ($authnRequest) {
56
            $loginData->setRequestId($authnRequest->getRequestId());
57
        }
58
        $form = $this
59
            ->createFormBuilder($loginData)
60
            ->add('username', TextType::class)
61
            ->add('requestId', TextType::class)
62
            ->add('submit', SubmitType::class)
63
            ->getForm();
64
65
        $form->handleRequest($request);
66
67
        if ($form->isSubmitted() && $form->isValid()) {
68
            $loginData = $form->getData();
69
            $response = $this->createResponse(
70
                'https://gateway.dev.openconext.local/authentication/consume-assertion',
71
                ['Value' => $loginData->getUsername(), 'Format' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'],
72
                $loginData->getRequestId()
73
            );
74
            return $this->renderSamlResponse($response);
75
        }
76
77
        return $this->render(
78
                '@test_resources/login.html.twig', [
79
                'form' => $form,
80
            ]
81
        );
82
    }
83
84
    /**
85
     * Handles a GSSP SSO request
86
     * @param Request $request
87
     */
88
    public function gsspSsoAction(Request $request)
89
    {
90
        // receives the AuthnRequest and sends a SAML response
91
        $authnRequest = $this->receiveSignedAuthnRequestFrom($request);
92
        // Todo: For some reason, the nameId is not transpored even tho it is set on the auhtnrequest.. Figure out whats going on here and fix this.
93
        // now the test will only work with one hard-coded user.
94
        $response = $this->createResponse(
95
            $authnRequest->getAssertionConsumerServiceURL(),
96
            ['Value' => 'foobar', 'Format' => 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'],
97
            $authnRequest->getRequestId()
98
        );
99
        return $this->renderSamlResponse($response);
100
    }
101
102
    public function renderSamlResponse(SAMLResponse $response): Response
103
    {
104
        $parameters = [
105
            'acu' => $response->getDestination(),
106
            'response' => $this->getResponseAsXML($response),
107
            'relayState' => ''
108
        ];
109
110
        $response = $this->render(
111
            '@SurfnetStepupGatewayGateway/gateway/consume_assertion.html.twig',
112
            $parameters
113
        );
114
115
        return $response;
116
    }
117
118
    public function createResponse(string $destination, array $nameId, $requestId): SAMLResponse
119
    {
120
        $newAssertion = new Assertion();
121
        $newAssertion->setNotBefore(time());
122
        $newAssertion->setNotOnOrAfter(time() + (60 * 5));//
123
        $newAssertion->setAttributes(['urn:mace:dir:attribute-def:eduPersonTargetedID' => [NameID::fromArray($nameId)]]);
0 ignored issues
show
The method fromArray() does not exist on SAML2\XML\saml\NameID. ( Ignorable by Annotation )

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

123
        $newAssertion->setAttributes(['urn:mace:dir:attribute-def:eduPersonTargetedID' => [NameID::/** @scrutinizer ignore-call */ fromArray($nameId)]]);

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...
124
        $newAssertion->setIssuer('https://idp.dev.openconext.local/');
0 ignored issues
show
'https://idp.dev.openconext.local/' of type string is incompatible with the type SAML2\XML\saml\Issuer expected by parameter $issuer of SAML2\Assertion::setIssuer(). ( Ignorable by Annotation )

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

124
        $newAssertion->setIssuer(/** @scrutinizer ignore-type */ 'https://idp.dev.openconext.local/');
Loading history...
125
        $newAssertion->setIssueInstant(time());
126
127
        $this->signAssertion($newAssertion);
128
        $this->addSubjectConfirmationFor($newAssertion, $destination, $requestId);
129
        $newAssertion->setNameId($nameId);
0 ignored issues
show
$nameId of type array is incompatible with the type SAML2\XML\saml\NameID|null expected by parameter $nameId of SAML2\Assertion::setNameId(). ( Ignorable by Annotation )

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

129
        $newAssertion->setNameId(/** @scrutinizer ignore-type */ $nameId);
Loading history...
130
        $response = new SAMLResponse();
131
        $response->setAssertions([$newAssertion]);
132
        $response->setIssuer('https://gateway.dev.openconext.local/idp/metadata');
0 ignored issues
show
'https://gateway.dev.ope...ext.local/idp/metadata' of type string is incompatible with the type SAML2\XML\saml\Issuer|null expected by parameter $issuer of SAML2\Message::setIssuer(). ( Ignorable by Annotation )

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

132
        $response->setIssuer(/** @scrutinizer ignore-type */ 'https://gateway.dev.openconext.local/idp/metadata');
Loading history...
133
        $response->setIssueInstant(time());
134
        $response->setDestination($destination);
135
        $response->setInResponseTo($requestId);
136
137
        $this->get('logger')->notice(
0 ignored issues
show
The method get() does not exist on Surfnet\StepupGateway\Be...ntityProviderController. ( Ignorable by Annotation )

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

137
        $this->/** @scrutinizer ignore-call */ 
138
               get('logger')->notice(

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...
138
            'Create the SAML Response after logging in to the test IdP',
139
            [$this->getResponseAsXML($response)]
140
        );
141
        return $response;
142
    }
143
144
    private function getResponseAsXML(SAMLResponse $response): string
145
    {
146
        return base64_encode($response->toUnsignedXML()->ownerDocument->saveXML());
0 ignored issues
show
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

146
        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...
147
    }
148
149
    private function getFullRequestUri(Request $request): string
150
    {
151
        return $request->getSchemeAndHttpHost() . $request->getBasePath() . $request->getRequestUri();
152
    }
153
154
    public function receiveSignedAuthnRequestFrom(Request $request): ?ReceivedAuthnRequest
155
    {
156
        if (!$request->isMethod(Request::METHOD_GET)) {
157
            return null;
158
        }
159
160
        $requestUri = $request->getRequestUri();
161
        if (strpos($requestUri, '?') === false) {
162
            throw new BadRequestHttpException(
163
                'Could not receive AuthnRequest from HTTP Request: expected query parameters, none found'
164
            );
165
        }
166
167
        list(, $rawQueryString) = explode('?', $requestUri);
168
        $query = ReceivedAuthnRequestQueryString::parse($rawQueryString);
169
170
        if (!$query->isSigned()) {
171
            throw new UnsignedRequestException('The SAMLRequest is expected to be signed but it was not');
172
        }
173
174
        $authnRequest = ReceivedAuthnRequest::from($query->getDecodedSamlRequest());
175
176
        $currentUri = $this->getFullRequestUri($request);
177
        if (!$authnRequest->getDestination() === $currentUri) {
0 ignored issues
show
The condition ! $authnRequest->getDestination() === $currentUri is always false.
Loading history...
178
            throw new BadRequestHttpException(
179
                sprintf(
180
                    'Actual Destination "%s" does not match the AuthnRequest Destination "%s"',
181
                    $currentUri,
182
                    $authnRequest->getDestination()
183
                )
184
            );
185
        }
186
187
        return $authnRequest;
188
    }
189
190
    /**
191
     * @param Assertion $assertion
192
     * @return Assertion
193
     */
194
    public function signAssertion(Assertion $assertion)
195
    {
196
        $assertion->setSignatureKey($this->loadPrivateKey());
197
        $assertion->setCertificates([$this->getPublicCertificate()]);
198
199
        return $assertion;
200
    }
201
202
    private function addSubjectConfirmationFor(Assertion $newAssertion, $destination, $requestId): void
203
    {
204
        $confirmation = new SubjectConfirmation();
205
        $confirmation->setMethod(Constants::CM_BEARER);
206
207
        $confirmationData = new SubjectConfirmationData();
208
        $confirmationData->setInResponseTo($requestId);
209
        $confirmationData->setRecipient($destination);
210
        $confirmationData->setNotOnOrAfter($newAssertion->getNotOnOrAfter());
211
212
        $confirmation->setSubjectConfirmationData($confirmationData);
213
214
        $newAssertion->setSubjectConfirmation([$confirmation]);
215
    }
216
217
    /**
218
     * @return XMLSecurityKey
219
     */
220
    private function loadPrivateKey()
221
    {
222
        $key        = new PrivateKey('/config/ssp/idp.key', 'default');
223
        $keyLoader  = new PrivateKeyLoader();
224
        $privateKey = $keyLoader->loadPrivateKey($key);
225
226
        $xmlSecurityKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
227
        $xmlSecurityKey->loadKey($privateKey->getKeyAsString());
228
229
        return $xmlSecurityKey;
230
    }
231
232
    /**
233
     * @return string
234
     */
235
    private function getPublicCertificate()
236
    {
237
        $keyLoader = new KeyLoader();
238
        $keyLoader->loadCertificateFile('/config/ssp/idp.crt');
239
        /** @var \SAML2\Certificate\X509 $publicKey */
240
        $publicKey = $keyLoader->getKeys()->getOnlyElement();
241
242
        return $publicKey->getCertificate();
243
    }
244
}
245