diff --git a/README.md b/README.md index 573712a1..32810e44 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ The API is described by [api.raml](api.raml), and an auto-generated [api.html](a ## Running tests interactively locally 1. Run `make testcli` to build and start needed containers and drop you in a shell -2. Run desired tests, example: `./vendor/bin/behat features/authentication.feature` +2. Run desired tests. Examples: + * `./vendor/bin/behat features/authentication.feature` + * `./vendor/bin/behat features/authentication.feature:298` ## Google Analytics Calls Calls are made to Google Analytics regarding users' mfas and whether a password has been pwned. diff --git a/actions-services.yml b/actions-services.yml index 5bc7509f..864c4b8f 100644 --- a/actions-services.yml +++ b/actions-services.yml @@ -16,7 +16,12 @@ services: - u2fsim working_dir: /data environment: + API_KEY_TABLE: ApiKey APP_ENV: test + AWS_ENDPOINT: dynamo:8000 + AWS_DEFAULT_REGION: us-east-1 + AWS_ACCESS_KEY_ID: abc123 + AWS_SECRET_ACCESS_KEY: abc123 EMAIL_SERVICE_accessToken: fake-abc-123 EMAIL_SERVICE_assertValidIp: "false" EMAIL_SERVICE_baseUrl: http://email diff --git a/application/behat.yml b/application/behat.yml index eabe85c3..5d93e463 100644 --- a/application/behat.yml +++ b/application/behat.yml @@ -2,49 +2,52 @@ default: suites: common_features: paths: - - "%paths.base%/features/authentication.feature" - - "%paths.base%/features/password.feature" - - "%paths.base%/features/user.feature" - - "%paths.base%/features/user-unit-tests.feature" - - "%paths.base%/features/user-search.feature" + - "features/password.feature" + - "features/user.feature" + - "features/user-unit-tests.feature" + - "features/user-search.feature" contexts: [ FeatureContext, Sil\SilIdBroker\Behat\Context\UnitTestsContext ] analytics_features: paths: - - "%paths.base%/features/analytics.feature" + - "features/analytics.feature" contexts: [ FeatureContext, Sil\SilIdBroker\Behat\Context\AnalyticsContext ] + authentication_features: + paths: + - "features/authentication.feature" + contexts: [ Sil\SilIdBroker\Behat\Context\AuthenticationContext ] email_features: paths: - - "%paths.base%/features/email.feature" + - "features/email.feature" contexts: [ Sil\SilIdBroker\Behat\Context\EmailContext ] hibp_unit_tests_features: paths: - - "%paths.base%/features/hibp-unit-tests.feature" + - "features/hibp-unit-tests.feature" contexts: [ Sil\SilIdBroker\Behat\Context\HibpUnitTestsContext ] invite_features: paths: - - "%paths.base%/features/invite.feature" + - "features/invite.feature" contexts: [ Sil\SilIdBroker\Behat\Context\UnitTestsContext ] method_features: paths: - - "%paths.base%/features/method.feature" + - "features/method.feature" contexts: [ Sil\SilIdBroker\Behat\Context\MethodContext ] mfa_features: paths: - - "%paths.base%/features/mfa.feature" + - "features/mfa.feature" contexts: [ Sil\SilIdBroker\Behat\Context\MfaContext ] mfa_rate_limit_features: paths: - - "%paths.base%/features/mfa-rate-limit.feature" + - "features/mfa-rate-limit.feature" contexts: [ Sil\SilIdBroker\Behat\Context\MfaRateLimitContext ] mfa_unit_tests_features: paths: - - "%paths.base%/features/mfa-unit-tests.feature" + - "features/mfa-unit-tests.feature" contexts: [ Sil\SilIdBroker\Behat\Context\MfaUnitTestsContext ] mysql_date_time_features: paths: - - "%paths.base%/features/mysql-date-time.feature" + - "features/mysql-date-time.feature" contexts: [ Sil\SilIdBroker\Behat\Context\MySqlDateTimeContext ] sheets_unit_tests_features: paths: - - "%paths.base%/features/sheets-unit-tests.feature" + - "features/sheets-unit-tests.feature" contexts: [ Sil\SilIdBroker\Behat\Context\SheetsUnitTestsContext ] diff --git a/application/composer.json b/application/composer.json index cfbbfc10..5276ebb9 100644 --- a/application/composer.json +++ b/application/composer.json @@ -29,6 +29,7 @@ "notamedia/yii2-sentry": "^1.7" }, "require-dev": { + "aws/aws-sdk-php": "~3.288.1", "behat/behat": "^3.3", "roave/security-advisories": "dev-master", "webmozart/assert": "^1.2", diff --git a/application/composer.lock b/application/composer.lock index ba9b0a4c..f613e2b7 100644 --- a/application/composer.lock +++ b/application/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2a0ac91096f615d3c499c4a0c58fd9f3", + "content-hash": "f20e5e2917e9b9f5e0c278a1328b9ab4", "packages": [ { "name": "bower-asset/inputmask", @@ -5311,6 +5311,155 @@ } ], "packages-dev": [ + { + "name": "aws/aws-crt-php", + "version": "v1.2.5", + "source": { + "type": "git", + "url": "https://github.com/awslabs/aws-crt-php.git", + "reference": "0ea1f04ec5aa9f049f97e012d1ed63b76834a31b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/0ea1f04ec5aa9f049f97e012d1ed63b76834a31b", + "reference": "0ea1f04ec5aa9f049f97e012d1ed63b76834a31b", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35||^5.6.3||^9.5", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality." + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "AWS SDK Common Runtime Team", + "email": "aws-sdk-common-runtime@amazon.com" + } + ], + "description": "AWS Common Runtime for PHP", + "homepage": "https://github.com/awslabs/aws-crt-php", + "keywords": [ + "amazon", + "aws", + "crt", + "sdk" + ], + "support": { + "issues": "https://github.com/awslabs/aws-crt-php/issues", + "source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.5" + }, + "time": "2024-04-19T21:30:56+00:00" + }, + { + "name": "aws/aws-sdk-php", + "version": "3.288.1", + "source": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-php.git", + "reference": "a1dfa12c7165de0b731ae8074c4ba1f3ae733f89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a1dfa12c7165de0b731ae8074c4ba1f3ae733f89", + "reference": "a1dfa12c7165de0b731ae8074c4ba1f3ae733f89", + "shasum": "" + }, + "require": { + "aws/aws-crt-php": "^1.2.3", + "ext-json": "*", + "ext-pcre": "*", + "ext-simplexml": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "guzzlehttp/promises": "^1.4.0 || ^2.0", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "mtdowling/jmespath.php": "^2.6", + "php": ">=7.2.5", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "andrewsville/php-token-reflection": "^1.4", + "aws/aws-php-sns-message-validator": "~1.0", + "behat/behat": "~3.0", + "composer/composer": "^1.10.22", + "dms/phpunit-arraysubset-asserts": "^0.4.0", + "doctrine/cache": "~1.4", + "ext-dom": "*", + "ext-openssl": "*", + "ext-pcntl": "*", + "ext-sockets": "*", + "nette/neon": "^2.3", + "paragonie/random_compat": ">= 2", + "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5", + "psr/cache": "^1.0", + "psr/simple-cache": "^1.0", + "sebastian/comparator": "^1.2.3 || ^4.0", + "yoast/phpunit-polyfills": "^1.0" + }, + "suggest": { + "aws/aws-php-sns-message-validator": "To validate incoming SNS notifications", + "doctrine/cache": "To use the DoctrineCacheAdapter", + "ext-curl": "To send requests using cURL", + "ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages", + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Aws\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Amazon Web Services", + "homepage": "http://aws.amazon.com" + } + ], + "description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project", + "homepage": "http://aws.amazon.com/sdkforphp", + "keywords": [ + "amazon", + "aws", + "cloud", + "dynamodb", + "ec2", + "glacier", + "s3", + "sdk" + ], + "support": { + "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", + "issues": "https://github.com/aws/aws-sdk-php/issues", + "source": "https://github.com/aws/aws-sdk-php/tree/3.288.1" + }, + "time": "2023-11-22T19:35:38+00:00" + }, { "name": "behat/behat", "version": "v3.14.0", @@ -5821,6 +5970,72 @@ ], "time": "2024-05-07T15:50:05+00:00" }, + { + "name": "mtdowling/jmespath.php", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/jmespath/jmespath.php.git", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/bbb69a935c2cbb0c03d7f481a238027430f6440b", + "reference": "bbb69a935c2cbb0c03d7f481a238027430f6440b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-mbstring": "^1.17" + }, + "require-dev": { + "composer/xdebug-handler": "^3.0.3", + "phpunit/phpunit": "^8.5.33" + }, + "bin": [ + "bin/jp.php" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "files": [ + "src/JmesPath.php" + ], + "psr-4": { + "JmesPath\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Declaratively specify how to extract elements from a JSON document", + "keywords": [ + "json", + "jsonpath" + ], + "support": { + "issues": "https://github.com/jmespath/jmespath.php/issues", + "source": "https://github.com/jmespath/jmespath.php/tree/2.7.0" + }, + "time": "2023-08-25T10:54:48+00:00" + }, { "name": "psr/event-dispatcher", "version": "1.0.0", diff --git a/application/features/authentication.feature b/application/features/authentication.feature index 5e776f50..7b1ac5b7 100644 --- a/application/features/authentication.feature +++ b/application/features/authentication.feature @@ -294,3 +294,19 @@ Feature: Authentication Then the response status code should be 200 And The user's current password should be marked as pwned And The user's password is expired + + Scenario Outline: Successfully authenticating even if the WebAuthn MFA API is unusable + Given "shep_clark" has a valid WebAuthn MFA method + And I provide the following valid data: + | property | value | + | username | shep_clark | + | password | govols!!! | + And we have the for the WebAuthn MFA API + When I request "/authentication" be created + Then the response status code should be 200 + And the response body should + + Examples: + | rightOrWrongPassword | containPublicKeyOrNot | + | wrong password | not contain "publicKey" | + | right password | contain "publicKey" | diff --git a/application/features/bootstrap/AuthenticationContext.php b/application/features/bootstrap/AuthenticationContext.php new file mode 100644 index 00000000..ff91ac43 --- /dev/null +++ b/application/features/bootstrap/AuthenticationContext.php @@ -0,0 +1,114 @@ +setWebAuthnApiSecretTo(Env::get('MFA_WEBAUTHN_apiSecret')); + } + + /** + * @Given :username has a valid WebAuthn MFA method + */ + public function userHasAValidWebauthnMfaMethod($username) + { + $user = User::findByUsername($username); + Assert::notEmpty($user, 'Unable to find user ' . $username); + + $creationResult = Mfa::create($user->id, Mfa::TYPE_WEBAUTHN); + + $publicKey = $creationResult['data']['publicKey']; + $rpId = $publicKey['rp']['id']; + $mfa = Mfa::findOne(['id' => $creationResult['id']]); + Assert::notEmpty($mfa, sprintf( + "Unable to find MFA after creation, response was: \n%s", + json_encode($creationResult, JSON_PRETTY_PRINT) + )); + + $u2fSimResponse = $this->simulateU2fDevice($publicKey['challenge'], $rpId, $user, $mfa); + + $mfaVerifyResult = $mfa->verify( + $u2fSimResponse, + $rpId, + 'registration' + ); + Assert::true($mfaVerifyResult, 'Failed to verify the WebAuthn MFA'); + } + + /** + * Simulate the browser interactions for registering a U2F/WebAuthn device. + * + * @param $challenge + * @param $rpId + * @param User|null $user + * @param Mfa|null $mfa + * @return array|mixed + */ + public function simulateU2fDevice($challenge, $rpId, User $user, Mfa $mfa) + { + $this->cleanRequestBody(); + $this->setRequestBody('challenge', $challenge); + $this->setRequestBody('relying_party_id', $rpId); + $this->callU2fSimulator('/u2f/registration', 'created', $user, $mfa->external_uuid); + $u2fSimResponse = $this->getResponseBody(); + if (isset($u2fSimResponse['clientExtensionResults']) && empty($u2fSimResponse['clientExtensionResults'])) { + // Force JSON-encoding to treat this as an empty object, not an empty array. + $u2fSimResponse['clientExtensionResults'] = new stdClass(); + } + return $u2fSimResponse; + } + + /** + * @Given we have the wrong password for the WebAuthn MFA API + */ + public function weHaveTheWrongPasswordForTheWebauthnMfaApi() + { + /* This is setting the API secret to something else, so that the one + * ID Broker has is NOT correct anymore. */ + $this->setWebAuthnApiSecretTo('something different'); + } + + protected function setWebAuthnApiSecretTo(string $newPlainTextApiSecret) + { + $newHashedApiSecret = password_hash($newPlainTextApiSecret, PASSWORD_BCRYPT); + $dynamoDbClient = new DynamoDbClient([ + 'region' => getenv('AWS_DEFAULT_REGION'), + 'endpoint' => getenv('AWS_ENDPOINT'), + 'disableSSL' => true, + 'version' => '2012-08-10', + ]); + $dynamoDbClient->updateItem([ + 'Key' => [ + 'value' => [ + 'S' => Env::get('MFA_WEBAUTHN_apiKey'), + ], + ], + 'UpdateExpression' => 'set hashedApiSecret = :newHashedApiSecret', + 'ExpressionAttributeValues' => [ + ':newHashedApiSecret' => [ + 'S' => $newHashedApiSecret, + ], + ], + 'TableName' => Env::get('API_KEY_TABLE'), + ]); + } + + /** + * @Given we have the right password for the WebAuthn MFA API + */ + public function weHaveTheRightPasswordForTheWebauthnMfaApi() + { + $this->setWebAuthnApiSecretTo(Env::get('MFA_WEBAUTHN_apiSecret')); + } +} diff --git a/application/features/bootstrap/FeatureContext.php b/application/features/bootstrap/FeatureContext.php index d490b713..45e74eac 100644 --- a/application/features/bootstrap/FeatureContext.php +++ b/application/features/bootstrap/FeatureContext.php @@ -262,6 +262,22 @@ public function theResponseBodyShouldContain($containsText) ); } + /** + * @Then the response body should not contain :notContainsText + */ + public function theResponseBodyShouldNotContain($notContainsText) + { + Assert::notContains( + var_export($this->resBody, true), + $notContainsText, + sprintf( + "Unexpected response body. Should not contain: %s, body=%s", + $notContainsText, + var_export($this->resBody, true) + ) + ); + } + /** * @Then /^the property (\w+) should contain "(.*)"$/ */ diff --git a/docker-compose.yml b/docker-compose.yml index df355b3b..5bf0c2be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,7 +162,12 @@ services: environment: TEST_SERVER_HOSTNAME: appfortests API_ACCESS_KEYS: api-test-NOTASECRET + API_KEY_TABLE: ApiKey APP_ENV: test + AWS_ENDPOINT: dynamo:8000 + AWS_DEFAULT_REGION: us-east-1 + AWS_ACCESS_KEY_ID: abc123 + AWS_SECRET_ACCESS_KEY: abc123 EMAILER_CLASS: \Sil\SilIdBroker\Behat\Context\fakes\FakeEmailer EMAIL_SERVICE_accessToken: dummy EMAIL_SERVICE_assertValidIp: "false"