OpenConext /
Stepup-Gateway
| 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
|
|||||||
| 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
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
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
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
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
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
|
|||||||
| 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 |
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.