FeatureContext::iShouldSeeTheYubikeyOtpScreen()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
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
    /**
42
     * @var DatabaseSchemaService
43
     */
44
    private static $databaseSchemaService;
45
46
    private $whitelistedInstitutions = [];
47
48
    /**
49
     * @var MinkContext
50
     */
51
    private $minkContext;
52
53
    /**
54
     * @var array
55
     */
56
    private $currentToken;
57
58
    private $sso2faCookieName;
59
60
    /**
61
     * @var string|null
62
     */
63
    private $previousSsoOn2faCookieValue;
64
65
    /**
66
     * @var string
67
     */
68
    private $sessCookieName;
69
70
    /**
71
     * @var string
72
     */
73
    private $cookieDomain;
74
75
    public function __construct(
76
        FixtureService $fixtureService,
77
        DatabaseSchemaService $databaseSchemaService,
78
        LoggerInterface $logger
79
    ) {
80
        $this->fixtureService = $fixtureService;
81
        self::$databaseSchemaService = $databaseSchemaService;
82
        $this->sso2faCookieName = 'stepup-gateway_sso-on-second-factor-authentication';
83
        $this->sessCookieName = 'MOCKSESSID';
84
        $this->cookieDomain = '.gateway.dev.openconext.local';
85
86
        // Set a test container for the SAML2 Library to work with (the compat container is broken)
87
        ContainerSingleton::setContainer(new TestSaml2Container($logger));
88
    }
89
90
    #[\Behat\Hook\BeforeFeature]
91
    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

91
    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...
92
    {
93
        self::$databaseSchemaService->resetSchema();
94
    }
95
96
    #[\Behat\Hook\BeforeScenario]
97
    public function gatherContexts(BeforeScenarioScope $scope): void
98
    {
99
        $environment = $scope->getEnvironment();
100
        $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

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

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

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

390
        if (strstr(/** @scrutinizer ignore-type */ $cookieValue, $expectedCookieValue) === false) {
Loading history...
391
            throw new ExpectationException(
392
                sprintf(
393
                    'The SSO on 2FA cookie did not contain the expected value: "%s", actual contents: "%s"',
394
                    $expectedCookieValue,
395
                    $cookieValue
396
                ),
397
                $this->minkContext->getSession()->getDriver()
398
            );
399
        }
400
    }
401
402
    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...
403
    {
404
        $response = [];
405
        foreach($responseCookieHeaders as $cookie) {
406
            $parts = explode('=', $cookie);
407
            $response[] = array_shift($parts);
408
        }
409
        return $response;
410
    }
411
412
    /**
413
     * @throws ExpectationException
414
     */
415
    private function validateSsoOn2faCookie(?string $cookieValue): void
416
    {
417
        if (empty($cookieValue)) {
418
            throw new ExpectationException(
419
                sprintf(
420
                    'The SSO on 2FA cookie was not present, or empty. Cookie name: %s',
421
                    $this->sso2faCookieName
422
                ),
423
                $this->minkContext->getSession()->getDriver()
424
            );
425
        }
426
    }
427
428
    private function pressButtonWhenNoJavascriptSupport()
429
    {
430
        if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) {
431
            $this->minkContext->pressButton('Submit');
432
        }
433
    }
434
}
435