FeatureContext::getCookieNames()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 1
dl 0
loc 8
rs 10
c 0
b 0
f 0
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\DatabaseSchemaService;
32
use Surfnet\StepupGateway\Behat\Service\FixtureService;
33
34
class FeatureContext implements Context
35
{
36
    /**
37
     * @var FixtureService
38
     */
39
    private $fixtureService;
40
41
    private static DatabaseSchemaService $databaseSchemaService;
42
43
    private $whitelistedInstitutions = [];
44
45
    /**
46
     * @var MinkContext
47
     */
48
    private $minkContext;
49
50
    /**
51
     * @var array
52
     */
53
    private $currentToken;
54
55
    private $sso2faCookieName;
56
57
    /**
58
     * @var string|null
59
     */
60
    private $previousSsoOn2faCookieValue;
61
62
    /**
63
     * @var string
64
     */
65
    private $sessCookieName;
66
67
    /**
68
     * @var string
69
     */
70
    private $cookieDomain;
71
72
    public function __construct(
73
        FixtureService $fixtureService,
74
        DatabaseSchemaService $databaseSchemaService,
75
        LoggerInterface $logger
76
    ) {
77
        $this->fixtureService = $fixtureService;
78
        self::$databaseSchemaService = $databaseSchemaService;
79
        $this->sso2faCookieName = 'stepup-gateway_sso-on-second-factor-authentication';
80
        $this->sessCookieName = 'MOCKSESSID';
81
        $this->cookieDomain = '.gateway.dev.openconext.local';
82
83
        // Set a test container for the SAML2 Library to work with (the compat container is broken)
84
        ContainerSingleton::setContainer(new TestSaml2Container($logger));
85
    }
86
87
    #[\Behat\Hook\BeforeFeature]
88
    public static function setupDatabase(BeforeFeatureScope $scope): void
0 ignored issues
show
Unused Code introduced by
The parameter $scope is not used and could be removed. ( Ignorable by Annotation )

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

88
    public static function setupDatabase(/** @scrutinizer ignore-unused */ BeforeFeatureScope $scope): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
89
    {
90
        self::$databaseSchemaService->resetSchema();
91
    }
92
93
    #[\Behat\Hook\BeforeScenario]
94
    public function gatherContexts(BeforeScenarioScope $scope): void
95
    {
96
        $environment = $scope->getEnvironment();
97
        $this->minkContext = $environment->getContext(MinkContext::class);
0 ignored issues
show
Bug introduced by
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 ignore-call  annotation

97
        /** @scrutinizer ignore-call */ 
98
        $this->minkContext = $environment->getContext(MinkContext::class);
Loading history...
98
    }
99
100
    #[\Behat\Step\Given('/^a user from "([^"]*)" identified by "([^"]*)" with a vetted "([^"]*)" token$/')]
101
    public function aUserIdentifiedByWithAVettedToken($institution, $nameId, $tokenType): void
102
    {
103
        switch (strtolower($tokenType)) {
104
            case "yubikey":
105
                $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution);
106
                break;
107
            case "sms":
108
                $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution);
109
                break;
110
            case "tiqr":
111
                $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution);
112
                break;
113
        }
114
    }
115
116
    #[\Behat\Step\Given('/^a user from "([^"]*)" identified by "([^"]*)" with a self-asserted "([^"]*)" token$/')]
117
    public function aUserIdentifiedByWithASelfAssertedToken($institution, $nameId, $tokenType): void
118
    {
119
        switch (strtolower($tokenType)) {
120
            case "yubikey":
121
                $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution, true);
122
                break;
123
            case "sms":
124
                $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution, true);
125
                break;
126
            case "tiqr":
127
                $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution, true);
128
                break;
129
        }
130
    }
131
132
    #[\Behat\Step\Then('I should see the Yubikey OTP screen')]
133
    public function iShouldSeeTheYubikeyOtpScreen(): void
134
    {
135
        $this->minkContext->assertPageContainsText('Your YubiKey-code');
136
    }
137
138
    #[\Behat\Step\Then('I should see the SMS verification screen')]
139
    public function iShouldSeeTheSMSScreen(): void
140
    {
141
        $this->minkContext->assertPageContainsText('Enter the received SMS-code');
142
        $this->minkContext->assertPageContainsText('Send again');
143
    }
144
145
    #[\Behat\Step\Given('/^I should see the Tiqr authentication screen$/')]
146
    public function iShouldSeeTheTiqrAuthenticationScreen(): void
147
    {
148
        $this->pressButtonWhenNoJavascriptSupport();
149
        $this->minkContext->assertPageContainsText('Log in with Tiqr');
150
    }
151
152
    #[\Behat\Step\When('I enter the OTP')]
153
    public function iEnterTheOtp(): void
154
    {
155
        $this->minkContext->fillField('gateway_verify_yubikey_otp_otp', 'bogus-otp-we-use-a-mock-yubikey-service');
156
        $this->minkContext->pressButton('gateway_verify_yubikey_otp_submit');
157
        $this->pressButtonWhenNoJavascriptSupport();
158
    }
159
160
    #[\Behat\Step\When('I enter the SMS verification code')]
161
    public function iEnterTheSmsVerificationCode(): void
162
    {
163
        $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service');
164
        if ($cookieValue === null) {
165
            throw new RuntimeException('Unable to load the smoketest-sms-service cookie');
166
        }
167
        $matches = [];
168
        preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches);
169
        $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]);
170
        $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge');
171
        $this->pressButtonWhenNoJavascriptSupport();
172
    }
173
174
    #[\Behat\Step\When('I enter the expired SMS verification code')]
175
    public function iEnterTheExpiredSmsVerificationCode(): void
176
    {
177
        $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service');
178
        $matches = [];
179
        preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches);
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

179
        preg_match('/^Your\ SMS\ code:\ (.*)$/', /** @scrutinizer ignore-type */ $cookieValue, $matches);
Loading history...
180
        $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]);
181
        $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge');
182
    }
183
184
    #[\Behat\Step\When('I finish the Tiqr authentication')]
185
    public function iFinishGsspAuthentication(): void
186
    {
187
        $this->pressButtonWhenNoJavascriptSupport();
188
        $this->pressButtonWhenNoJavascriptSupport();
189
    }
190
191
    #[\Behat\Step\Given('/^a whitelisted institution ([^"]*)$/')]
192
    public function aWhitelistedInstitution($institution): void
193
    {
194
        $this->whitelistedInstitutions[] = $this->fixtureService->whitelist($institution)['institution'];
195
    }
196
197
    #[\Behat\Step\Given('/^an institution "([^"]*)" that allows "([^"]*)"$/')]
198
    public function anInstitutionThatAllows(string $institution, string $option): void
199
    {
200
        switch(true) {
201
            case $option === 'sso_on_2fa':
202
                $optionColumnName = 'sso_on2fa_enabled';
203
                break;
204
            case $option === 'sso_registration_bypass':
205
                $optionColumnName = 'sso_registration_bypass';
206
                break;
207
            default:
208
                throw new RuntimeException(sprintf('Option "%s" is not supported', $option));
209
        }
210
        $this->fixtureService->configureBoolean($institution, $optionColumnName, true);
211
    }
212
213
    #[\Behat\Step\Then('/^I select my ([^"]*) token on the WAYG$/')]
214
    public function iShouldSelectMyTokenOnTheWAYG($tokenType): void
215
    {
216
        switch (strtolower($tokenType)) {
217
            case "yubikey":
218
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_yubikey');
219
                break;
220
            case "sms":
221
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_sms');
222
                break;
223
            case "tiqr":
224
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_tiqr');
225
                break;
226
        }
227
    }
228
229
    #[\Behat\Step\Then('/^I should be on the WAYG$/')]
230
    public function iShouldBeOnTheWAYG(): void
231
    {
232
        $this->minkContext->assertPageContainsText('Choose a token for login');
233
    }
234
235
    #[\Behat\Step\Then('/^an error response is posted back to the SP$/')]
236
    public function anErrorResponseIsPostedBackToTheSP(): void
237
    {
238
        $this->pressButtonWhenNoJavascriptSupport();
239
    }
240
241
    #[\Behat\Step\Given('/^I cancel the authentication$/')]
242
    public function iCancelTheAuthentication(): void
243
    {
244
        $this->minkContext->pressButton('Cancel');
245
    }
246
247
    #[\Behat\Step\Given('/^I pass through the Gateway$/')]
248
    public function iPassThroughTheGateway(): void
249
    {
250
        $this->pressButtonWhenNoJavascriptSupport();
251
    }
252
253
    #[\Behat\Step\Given('/^I pass through the IdP/')]
254
    public function iPassThroughTheIdP(): void
255
    {
256
        if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) {
257
            $this->minkContext->pressButton('Yes, continue');
258
        }
259
    }
260
261
    /**
262
     * @throws ExpectationException
263
     */
264
    #[\Behat\Step\Then('/^the response should have a SSO\-2FA cookie$/')]
265
    public function theResponseShouldHaveASSO2FACookie(): void
266
    {
267
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
268
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
269
        // Store the previous cookie value
270
        $this->previousSsoOn2faCookieValue = $cookieValue;
271
        $this->validateSsoOn2faCookie($cookieValue);
272
    }
273
274
275
276
    /**
277
     * @throws ExpectationException
278
     */
279
    #[\Behat\Step\Then('/^the response should have a valid session cookie$/')]
280
    public function validateSessionCookie(): void
281
    {
282
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
283
284
        $driver = $this->minkContext->getSession()->getDriver();
285
286
        $cookie = $this->minkContext->getSession()->getCookie($this->sessCookieName);
287
        if ($cookie === null) {
288
            throw new ExpectationException(
289
                'No session cookie found',
290
                $this->minkContext->getSession()->getDriver()
291
            );
292
        }
293
294
        if (!$driver instanceof ChromeDriver) {
295
            return;
296
        }
297
298
        $sessionCookie = null;
299
        foreach ($driver->getCookies() as $cookie) {
300
            if ($cookie['name'] === $this->sessCookieName) {
301
                $sessionCookie = $cookie;
302
                break;
303
            }
304
        }
305
        if ($sessionCookie === null) {
306
            throw new ExpectationException(
307
                'No session cookie found',
308
                $this->minkContext->getSession()->getDriver()
309
            );
310
        }
311
312
        if (!array_key_exists('domain', $sessionCookie) || $sessionCookie['domain'] !== $this->cookieDomain) {
0 ignored issues
show
Bug introduced by
$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 ignore-type  annotation

312
        if (!array_key_exists('domain', /** @scrutinizer ignore-type */ $sessionCookie) || $sessionCookie['domain'] !== $this->cookieDomain) {
Loading history...
313
            throw new ExpectationException(
314
                'The domain of the session cookie is invalid',
315
                $this->minkContext->getSession()->getDriver()
316
            );
317
        };
318
    }
319
320
    /**
321
     * @throws ExpectationException
322
     */
323
    #[\Behat\Step\Then('/^the response should not have a SSO\-2FA cookie$/')]
324
    public function theResponseShouldNotHaveASSO2FACookie(): void
325
    {
326
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
327
        $cookie = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
328
        if (!is_null($cookie)) {
329
            throw new ExpectationException(
330
                'The SSO cookie must NOT be present',
331
                $this->minkContext->getSession()->getDriver()
332
            );
333
        }
334
    }
335
336
    /**
337
     * @throws ExpectationException
338
     */
339
    #[\Behat\Step\Then('/^a new SSO\-2FA cookie was written$/')]
340
    public function theSSO2FACookieIsRewritten(): void
341
    {
342
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
343
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
344
        $this->validateSsoOn2faCookie($cookieValue);
345
346
        if ($this->previousSsoOn2faCookieValue === $cookieValue) {
347
            throw new ExpectationException(
348
                sprintf('The SSO on 2FA cookie did not change since the previous response: "%s" !== "%s"', $this->previousSsoOn2faCookieValue, $cookieValue),
349
                $this->minkContext->getSession()->getDriver()
350
            );
351
        }
352
    }
353
354
    /**
355
     * @throws ExpectationException
356
     */
357
    #[\Behat\Step\Then('/^the existing SSO\-2FA cookie was used$/')]
358
    public function theSSO2FACookieRemainedTheSame(): void
359
    {
360
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
361
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
362
        $this->validateSsoOn2faCookie($cookieValue);
363
        if ($this->previousSsoOn2faCookieValue !== $cookieValue) {
364
            throw new ExpectationException(
365
                sprintf(
366
                    'The SSO on 2FA cookie changed since the previous response %s vs %s',
367
                    $this->previousSsoOn2faCookieValue,
368
                    $cookieValue
369
                ),
370
                $this->minkContext->getSession()->getDriver()
371
            );
372
        }
373
    }
374
375
    #[\Behat\Step\Given('/^the user cleared cookies from browser$/')]
376
    public function userClearedCookies(): void
377
    {
378
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
379
        $this->minkContext->getSession()->setCookie($this->sso2faCookieName, null);
380
    }
381
382
    #[\Behat\Step\Given('/^the SSO\-2FA cookie should contain "([^"]*)"$/')]
383
    public function theSSO2FACookieShouldContain($expectedCookieValue): void
384
    {
385
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
386
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
387
        if (strstr($cookieValue, $expectedCookieValue) === false) {
0 ignored issues
show
Bug introduced by
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 ignore-type  annotation

387
        if (strstr(/** @scrutinizer ignore-type */ $cookieValue, $expectedCookieValue) === false) {
Loading history...
388
            throw new ExpectationException(
389
                sprintf(
390
                    'The SSO on 2FA cookie did not contain the expected value: "%s", actual contents: "%s"',
391
                    $expectedCookieValue,
392
                    $cookieValue
393
                ),
394
                $this->minkContext->getSession()->getDriver()
395
            );
396
        }
397
    }
398
399
    private function getCookieNames(array $responseCookieHeaders): array
0 ignored issues
show
Unused Code introduced by
The method getCookieNames() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
400
    {
401
        $response = [];
402
        foreach($responseCookieHeaders as $cookie) {
403
            $parts = explode('=', $cookie);
404
            $response[] = array_shift($parts);
405
        }
406
        return $response;
407
    }
408
409
    /**
410
     * @throws ExpectationException
411
     */
412
    private function validateSsoOn2faCookie(?string $cookieValue): void
413
    {
414
        if (empty($cookieValue)) {
415
            throw new ExpectationException(
416
                sprintf(
417
                    'The SSO on 2FA cookie was not present, or empty. Cookie name: %s',
418
                    $this->sso2faCookieName
419
                ),
420
                $this->minkContext->getSession()->getDriver()
421
            );
422
        }
423
    }
424
425
    private function pressButtonWhenNoJavascriptSupport()
426
    {
427
        if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) {
428
            $this->minkContext->pressButton('Submit');
429
        }
430
    }
431
}
432