1 | <?php |
||||||
2 | |||||||
3 | /** |
||||||
4 | * Copyright 2020 SURFnet B.V. |
||||||
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; |
||||||
20 | |||||||
21 | use Behat\Behat\Context\Context; |
||||||
22 | use Behat\Behat\Hook\Scope\BeforeFeatureScope; |
||||||
23 | use Behat\Behat\Hook\Scope\BeforeScenarioScope; |
||||||
24 | use Behat\Mink\Exception\ExpectationException; |
||||||
25 | use DMore\ChromeDriver\ChromeDriver; |
||||||
26 | use FriendsOfBehat\SymfonyExtension\Driver\SymfonyDriver; |
||||||
27 | use Psr\Log\LoggerInterface; |
||||||
28 | use RuntimeException; |
||||||
29 | use SAML2\Compat\ContainerSingleton; |
||||||
30 | use Surfnet\SamlBundle\Tests\TestSaml2Container; |
||||||
31 | use Surfnet\StepupGateway\Behat\Service\FixtureService; |
||||||
32 | |||||||
33 | class FeatureContext implements Context |
||||||
34 | { |
||||||
35 | /** |
||||||
36 | * @var FixtureService |
||||||
37 | */ |
||||||
38 | private $fixtureService; |
||||||
39 | |||||||
40 | private $whitelistedInstitutions = []; |
||||||
41 | |||||||
42 | /** |
||||||
43 | * @var MinkContext |
||||||
44 | */ |
||||||
45 | private $minkContext; |
||||||
46 | |||||||
47 | /** |
||||||
48 | * @var array |
||||||
49 | */ |
||||||
50 | private $currentToken; |
||||||
51 | |||||||
52 | private $sso2faCookieName; |
||||||
53 | |||||||
54 | /** |
||||||
55 | * @var string|null |
||||||
56 | */ |
||||||
57 | private $previousSsoOn2faCookieValue; |
||||||
58 | |||||||
59 | /** |
||||||
60 | * @var string |
||||||
61 | */ |
||||||
62 | private $sessCookieName; |
||||||
63 | |||||||
64 | /** |
||||||
65 | * @var string |
||||||
66 | */ |
||||||
67 | private $cookieDomain; |
||||||
68 | |||||||
69 | public function __construct(FixtureService $fixtureService, LoggerInterface $logger) |
||||||
70 | { |
||||||
71 | $this->fixtureService = $fixtureService; |
||||||
72 | $this->sso2faCookieName = 'stepup-gateway_sso-on-second-factor-authentication'; |
||||||
73 | $this->sessCookieName = 'MOCKSESSID'; |
||||||
74 | $this->cookieDomain = '.gateway.dev.openconext.local'; |
||||||
75 | |||||||
76 | // Set a test container for the SAML2 Library to work with (the compat container is broken) |
||||||
77 | ContainerSingleton::setContainer(new TestSaml2Container($logger)); |
||||||
78 | } |
||||||
79 | |||||||
80 | /** |
||||||
81 | * @BeforeFeature |
||||||
82 | */ |
||||||
83 | public static function setupDatabase(BeforeFeatureScope $scope): void |
||||||
0 ignored issues
–
show
|
|||||||
84 | { |
||||||
85 | // Generate test databases |
||||||
86 | echo "Preparing test schemas\n"; |
||||||
87 | shell_exec("/var/www/html/bin/console doctrine:schema:drop --env=smoketest --force"); |
||||||
88 | shell_exec("/var/www/html/bin/console doctrine:schema:create --env=smoketest"); |
||||||
89 | } |
||||||
90 | |||||||
91 | /** |
||||||
92 | * @BeforeScenario |
||||||
93 | */ |
||||||
94 | public function gatherContexts(BeforeScenarioScope $scope): void |
||||||
95 | { |
||||||
96 | $environment = $scope->getEnvironment(); |
||||||
97 | $this->minkContext = $environment->getContext(MinkContext::class); |
||||||
0 ignored issues
–
show
The method
getContext() does not exist on Behat\Testwork\Environment\Environment . It seems like you code against a sub-type of Behat\Testwork\Environment\Environment such as Behat\Behat\Context\Envi...lizedContextEnvironment or Behat\Behat\Context\Envi...lizedContextEnvironment or FriendsOfBehat\SymfonyEx...onyExtensionEnvironment .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
98 | } |
||||||
99 | |||||||
100 | /** |
||||||
101 | * @Given /^a user from "([^"]*)" identified by "([^"]*)" with a vetted "([^"]*)" token$/ |
||||||
102 | */ |
||||||
103 | public function aUserIdentifiedByWithAVettedToken($institution, $nameId, $tokenType): void |
||||||
104 | { |
||||||
105 | switch (strtolower($tokenType)) { |
||||||
106 | case "yubikey": |
||||||
107 | $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution); |
||||||
108 | break; |
||||||
109 | case "sms": |
||||||
110 | $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution); |
||||||
111 | break; |
||||||
112 | case "tiqr": |
||||||
113 | $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution); |
||||||
114 | break; |
||||||
115 | } |
||||||
116 | } |
||||||
117 | |||||||
118 | /** |
||||||
119 | * @Given /^a user from "([^"]*)" identified by "([^"]*)" with a self-asserted "([^"]*)" token$/ |
||||||
120 | */ |
||||||
121 | public function aUserIdentifiedByWithASelfAssertedToken($institution, $nameId, $tokenType): void |
||||||
122 | { |
||||||
123 | switch (strtolower($tokenType)) { |
||||||
124 | case "yubikey": |
||||||
125 | $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution, true); |
||||||
126 | break; |
||||||
127 | case "sms": |
||||||
128 | $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution, true); |
||||||
129 | break; |
||||||
130 | case "tiqr": |
||||||
131 | $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution, true); |
||||||
132 | break; |
||||||
133 | } |
||||||
134 | } |
||||||
135 | |||||||
136 | /** |
||||||
137 | * @Then I should see the Yubikey OTP screen |
||||||
138 | */ |
||||||
139 | public function iShouldSeeTheYubikeyOtpScreen(): void |
||||||
140 | { |
||||||
141 | $this->minkContext->assertPageContainsText('Your YubiKey-code'); |
||||||
142 | } |
||||||
143 | |||||||
144 | /** |
||||||
145 | * @Then I should see the SMS verification screen |
||||||
146 | */ |
||||||
147 | public function iShouldSeeTheSMSScreen(): void |
||||||
148 | { |
||||||
149 | $this->minkContext->assertPageContainsText('Enter the received SMS-code'); |
||||||
150 | $this->minkContext->assertPageContainsText('Send again'); |
||||||
151 | } |
||||||
152 | |||||||
153 | /** |
||||||
154 | * @Given /^I should see the Tiqr authentication screen$/ |
||||||
155 | */ |
||||||
156 | public function iShouldSeeTheTiqrAuthenticationScreen(): void |
||||||
157 | { |
||||||
158 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
159 | $this->minkContext->assertPageContainsText('Log in with Tiqr'); |
||||||
160 | } |
||||||
161 | |||||||
162 | /** |
||||||
163 | * @When I enter the OTP |
||||||
164 | */ |
||||||
165 | public function iEnterTheOtp(): void |
||||||
166 | { |
||||||
167 | $this->minkContext->fillField('gateway_verify_yubikey_otp_otp', 'bogus-otp-we-use-a-mock-yubikey-service'); |
||||||
168 | $this->minkContext->pressButton('gateway_verify_yubikey_otp_submit'); |
||||||
169 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
170 | } |
||||||
171 | |||||||
172 | /** |
||||||
173 | * @When I enter the SMS verification code |
||||||
174 | */ |
||||||
175 | public function iEnterTheSmsVerificationCode(): void |
||||||
176 | { |
||||||
177 | $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service'); |
||||||
178 | if ($cookieValue === null) { |
||||||
179 | throw new RuntimeException('Unable to load the smoketest-sms-service cookie'); |
||||||
180 | } |
||||||
181 | $matches = []; |
||||||
182 | preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches); |
||||||
183 | $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]); |
||||||
184 | $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge'); |
||||||
185 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
186 | } |
||||||
187 | |||||||
188 | /** |
||||||
189 | * @When I enter the expired SMS verification code |
||||||
190 | */ |
||||||
191 | public function iEnterTheExpiredSmsVerificationCode(): void |
||||||
192 | { |
||||||
193 | $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service'); |
||||||
194 | $matches = []; |
||||||
195 | preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches); |
||||||
0 ignored issues
–
show
It seems like
$cookieValue can also be of type null ; however, parameter $subject of preg_match() 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
![]() |
|||||||
196 | $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]); |
||||||
197 | $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge'); |
||||||
198 | } |
||||||
199 | |||||||
200 | /** |
||||||
201 | * @When I finish the Tiqr authentication |
||||||
202 | */ |
||||||
203 | public function iFinishGsspAuthentication(): void |
||||||
204 | { |
||||||
205 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
206 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
207 | } |
||||||
208 | |||||||
209 | /** |
||||||
210 | * @Given /^a whitelisted institution ([^"]*)$/ |
||||||
211 | */ |
||||||
212 | public function aWhitelistedInstitution($institution): void |
||||||
213 | { |
||||||
214 | $this->whitelistedInstitutions[] = $this->fixtureService->whitelist($institution)['institution']; |
||||||
215 | } |
||||||
216 | |||||||
217 | /** |
||||||
218 | * @Given /^an institution "([^"]*)" that allows "([^"]*)"$/ |
||||||
219 | */ |
||||||
220 | public function anInstitutionThatAllows(string $institution, string $option): void |
||||||
221 | { |
||||||
222 | switch(true) { |
||||||
223 | case $option === 'sso_on_2fa': |
||||||
224 | $optionColumnName = 'sso_on2fa_enabled'; |
||||||
225 | break; |
||||||
226 | case $option === 'sso_registration_bypass': |
||||||
227 | $optionColumnName = 'sso_registration_bypass'; |
||||||
228 | break; |
||||||
229 | default: |
||||||
230 | throw new RuntimeException(sprintf('Option "%s" is not supported', $option)); |
||||||
231 | } |
||||||
232 | $this->fixtureService->configureBoolean($institution, $optionColumnName, true); |
||||||
233 | } |
||||||
234 | |||||||
235 | /** |
||||||
236 | * @Then /^I select my ([^"]*) token on the WAYG$/ |
||||||
237 | */ |
||||||
238 | public function iShouldSelectMyTokenOnTheWAYG($tokenType): void |
||||||
239 | { |
||||||
240 | switch (strtolower($tokenType)) { |
||||||
241 | case "yubikey": |
||||||
242 | $this->minkContext->pressButton('gateway_choose_second_factor_choose_yubikey'); |
||||||
243 | break; |
||||||
244 | case "sms": |
||||||
245 | $this->minkContext->pressButton('gateway_choose_second_factor_choose_sms'); |
||||||
246 | break; |
||||||
247 | case "tiqr": |
||||||
248 | $this->minkContext->pressButton('gateway_choose_second_factor_choose_tiqr'); |
||||||
249 | break; |
||||||
250 | } |
||||||
251 | } |
||||||
252 | |||||||
253 | /** |
||||||
254 | * @Then /^I should be on the WAYG$/ |
||||||
255 | */ |
||||||
256 | public function iShouldBeOnTheWAYG(): void |
||||||
257 | { |
||||||
258 | $this->minkContext->assertPageContainsText('Choose a token for login'); |
||||||
259 | } |
||||||
260 | |||||||
261 | /** |
||||||
262 | * @Then /^an error response is posted back to the SP$/ |
||||||
263 | */ |
||||||
264 | public function anErrorResponseIsPostedBackToTheSP(): void |
||||||
265 | { |
||||||
266 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
267 | } |
||||||
268 | |||||||
269 | /** |
||||||
270 | * @Given /^I cancel the authentication$/ |
||||||
271 | */ |
||||||
272 | public function iCancelTheAuthentication(): void |
||||||
273 | { |
||||||
274 | $this->minkContext->pressButton('Cancel'); |
||||||
275 | } |
||||||
276 | |||||||
277 | /** |
||||||
278 | * @Given /^I pass through the Gateway$/ |
||||||
279 | */ |
||||||
280 | public function iPassThroughTheGateway(): void |
||||||
281 | { |
||||||
282 | $this->pressButtonWhenNoJavascriptSupport(); |
||||||
283 | } |
||||||
284 | |||||||
285 | /** |
||||||
286 | * @Given /^I pass through the IdP/ |
||||||
287 | */ |
||||||
288 | public function iPassThroughTheIdP(): void |
||||||
289 | { |
||||||
290 | if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) { |
||||||
291 | $this->minkContext->pressButton('Yes, continue'); |
||||||
292 | } |
||||||
293 | } |
||||||
294 | |||||||
295 | /** |
||||||
296 | * @Then /^the response should have a SSO\-2FA cookie$/ |
||||||
297 | * @throws ExpectationException |
||||||
298 | */ |
||||||
299 | public function theResponseShouldHaveASSO2FACookie(): void |
||||||
300 | { |
||||||
301 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
302 | $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName); |
||||||
303 | // Store the previous cookie value |
||||||
304 | $this->previousSsoOn2faCookieValue = $cookieValue; |
||||||
305 | $this->validateSsoOn2faCookie($cookieValue); |
||||||
306 | } |
||||||
307 | |||||||
308 | |||||||
309 | |||||||
310 | /** |
||||||
311 | * @Then /^the response should have a valid session cookie$/ |
||||||
312 | * @throws ExpectationException |
||||||
313 | */ |
||||||
314 | public function validateSessionCookie(): void |
||||||
315 | { |
||||||
316 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
317 | |||||||
318 | $driver = $this->minkContext->getSession()->getDriver(); |
||||||
319 | |||||||
320 | $cookie = $this->minkContext->getSession()->getCookie($this->sessCookieName); |
||||||
321 | if ($cookie === null) { |
||||||
322 | throw new ExpectationException( |
||||||
323 | 'No session cookie found', |
||||||
324 | $this->minkContext->getSession()->getDriver() |
||||||
325 | ); |
||||||
326 | } |
||||||
327 | |||||||
328 | if (!$driver instanceof ChromeDriver) { |
||||||
329 | return; |
||||||
330 | } |
||||||
331 | |||||||
332 | $sessionCookie = null; |
||||||
333 | foreach ($driver->getCookies() as $cookie) { |
||||||
334 | if ($cookie['name'] === $this->sessCookieName) { |
||||||
335 | $sessionCookie = $cookie; |
||||||
336 | break; |
||||||
337 | } |
||||||
338 | } |
||||||
339 | if ($sessionCookie === null) { |
||||||
340 | throw new ExpectationException( |
||||||
341 | 'No session cookie found', |
||||||
342 | $this->minkContext->getSession()->getDriver() |
||||||
343 | ); |
||||||
344 | } |
||||||
345 | |||||||
346 | if (!array_key_exists('domain', $sessionCookie) || $sessionCookie['domain'] !== $this->cookieDomain) { |
||||||
0 ignored issues
–
show
$sessionCookie of type string is incompatible with the type ArrayObject|array expected by parameter $array of array_key_exists() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
347 | throw new ExpectationException( |
||||||
348 | 'The domain of the session cookie is invalid', |
||||||
349 | $this->minkContext->getSession()->getDriver() |
||||||
350 | ); |
||||||
351 | }; |
||||||
352 | } |
||||||
353 | |||||||
354 | /** |
||||||
355 | * @Then /^the response should not have a SSO\-2FA cookie$/ |
||||||
356 | * @throws ExpectationException |
||||||
357 | */ |
||||||
358 | public function theResponseShouldNotHaveASSO2FACookie(): void |
||||||
359 | { |
||||||
360 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
361 | $cookie = $this->minkContext->getSession()->getCookie($this->sso2faCookieName); |
||||||
362 | if (!is_null($cookie)) { |
||||||
363 | throw new ExpectationException( |
||||||
364 | 'The SSO cookie must NOT be present', |
||||||
365 | $this->minkContext->getSession()->getDriver() |
||||||
366 | ); |
||||||
367 | } |
||||||
368 | } |
||||||
369 | |||||||
370 | /** |
||||||
371 | * @Then /^a new SSO\-2FA cookie was written$/ |
||||||
372 | * @throws ExpectationException |
||||||
373 | */ |
||||||
374 | public function theSSO2FACookieIsRewritten(): void |
||||||
375 | { |
||||||
376 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
377 | $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName); |
||||||
378 | $this->validateSsoOn2faCookie($cookieValue); |
||||||
379 | |||||||
380 | if ($this->previousSsoOn2faCookieValue === $cookieValue) { |
||||||
381 | throw new ExpectationException( |
||||||
382 | sprintf('The SSO on 2FA cookie did not change since the previous response: "%s" !== "%s"', $this->previousSsoOn2faCookieValue, $cookieValue), |
||||||
383 | $this->minkContext->getSession()->getDriver() |
||||||
384 | ); |
||||||
385 | } |
||||||
386 | } |
||||||
387 | |||||||
388 | /** |
||||||
389 | * @Then /^the existing SSO\-2FA cookie was used$/ |
||||||
390 | * @throws ExpectationException |
||||||
391 | */ |
||||||
392 | public function theSSO2FACookieRemainedTheSame(): void |
||||||
393 | { |
||||||
394 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
395 | $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName); |
||||||
396 | $this->validateSsoOn2faCookie($cookieValue); |
||||||
397 | if ($this->previousSsoOn2faCookieValue !== $cookieValue) { |
||||||
398 | throw new ExpectationException( |
||||||
399 | sprintf( |
||||||
400 | 'The SSO on 2FA cookie changed since the previous response %s vs %s', |
||||||
401 | $this->previousSsoOn2faCookieValue, |
||||||
402 | $cookieValue |
||||||
403 | ), |
||||||
404 | $this->minkContext->getSession()->getDriver() |
||||||
405 | ); |
||||||
406 | } |
||||||
407 | } |
||||||
408 | |||||||
409 | /** |
||||||
410 | * @Given /^the user cleared cookies from browser$/ |
||||||
411 | */ |
||||||
412 | public function userClearedCookies(): void |
||||||
413 | { |
||||||
414 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
415 | $this->minkContext->getSession()->setCookie($this->sso2faCookieName, null); |
||||||
416 | } |
||||||
417 | |||||||
418 | /** |
||||||
419 | * @Given /^the SSO\-2FA cookie should contain "([^"]*)"$/ |
||||||
420 | */ |
||||||
421 | public function theSSO2FACookieShouldContain($expectedCookieValue): void |
||||||
422 | { |
||||||
423 | $this->minkContext->visit('https://gateway.dev.openconext.local/info'); |
||||||
424 | $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName); |
||||||
425 | if (strstr($cookieValue, $expectedCookieValue) === false) { |
||||||
0 ignored issues
–
show
It seems like
$cookieValue can also be of type null ; however, parameter $haystack of strstr() 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
![]() |
|||||||
426 | throw new ExpectationException( |
||||||
427 | sprintf( |
||||||
428 | 'The SSO on 2FA cookie did not contain the expected value: "%s", actual contents: "%s"', |
||||||
429 | $expectedCookieValue, |
||||||
430 | $cookieValue |
||||||
431 | ), |
||||||
432 | $this->minkContext->getSession()->getDriver() |
||||||
433 | ); |
||||||
434 | } |
||||||
435 | } |
||||||
436 | |||||||
437 | private function getCookieNames(array $responseCookieHeaders): array |
||||||
0 ignored issues
–
show
|
|||||||
438 | { |
||||||
439 | $response = []; |
||||||
440 | foreach($responseCookieHeaders as $cookie) { |
||||||
441 | $parts = explode('=', $cookie); |
||||||
442 | $response[] = array_shift($parts); |
||||||
443 | } |
||||||
444 | return $response; |
||||||
445 | } |
||||||
446 | |||||||
447 | /** |
||||||
448 | * @throws ExpectationException |
||||||
449 | */ |
||||||
450 | private function validateSsoOn2faCookie(?string $cookieValue): void |
||||||
451 | { |
||||||
452 | if (empty($cookieValue)) { |
||||||
453 | throw new ExpectationException( |
||||||
454 | sprintf( |
||||||
455 | 'The SSO on 2FA cookie was not present, or empty. Cookie name: %s', |
||||||
456 | $this->sso2faCookieName |
||||||
457 | ), |
||||||
458 | $this->minkContext->getSession()->getDriver() |
||||||
459 | ); |
||||||
460 | } |
||||||
461 | } |
||||||
462 | |||||||
463 | private function pressButtonWhenNoJavascriptSupport() |
||||||
464 | { |
||||||
465 | if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) { |
||||||
466 | $this->minkContext->pressButton('Submit'); |
||||||
467 | } |
||||||
468 | } |
||||||
469 | } |
||||||
470 |
This check looks for parameters that have been defined for a function or method, but which are not used in the method body.