diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7b94d6b8..198c1a7b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,6 +2,12 @@ version: 2 updates: - package-ecosystem: "composer" directory: "/" + schedule: + interval: "weekly" ignore: - dependency-name: "symfony/*" - update-types: ["version-update:semver-major"] \ No newline at end of file + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/assets/typescript/AuthenticationPageService.ts b/assets/typescript/AuthenticationPageService.ts index 78297cc5..11ffde8d 100644 --- a/assets/typescript/AuthenticationPageService.ts +++ b/assets/typescript/AuthenticationPageService.ts @@ -134,6 +134,7 @@ export class AuthenticationPageService { this.switchToNotificationFailed(); break; case 'no-device': + case 'no-trusted-device': this.switchToNoDevice(); break; } diff --git a/assets/typescript/__test__/AuthenticationPageService.test.ts b/assets/typescript/__test__/AuthenticationPageService.test.ts index 245c3ab2..110b87c3 100644 --- a/assets/typescript/__test__/AuthenticationPageService.test.ts +++ b/assets/typescript/__test__/AuthenticationPageService.test.ts @@ -231,6 +231,15 @@ describe('AuthenticationPageService', () => { expect(spy).toBeCalled(); }); + it('Should show qr when there is no trusted-device cookie', () => { + if (!successCallback || !errorCallback) { + throw new Error('Should have started notification request'); + } + const spy = jest.spyOn(context.authenticationPageService, 'switchToNoDevice'); + successCallback('no-trusted-device'); + expect(spy).toBeCalled(); + }); + it('Should handle connection errors', () => { if (!successCallback || !errorCallback) { throw new Error('Should have started notification request'); diff --git a/ci/qa/phpstan-baseline.neon b/ci/qa/phpstan-baseline.neon index ae3ba535..a772c9ff 100644 --- a/ci/qa/phpstan-baseline.neon +++ b/ci/qa/phpstan-baseline.neon @@ -181,7 +181,7 @@ parameters: path: ../../dev/FileLogger.php - - message: "#^Method Surfnet\\\\Tiqr\\\\Dev\\\\FileLogger\\:\\:log\\(\\) has parameter \\$message with no type specified\\.$#" + message: "#^Parameter \\#1 \\$record of method League\\\\Csv\\\\Writer\\:\\:insertOne\\(\\) expects array\\, array\\ given\\.$#" count: 1 path: ../../dev/FileLogger.php @@ -240,11 +240,6 @@ parameters: count: 1 path: ../../src/Controller/TiqrAppApiController.php - - - message: "#^Parameter \\#2 \\$notificationType of method Surfnet\\\\Tiqr\\\\Controller\\\\TiqrAppApiController\\:\\:loginAction\\(\\) expects string, mixed given\\.$#" - count: 1 - path: ../../src/Controller/TiqrAppApiController.php - - message: "#^Parameter \\#2 \\$secret of method Surfnet\\\\Tiqr\\\\Tiqr\\\\TiqrUserRepositoryInterface\\:\\:createUser\\(\\) expects string, mixed given\\.$#" count: 1 @@ -255,16 +250,6 @@ parameters: count: 2 path: ../../src/Controller/TiqrAppApiController.php - - - message: "#^Parameter \\#3 \\$notificationAddress of method Surfnet\\\\Tiqr\\\\Controller\\\\TiqrAppApiController\\:\\:loginAction\\(\\) expects string, mixed given\\.$#" - count: 1 - path: ../../src/Controller/TiqrAppApiController.php - - - - message: "#^Parameter \\#3 \\$notificationType of method Surfnet\\\\Tiqr\\\\Controller\\\\TiqrAppApiController\\:\\:registerAction\\(\\) expects string, mixed given\\.$#" - count: 1 - path: ../../src/Controller/TiqrAppApiController.php - - message: "#^Parameter \\#3 \\$response of method Surfnet\\\\Tiqr\\\\Tiqr\\\\AuthenticationRateLimitServiceInterface\\:\\:authenticate\\(\\) expects string, mixed given\\.$#" count: 1 @@ -275,11 +260,6 @@ parameters: count: 2 path: ../../src/Controller/TiqrAppApiController.php - - - message: "#^Parameter \\#4 \\$notificationAddress of method Surfnet\\\\Tiqr\\\\Controller\\\\TiqrAppApiController\\:\\:registerAction\\(\\) expects string, mixed given\\.$#" - count: 1 - path: ../../src/Controller/TiqrAppApiController.php - - message: "#^Method Surfnet\\\\Tiqr\\\\DependencyInjection\\\\Configuration\\:\\:createBlockingConfig\\(\\) has no return type specified\\.$#" count: 1 @@ -332,7 +312,7 @@ parameters: - message: "#^Call to an undefined method Behat\\\\Mink\\\\Driver\\\\DriverInterface\\:\\:getClient\\(\\)\\.$#" - count: 2 + count: 6 path: ../../src/Features/Context/TiqrContext.php - @@ -342,7 +322,7 @@ parameters: - message: "#^Cannot access offset 'sari' on mixed\\.$#" - count: 2 + count: 3 path: ../../src/Features/Context/TiqrContext.php - @@ -352,12 +332,12 @@ parameters: - message: "#^Cannot access offset 1 on mixed\\.$#" - count: 1 + count: 2 path: ../../src/Features/Context/TiqrContext.php - message: "#^Cannot access offset 2 on mixed\\.$#" - count: 1 + count: 2 path: ../../src/Features/Context/TiqrContext.php - @@ -372,7 +352,7 @@ parameters: - message: "#^Cannot use array destructuring on mixed\\.$#" - count: 1 + count: 2 path: ../../src/Features/Context/TiqrContext.php - diff --git a/composer.json b/composer.json index 689083f1..58944651 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "ext-zlib": "*", "incenteev/composer-parameter-handler": "^2.2", "openconext/monitor-bundle": "^4.3.1", + "paragonie/halite": "^5.1", "surfnet/stepup-bundle": "^6.0.17", "surfnet/stepup-gssp-bundle": "^5.1", "surfnet/stepup-saml-bundle": "^6.1", @@ -48,7 +49,6 @@ "khanamiryan/qrcode-detector-decoder": "^2.0.2", "league/csv": "^9.18", "malukenho/docheader": "^1.1", - "mockery/mockery": "^1.6.12", "overtrue/phplint": ">=9.4.2", "phpmd/phpmd": "^2.15", "phpstan/phpstan": "^1.12.11", @@ -87,6 +87,7 @@ "unit-tests": "./ci/qa/phpunit", "behat": "./ci/qa/behat", "jest": "./ci/qa/jest", + "jscpd": "./ci/qa/jscpd", "encore": [ "yarn encore production" ], diff --git a/composer.lock b/composer.lock index 22985147..8f42d1ee 100644 --- a/composer.lock +++ b/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": "aa6fb4acac2ef1b36626ae98dbadfbd7", + "content-hash": "37dbe8ead3a382cd728ebb2494e323f4", "packages": [ { "name": "beberlei/assert", @@ -885,16 +885,16 @@ }, { "name": "google/apiclient-services", - "version": "v0.383.0", + "version": "v0.384.0", "source": { "type": "git", "url": "https://github.com/googleapis/google-api-php-client-services.git", - "reference": "0da092376837d363ef0cfa5ef56e43308f7c3763" + "reference": "fcb190bb02cea2c939e67867bf9122fd8d02c351" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/0da092376837d363ef0cfa5ef56e43308f7c3763", - "reference": "0da092376837d363ef0cfa5ef56e43308f7c3763", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/fcb190bb02cea2c939e67867bf9122fd8d02c351", + "reference": "fcb190bb02cea2c939e67867bf9122fd8d02c351", "shasum": "" }, "require": { @@ -923,9 +923,9 @@ ], "support": { "issues": "https://github.com/googleapis/google-api-php-client-services/issues", - "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.383.0" + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.384.0" }, - "time": "2024-11-20T01:10:23+00:00" + "time": "2024-11-27T01:08:46+00:00" }, { "name": "google/auth", @@ -1744,6 +1744,129 @@ }, "time": "2024-05-08T12:18:48+00:00" }, + { + "name": "paragonie/halite", + "version": "v5.1.2", + "source": { + "type": "git", + "url": "https://github.com/paragonie/halite.git", + "reference": "aee234711b29cccb4a17aaaf6104fc542862fc1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/halite/zipball/aee234711b29cccb4a17aaaf6104fc542862fc1e", + "reference": "aee234711b29cccb4a17aaaf6104fc542862fc1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "paragonie/constant_time_encoding": "^2|^3", + "paragonie/hidden-string": "^1|^2", + "paragonie/sodium_compat": "^1|^2", + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^9", + "vimeo/psalm": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\Halite\\": "./src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "High-level cryptography interface powered by libsodium", + "homepage": "https://github.com/paragonie/halite", + "keywords": [ + "Argon2i", + "BLAKE", + "BLAKE2", + "BLAKE2b", + "Curve25519", + "Ed25519", + "X25519", + "Xsalsa20", + "argon2", + "cryptography", + "encryption", + "ext-sodium", + "hashing", + "libsodium", + "password", + "public-key", + "signatures", + "sodium" + ], + "support": { + "docs": "https://github.com/paragonie/halite/tree/master/doc", + "issues": "https://github.com/paragonie/halite/issues", + "source": "https://github.com/paragonie/halite/tree/v5.1.2" + }, + "time": "2024-05-08T12:59:43+00:00" + }, + { + "name": "paragonie/hidden-string", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/hidden-string.git", + "reference": "87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/hidden-string/zipball/87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7", + "reference": "87886ab8ed7abb61c8bcf8d67cd3d3527feedbf7", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2|^3", + "php": "^7.4|^8" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\HiddenString\\": "./src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MPL-2.0" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Encapsulate strings in an object to hide them from stack traces", + "homepage": "https://github.com/paragonie/hidden-string", + "keywords": [ + "hidden", + "stack trace", + "string" + ], + "support": { + "issues": "https://github.com/paragonie/hidden-string/issues", + "source": "https://github.com/paragonie/hidden-string/tree/v2.2.0" + }, + "time": "2024-05-08T12:45:06+00:00" + }, { "name": "paragonie/random_compat", "version": "v9.99.100", @@ -8408,57 +8531,6 @@ "abandoned": "guzzlehttp/guzzle", "time": "2014-01-28T22:29:15+00:00" }, - { - "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", - "source": { - "type": "git", - "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "shasum": "" - }, - "require": { - "php": "^5.3|^7.0|^8.0" - }, - "replace": { - "cordoval/hamcrest-php": "*", - "davedevelopment/hamcrest-php": "*", - "kodova/hamcrest-php": "*" - }, - "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.1-dev" - } - }, - "autoload": { - "classmap": [ - "hamcrest" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "This is the PHP port of Hamcrest Matchers", - "keywords": [ - "test" - ], - "support": { - "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" - }, - "time": "2020-07-09T08:09:16+00:00" - }, { "name": "icecave/parity", "version": "1.0.0", @@ -9052,89 +9124,6 @@ }, "time": "2024-03-31T07:05:07+00:00" }, - { - "name": "mockery/mockery", - "version": "1.6.12", - "source": { - "type": "git", - "url": "https://github.com/mockery/mockery.git", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", - "shasum": "" - }, - "require": { - "hamcrest/hamcrest-php": "^2.0.1", - "lib-pcre": ">=7.0", - "php": ">=7.3" - }, - "conflict": { - "phpunit/phpunit": "<8.0" - }, - "require-dev": { - "phpunit/phpunit": "^8.5 || ^9.6.17", - "symplify/easy-coding-standard": "^12.1.14" - }, - "type": "library", - "autoload": { - "files": [ - "library/helpers.php", - "library/Mockery.php" - ], - "psr-4": { - "Mockery\\": "library/Mockery" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Pádraic Brady", - "email": "padraic.brady@gmail.com", - "homepage": "https://github.com/padraic", - "role": "Author" - }, - { - "name": "Dave Marshall", - "email": "dave.marshall@atstsolutions.co.uk", - "homepage": "https://davedevelopment.co.uk", - "role": "Developer" - }, - { - "name": "Nathanael Esayeas", - "email": "nathanael.esayeas@protonmail.com", - "homepage": "https://github.com/ghostwriter", - "role": "Lead Developer" - } - ], - "description": "Mockery is a simple yet flexible PHP mock object framework", - "homepage": "https://github.com/mockery/mockery", - "keywords": [ - "BDD", - "TDD", - "library", - "mock", - "mock objects", - "mockery", - "stub", - "test", - "test double", - "testing" - ], - "support": { - "docs": "https://docs.mockery.io/", - "issues": "https://github.com/mockery/mockery/issues", - "rss": "https://github.com/mockery/mockery/releases.atom", - "security": "https://github.com/mockery/mockery/security/advisories", - "source": "https://github.com/mockery/mockery" - }, - "time": "2024-05-16T03:13:13+00:00" - }, { "name": "myclabs/deep-copy", "version": "1.12.1", @@ -9291,20 +9280,20 @@ ], "type": "library", "extra": { - "bamarni-bin": { - "bin-links": true, - "target-directory": "vendor-bin", - "forward-command": true - }, "hooks": { + "pre-push": [ + "composer qa:check" + ], "pre-commit": [ "composer style:fix", "composer code:check" - ], - "pre-push": [ - "composer qa:check" ] }, + "bamarni-bin": { + "bin-links": true, + "forward-command": true, + "target-directory": "vendor-bin" + }, "branch-alias": { "dev-main": "9.4.x-dev" } @@ -11984,7 +11973,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -11992,7 +11981,7 @@ "ext-libxml": "*", "ext-zlib": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.2" }, diff --git a/config/openconext/parameters.yaml.dist b/config/openconext/parameters.yaml.dist index 555177c9..850dc62b 100644 --- a/config/openconext/parameters.yaml.dist +++ b/config/openconext/parameters.yaml.dist @@ -44,6 +44,26 @@ parameters: # PCRE as accepted by preg_match (http://php.net/preg_match). mobile_app_user_agent_pattern: "/^.*$/" + # The lifetime of the trusted device in seconds + # For example, 30 days is 2592000 seconds + trusted_device_cookie_lifetime: 2592000 + + # The name used for the trusted-device cookies + trusted_device_cookie_name: 'tiqr-trusted-device' + + # The same_site attribute of the trusted-device cookies + # Should be one of the strings in: \Surfnet\Tiqr\Service\TrustedDevice\Http\CookieSameSite: 'none', 'lax', 'strict' + trusted_device_cookie_same_site: 'none' + + # The secret key is used for the authenticated encryption (AE) of the trusted-device cookies, it is stored in the + # parameters.yaml of Stepup-Tiqr. This secret may only be used for the AE of the trusted device cookies. + # The secret key size is fixed, it must be 256 bits (32 bytes (64 hex digits)) + # We use hex encoding to store the key in the configuration, so the key will be 64 hex digits long + # Please use this encryption key only for this purpose, do not re-use it for other crypto work. + # + # Example value: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + trusted_device_encryption_key: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + # Options for the tiqr library tiqr_library_options: general: diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index f04eec90..8d92b795 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -40,5 +40,6 @@ when@test: test: true session: storage_factory_id: session.storage.factory.mock_file + name: MOCKSESSID profiler: collect: false diff --git a/config/services.yaml b/config/services.yaml index 8e2963ef..cbd06994 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -61,3 +61,15 @@ services: Surfnet\Tiqr\Controller\ExceptionController: arguments: $errorPageHelper: '@Surfnet\Tiqr\Service\ErrorPageHelper' + + Surfnet\Tiqr\Service\TrustedDevice\Crypto\CryptoHelperInterface: + class: Surfnet\Tiqr\Service\TrustedDevice\Crypto\HaliteCryptoHelper + + Surfnet\Tiqr\Service\TrustedDevice\ValueObject\Configuration: + public: false + arguments: + - "%trusted_device_cookie_name%" + - "%trusted_device_cookie_lifetime%" + - "%trusted_device_encryption_key%" + - "%trusted_device_cookie_same_site%" + diff --git a/config/services_test.yaml b/config/services_test.yaml index 67be77dc..63a27b78 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -17,5 +17,7 @@ services: - '/^Behat UA$/' surfnet_gssp.value_store.service: - class: Surfnet\GsspBundle\Service\ValueStore\InMemoryValueStore + class: Surfnet\Tiqr\Features\Framework\FileValueStore public: true + arguments: + $filePath: '/var/www/html/var/gssp_store.json' diff --git a/dev/FileLogger.php b/dev/FileLogger.php index 372a4a04..a5736205 100644 --- a/dev/FileLogger.php +++ b/dev/FileLogger.php @@ -22,6 +22,7 @@ use League\Csv\Reader; use League\Csv\Writer; use Psr\Log\AbstractLogger; +use Stringable; use Symfony\Component\HttpKernel\Kernel; final class FileLogger extends AbstractLogger @@ -30,11 +31,17 @@ public function __construct(private readonly Kernel $kernel) { } - public function log($level, $message, array $context = []): void + public function log($level, string|Stringable $message, array $context = []): void { if ($level === 'debug') { return; } + + if (!file_exists($this->getCSVFile())) { + touch($this->getCSVFile()); + chmod($this->getCSVFile(), 0666); + } + $file = fopen($this->getCSVFile(), 'ab+'); if (!$file) { return; diff --git a/src/Controller/AuthenticationNotificationController.php b/src/Controller/AuthenticationNotificationController.php index f0f0568d..237be4e3 100644 --- a/src/Controller/AuthenticationNotificationController.php +++ b/src/Controller/AuthenticationNotificationController.php @@ -26,12 +26,14 @@ use Surfnet\GsspBundle\Service\AuthenticationService; use Surfnet\GsspBundle\Service\StateHandlerInterface; use Surfnet\Tiqr\Attribute\RequiresActiveSession; +use Surfnet\Tiqr\Service\TrustedDevice\TrustedDeviceService; use Surfnet\Tiqr\Tiqr\Exception\UserNotExistsException; use Surfnet\Tiqr\Tiqr\TiqrServiceInterface; use Surfnet\Tiqr\Tiqr\TiqrUserRepositoryInterface; use Surfnet\Tiqr\WithContextLogger; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -45,7 +47,8 @@ public function __construct( private readonly StateHandlerInterface $stateHandler, private readonly TiqrServiceInterface $tiqrService, private readonly TiqrUserRepositoryInterface $userRepository, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, + private readonly TrustedDeviceService $trustedDeviceService, ) { } @@ -54,7 +57,7 @@ public function __construct( */ #[Route(path: '/authentication/notification', name: 'app_identity_authentication_notification', methods: ['POST'])] #[RequiresActiveSession] - public function __invoke(): Response + public function __invoke(Request $request): Response { $nameId = $this->authenticationService->getNameId(); $sari = $this->stateHandler->getRequestId(); @@ -87,24 +90,49 @@ public function __invoke(): Response $notificationType = $user->getNotificationType(); $notificationAddress = $user->getNotificationAddress(); - if ($notificationType && $notificationAddress) { - $this->logger->notice(sprintf( - 'Sending push notification for user "%s" with type "%s" and (untranslated) address "%s"', - $nameId, - $notificationType, - $notificationAddress - )); + if (!$notificationType || !$notificationAddress) { + $this->logger->notice(sprintf('No notification address for user "%s", no notification was sent', $nameId)); - $result = $this->sendNotification($notificationType, $notificationAddress); - if ($result) { - return $this->generateNotificationResponse('success'); - } - return $this->generateNotificationResponse('error'); + return $this->generateNotificationResponse('no-device'); } - $this->logger->notice(sprintf('No notification address for user "%s", no notification was sent', $nameId)); + $cookie = $this->trustedDeviceService->read($request); + if ($cookie === null) { + $this->logger->notice( + sprintf( + 'No trusted device cookie stored for notification address "%s" and user "%s". No notification was sent', + $notificationAddress, + $nameId + ) + ); + return $this->generateNotificationResponse('no-trusted-device'); + } - return $this->generateNotificationResponse('no-device'); + if ($this->trustedDeviceService->isTrustedDevice($cookie, $notificationAddress) === false) { + $this->logger->notice( + sprintf( + 'A trusted device cookie is found for notification address "%s" and user "%s", but has signature mismatch', + $notificationAddress, + $nameId + ) + ); + + return $this->generateNotificationResponse('no-trusted-device'); + } + + + $this->logger->notice(sprintf( + 'Sending push notification for user "%s" with type "%s" and (untranslated) address "%s"', + $nameId, + $notificationType, + $notificationAddress + )); + + $result = $this->sendNotification($notificationType, $notificationAddress); + if ($result) { + return $this->generateNotificationResponse('success'); + } + return $this->generateNotificationResponse('error'); } /** diff --git a/src/Controller/TiqrAppApiController.php b/src/Controller/TiqrAppApiController.php index ebf63b1a..cf8bc063 100644 --- a/src/Controller/TiqrAppApiController.php +++ b/src/Controller/TiqrAppApiController.php @@ -22,10 +22,12 @@ use Exception; use Psr\Log\LoggerInterface; +use Surfnet\Tiqr\Service\TrustedDevice\TrustedDeviceService; use Surfnet\Tiqr\Service\UserAgentMatcherInterface; use Surfnet\Tiqr\Tiqr\AuthenticationRateLimitServiceInterface; use Surfnet\Tiqr\Tiqr\Exception\UserNotExistsException; use Surfnet\Tiqr\Tiqr\TiqrServiceInterface; +use Surfnet\Tiqr\Tiqr\TiqrUserInterface; use Surfnet\Tiqr\Tiqr\TiqrUserRepositoryInterface; use Surfnet\Tiqr\WithContextLogger; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -33,12 +35,15 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Throwable; /** * The api that connects to the Tiqr app. * * Keep in mind that the endpoint routers cannot change because of the 'old' * clients are depending on this. + * + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ class TiqrAppApiController extends AbstractController { @@ -46,7 +51,8 @@ public function __construct( private readonly TiqrServiceInterface $tiqrService, private readonly TiqrUserRepositoryInterface $userRepository, private readonly AuthenticationRateLimitServiceInterface $authenticationRateLimitService, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, + private readonly TrustedDeviceService $cookieService, ) { } @@ -116,7 +122,14 @@ public function tiqr(UserAgentMatcherInterface $userAgentMatcher, Request $reque } $notificationType = $request->get('notificationType', ''); + if (!is_string($notificationType)) { + $notificationType = ''; + } $notificationAddress = $request->get('notificationAddress', ''); + if (!is_string($notificationAddress)) { + $notificationAddress = ''; + } + if ($operation === 'register') { $this->logger->notice( 'Got POST with registration response', @@ -138,6 +151,8 @@ public function tiqr(UserAgentMatcherInterface $userAgentMatcher, Request $reque /** * @SuppressWarnings(PHPMD.NPathComplexity) + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * * @throws \InvalidArgumentException */ @@ -234,7 +249,15 @@ private function registerAction( $logger->warning('Error finalizing enrollment', ['exception' => $e]); } - return new Response('OK', Response::HTTP_OK); + $okResponse = new Response('OK', Response::HTTP_OK); + + try { + $this->registerTrustedDevice($notificationAddress, $okResponse); + } catch (Throwable $e) { + $logger->warning('Could not register trusted device on registration', ['exception' => $e]); + } + + return $okResponse; } /** Handle login operation from the app, returns response for the app @@ -294,13 +317,21 @@ private function loginAction(Request $request, string $notificationType, string if ($result->isValid()) { $logger->notice('User authenticated ' . $result->getMessage()); + $responseObject = new Response($result->getMessage(), Response::HTTP_OK); try { $user->updateNotification($notificationType, $notificationAddress); } catch (Exception $e) { $this->logger->warning('Error updating notification type and address', ['exception' => $e]); // Continue } - return new Response($result->getMessage(), Response::HTTP_OK); + + try { + $this->registerTrustedDevice($notificationAddress, $responseObject); + } catch (Throwable $e) { + $this->logger->warning('Could not create trusted device cookie.', ['exception' => $e]); + } + + return $responseObject; } $logger->notice('User authentication denied: ' . $result->getMessage()); @@ -311,4 +342,16 @@ private function loginAction(Request $request, string $notificationType, string return new Response('AUTHENTICATION_FAILED', Response::HTTP_FORBIDDEN); } + + private function registerTrustedDevice( + string $notificationAddress, + Response $responseObject + ): void { + if (trim($notificationAddress) !== '') { + $this->cookieService->registerTrustedDevice( + $responseObject, + $notificationAddress + ); + } + } } diff --git a/src/Features/Context/TiqrContext.php b/src/Features/Context/TiqrContext.php index 872f9b7d..206b8aa8 100644 --- a/src/Features/Context/TiqrContext.php +++ b/src/Features/Context/TiqrContext.php @@ -24,16 +24,21 @@ use Assert\AssertionFailedException; use Behat\Behat\Context\Context; use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Behat\Tester\Exception\PendingException; use Behat\Gherkin\Node\TableNode; +use Behat\Mink\Driver\BrowserKitDriver; use Behat\MinkExtension\Context\MinkContext; use Exception; -use GuzzleHttp\Client; use GuzzleHttp\Exception\GuzzleException; use OCRA; use RuntimeException; use stdClass; use Surfnet\SamlBundle\Exception\NotFound; use Surfnet\Tiqr\Dev\FileLogger; +use Surfnet\Tiqr\Service\TrustedDevice\Crypto\HaliteCryptoHelper; +use Surfnet\Tiqr\Service\TrustedDevice\TrustedDeviceService; +use Surfnet\Tiqr\Service\TrustedDevice\ValueObject\Configuration; +use Surfnet\Tiqr\Service\TrustedDevice\ValueObject\CookieValue; use Surfnet\Tiqr\Tiqr\Exception\UserNotExistsException; use Surfnet\Tiqr\Tiqr\TiqrConfigurationInterface; use Surfnet\Tiqr\Tiqr\TiqrUserRepositoryInterface; @@ -41,6 +46,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; use Zxing\QrReader; +use Symfony\Component\BrowserKit\Cookie; /** * With this context Tiqr can be tested without an active Saml AuthnNRequest. @@ -78,11 +84,11 @@ public function __construct( private readonly TiqrUserRepositoryInterface $tiqrUserRepository, private readonly TiqrConfigurationInterface $configuration, private readonly FileLogger $fileLogger, - private readonly KernelInterface $kernel + private readonly KernelInterface $kernel, + private readonly TrustedDeviceService $trustedDeviceService, ) { } - /** * Fetch the required contexts. * @@ -182,10 +188,10 @@ public function userRegisterTheService( 'notificationType' => $notificationType, 'notificationAddress' => $notificationAddress, ]; - if ($notificationType == 'NULL') { + if ($notificationType === 'NULL') { unset($registrationBody['notificationType']); } - if ($notificationAddress == 'NULL') { + if ($notificationAddress === 'NULL') { unset($registrationBody['notificationAddress']); } @@ -236,10 +242,10 @@ public function appAuthenticates( 'notificationType' => $notificationType, 'notificationAddress' => $notificationAddress, ]; - if ($notificationType == 'NULL') { + if ($notificationType === 'NULL') { unset($authenticationBody['notificationType']); } - if ($notificationAddress == 'NULL') { + if ($notificationAddress === 'NULL') { unset($authenticationBody['notificationAddress']); } // Internal request does not like an absolute path. @@ -339,6 +345,22 @@ public function weHaveAAuthenticatedUser(): void Assertion::eq($this->authenticatioResponse->getStatusCode(), 200); } + /** + * @Given we have a trusted cookie for address: :arg1 + */ + public function weHaveATrustedDevice(string $notificationAddress): void + { + $cookieJar = $this->authenticatioResponse->headers->getCookies(); + + $request = new Request(); + foreach ($cookieJar as $cookie) { + $request->cookies->set($cookie->getName(), $cookie->getValue()); + } + $cookieValue = $this->trustedDeviceService->read($request); + Assertion::isInstanceOf($cookieValue, CookieValue::class); + Assertion::true($this->trustedDeviceService->isTrustedDevice($cookieValue, $notificationAddress)); + } + /** * @Then we have the authentication error :error * @@ -495,6 +517,26 @@ public function theLogsAre(TableNode $table): void } } + /** + * @Given /^the logs are dumped:$/ + * + * @throws AssertionFailedException + * @throws Exception + */ + public function theLogsAreDumped(TableNode $table): void + { + $logs = $this->fileLogger->cleanLogs(); + $output = ''; + + foreach ($logs as $index => $row) { + [$level, $message] = $row; + $sari = !empty($row[2]['sari']) ? 'present' : ' '; + $output .= "| " . $level . " | " . $message . " | " . $sari . " |\n"; + } + + dd($output); + } + /** * Return file from stream response. * @@ -506,12 +548,11 @@ private function getFileContentsInsecure(string $src): string|false { $session = $this->minkContext->getMink()->getSession(); $driver = $session->getDriver(); - /** @var Client $client */ $client = $driver->getClient(); - ob_start(); $client->request('get', $src); - return ob_get_clean(); + // retrieving streamed content is pretty finicky, but this works: https://github.com/symfony/symfony/issues/25005#issuecomment-1564417224 + return $client->getInternalResponse()->getContent(); } /** @@ -533,4 +574,175 @@ public function iFillInWithMyOTP(string $field): void $response = OCRA::generateOCRA($ocraSuite, $this->clientSecret, '', $challenge, '', $session, ''); $this->minkContext->visit('/authentication?otp=' . urlencode($response)); } + + /** + * @When /^a push notification is sent$/ + */ + public function aPushNotificationIsSent(): void + { + $session = $this->minkContext->getMink()->getSession(); + $driver = $session->getDriver(); + $client = $driver->getClient(); + $client->request('POST', '/authentication/notification'); + } + + /** + * @When /^push notification is sent with a trusted\-device cookie with address "([^"]*)"$/ + * @When /^push notification is sent with a trusted\-device cookie with address "([^"]*)" and cookie value "([^"]*)"$/ + */ + public function aPushNotificationIsSentWithATrustedDevice(string $notificationAddress, string $overwriteCookieValue = null): void + { + $config = new Configuration('tiqr-trusted-device', 3600, '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 'none'); + $cryptoHelper = new HaliteCryptoHelper($config); + + if ($overwriteCookieValue !== null) { + $cookieValue = CookieValue::from($overwriteCookieValue); + } else { + $cookieValue = CookieValue::from($notificationAddress); + } + $cookieName = 'tiqr-trusted-device'; + + $encryptedValue = $cryptoHelper->encrypt($cookieValue); + + $session = $this->minkContext->getMink()->getSession(); + $driver = $session->getDriver(); + $client = $driver->getClient(); + + $client->getCookieJar()->set(new Cookie( + $cookieName, + $encryptedValue, + '' . (time() + 3600), + '/', + '', + true, + true, + false, + 'strict' + )); + + $client->request('POST', '/authentication/notification'); + } + + + /** + * @Then /^it should fail with "([^"]*)"$/ + */ + public function itShouldFailWith(string $errorCode): void + { + $session = $this->minkContext->getMink()->getSession(); + $driver = $session->getDriver(); + $client = $driver->getClient(); + $response = $client->getResponse(); + Assertion::eq($response->getStatusCode(), 200); + Assertion::eq($response->getContent(), '"' . $errorCode . '"'); + } + + /** + * @Then /^it should send a notification for the user with type "([^"]*)" and address "([^"]*)"$/ + */ + public function itShouldSendANotification(string $type, string $address): void + { + $id = $this->metadata->identity->identifier; + $session = $this->minkContext->getMink()->getSession(); + /** @var BrowserKitDriver $driver */ + $driver = $session->getDriver(); + $client = $driver->getClient(); + $response = $client->getResponse(); + /** @var \Symfony\Component\HttpFoundation\JsonResponse $response */ + Assertion::eq($response->getStatusCode(), 200); + + $this->logsContain('Sending push notification for user "' . $id . '" with type "' . $type . '" and (untranslated) address "' . $address .'"'); + } + + private function logsContain(string $string): void + { + $logs = $this->fileLogger->cleanLogs(); + foreach ($logs as $log) { + if ($log[1] === $string) { + return; + } + } + + Assertion::eq($string, '', sprintf('The logs do not contain %s', $string)); + } + + private function logsContainLineStartingWith(string $string): void + { + /** @var array> $logs */ + $logs = $this->fileLogger->cleanLogs(); + foreach ($logs as $log) { + if (str_contains($log[1], $string)) { + return; + } + } + + Assertion::eq($string, '', sprintf('The logs do not contain a line starting with "%s"', $string)); + } + + /** + * @Then /^the logs should say: no trusted cookie for address "([^"]*)"$/ + */ + public function theLogsShouldSayNoTrustedDevice(string $address): void + { + $userId = $this->metadata->identity->identifier; + $this->logsContain( + 'No trusted device cookie stored for notification address "' . $address . '" and user "' . $userId . '". No notification was sent' + ); + } + + /** + * @Then /^the logs should mention a signature mismatch for address "([^"]*)"$/ + */ + public function theLogsShouldMentionSignatureMismatch(string $address): void + { + $this->logsContain( + 'Trusted device cookie "$address" does not match: "%s"' + ); + } + + /** + * @Then /^the logs should mention: Trusted device cookie "([^"]*)" does not match: "([^"]*)"$/ + */ + public function theLogsShouldMentionTrustedDeviceCookieDoesNotMatch(string $address1, string $address2): void + { + $this->logsContain('Trusted device cookie "' . $address1 . '" does not match: "' . $address2 . '"'); + } + + /** + * @Given /^the logs should mention: Writing a trusted\-device cookie with fingerprint$/ + */ + public function theLogsShouldMentionWritingATrustedDeviceCookieWithFingerprint(): void + { + $this->logsContainLineStartingWith('Writing a trusted-device cookie with fingerprint '); + } + + /** + * @Then /^I dump the page$/ + */ + public function iDumpThePage(): void + { + $session = $this->minkContext->getSession(); + $driver = $session->getDriver(); + /** @var BrowserKitDriver $driver */ + $client = $driver->getClient(); + $response = $client->getResponse(); + + dump($response); + } + + /** + * @Then /^I dump the auth response$/ + */ + public function iDumpTheAuthResponse(): void + { + dump($this->authenticatioResponse); + } + + /** + * @When /^the trusted device cookie is cleared$/ + */ + public function theTrustedDeviceCookieIsCleared(): void + { + $this->minkContext->getSession()->getDriver()->getClient()->getCookieJar()->expire('tiqr-trusted-device'); + } } diff --git a/src/Features/Framework/FileValueStore.php b/src/Features/Framework/FileValueStore.php new file mode 100644 index 00000000..6a71e867 --- /dev/null +++ b/src/Features/Framework/FileValueStore.php @@ -0,0 +1,100 @@ +filePath = $filePath; + if (!file_exists($this->filePath)) { + file_put_contents($this->filePath, json_encode([], JSON_THROW_ON_ERROR)); + chmod($this->filePath, 0666); + } + } + + /** + * @return array + */ + private function readValues(): array + { + $content = file_get_contents($this->filePath); + if ($content === false) { + throw new InvalidArgumentException(sprintf('Could not read FileValueStore storage file. %s', $this->filePath)); + } + + $result = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + return is_array($result) ? $result : []; + } + + /** + * @param array $values + */ + private function writeValues(array $values): void + { + file_put_contents($this->filePath, json_encode($values, JSON_THROW_ON_ERROR)); + } + + public function set(string $key, mixed $value): self + { + $values = $this->readValues(); + $values[$key] = $value; + $this->writeValues($values); + return $this; + } + + public function get(string $key): mixed + { + $values = $this->readValues(); + if (!isset($values[$key])) { + throw NotFound::stateProperty($key); + } + return $values[$key]; + } + + /** + * @SuppressWarnings(PHPMD.ShortMethodName) + */ + public function is(string $key, mixed $value): bool + { + $values = $this->readValues(); + return isset($values[$key]) && $values[$key] === $value; + } + + public function has(string $key): bool + { + $values = $this->readValues(); + return isset($values[$key]); + } + + public function clear(): self + { + $this->writeValues([]); + return $this; + } +} diff --git a/src/Features/authentication.feature b/src/Features/authentication.feature index 5bde9d28..770336be 100644 --- a/src/Features/authentication.feature +++ b/src/Features/authentication.feature @@ -1,4 +1,3 @@ -@skip Feature: When an user needs to authenticate As a service provider I need to send an AuthnRequest with a nameID to the identity provider @@ -35,43 +34,78 @@ Feature: When an user needs to authenticate Then I should see "urn:oasis:names:tc:SAML:2.0:status:Success" And the logs are: - | level | message | sari | - | notice | Received sso request | | - | info | Processing AuthnRequest | | + | level | message | sari | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | notice | Received sso request | | + | info | Processing AuthnRequest | | | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application authentication route /authentication | present | - | info | Using dummy as UserStorage encryption type | present | - | info | Verifying if there is a pending authentication request from SP | present | - | info | Verify if user is blocked | present | - | info | Verifying if authentication is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Start authentication | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | info | Return authentication page with QR code | present | - | info | Using dummy as UserStorage encryption type | present | - | info | Client request QR image | present | - | info | Return QR image response | present | - | notice | Got POST with login response | present | - | notice | Received login action from client with User-Agent "Symfony" and version "" | present | - | info | Validating authentication response | present | - | notice | /Authenticated user ".*" in session ".*"/ | present | - | info | response is valid | present | - | notice | User authenticated OK | present | - - | info | Using dummy as UserStorage encryption type | present | - | info | Verifying if there is a pending authentication request from SP | present | - | info | Verify if user is blocked | present | - | info | Verifying if authentication is finalized | present | - | info | Authentication is finalized, returning to SP | present | - | notice | Application authenticates the user | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*", request ID: ".*"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconect.local/demo/sp/acs" | present | - | info | /SAMLResponse with id ".*" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | - | info | /Verifying signature of Assertion with id ".*"/ | | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application authentication route /authentication | | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Verifying if there is a pending authentication request from SP | present | + | info | Verify if user is blocked | present | + | info | Verifying if authentication is finalized | present | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Start authentication | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | Return authentication page with QR code | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Client request QR image | present | + | info | Return QR image response | present | + | info | User made a request without a session cookie. | present | + | notice | Got POST with login response | present | + | notice | Received login action from client with User-Agent "Symfony" and version "" | present | + | info | Validating authentication response | present | + | notice | /Authenticated user ".*" in session ".*"/ | present | + | info | response is valid | present | + | notice | User authenticated OK | present | + | info | Created new session. | | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Verifying if there is a pending authentication request from SP | present | + | info | Verify if user is blocked | present | + | info | Verifying if authentication is finalized | present | + | info | Authentication is finalized, returning to SP | present | + | notice | Application authenticates the user | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*", request ID: ".*"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | /SAMLResponse with id ".*" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | + | info | /Verifying signature of Assertion with id ".*"/ | | + Scenario: When an user cancels it's authentication # Service provider demo page @@ -92,35 +126,59 @@ Feature: When an user needs to authenticate # Service provider AuthNRequest response page Then I should see "Cannot process response, preconditions not met: \"Responder/AuthnFailed User cancelled the request\"" - And the logs are: - | level | message | sari | - # GSSP bundle handling the AuthnRequest - | notice | Received sso request | | - | info | Processing AuthnRequest | | + And the logs are: + | level | message | sari | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | notice | Received sso request | | + | info | Processing AuthnRequest | | | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application authentication route /authentication | present | - - # Tiqr showing qr image - | info | Using dummy as UserStorage encryption type | present | - | info | Verifying if there is a pending authentication request from SP | present | - | info | Verify if user is blocked | present | - | info | Verifying if authentication is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Start authentication | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | info | Return authentication page with QR code | present | - | notice | User cancelled the request | present | - | critical | User cancelled the request | present | - | info | Redirect to sso return endpoint with authentication reject response | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - - # GSSP bundle creates saml return response - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*?", request ID: ".*?"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | present | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application authentication route /authentication | | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Verifying if there is a pending authentication request from SP | present | + | info | Verify if user is blocked | present | + | info | Verifying if authentication is finalized | present | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Start authentication | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | Return authentication page with QR code | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | User cancelled the request | present | + | critical | User cancelled the request | present | + | info | Redirect to sso return endpoint with authentication reject response | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*", request ID: ".*"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | Scenario: An user can authenticate with a one time password # Service provider demo page @@ -144,41 +202,72 @@ Feature: When an user needs to authenticate Then I should see "urn:oasis:names:tc:SAML:2.0:status:Success" And the logs are: - | level | message | sari | + | level | message | sari | - # GSSP bundle handling the AuthnRequest - | notice | Received sso request | | - | info | Processing AuthnRequest | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | notice | Received sso request | | + | info | Processing AuthnRequest | | | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application authentication route /authentication | present | - | info | Using dummy as UserStorage encryption type | present | - | info | Verifying if there is a pending authentication request from SP | present | - | info | Verify if user is blocked | present | - | info | Verifying if authentication is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Start authentication | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | info | Return authentication page with QR code | present | - | info | Using dummy as UserStorage encryption type | present | - | info | Client request QR image | present | - | info | Return QR image response | present | - | info | Using dummy as UserStorage encryption type | present | - | info | Verifying if there is a pending authentication request from SP | present | - | info | Verify if user is blocked | present | - | info | Handling otp | present | - | info | Validating authentication response | present | - | notice | /Authenticated user ".*" in session ".*"/ | present | - | info | response is valid | present | - | info | Verifying if authentication is finalized | present | - | info | Authentication is finalized, returning to SP | present | - | notice | Application authenticates the user | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - - # GSSP bundle creates saml return response - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*", request ID: ".*"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | present | - | info | /SAMLResponse with id ".*" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | - | info | /Verifying signature of Assertion with id ".*"/ | | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application authentication route /authentication | | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Verifying if there is a pending authentication request from SP | present | + | info | Verify if user is blocked | present | + | info | Verifying if authentication is finalized | present | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Start authentication | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | Return authentication page with QR code | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Client request QR image | present | + | info | Return QR image response | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Verifying if there is a pending authentication request from SP | present | + | info | Verify if user is blocked | present | + | info | Handling otp | present | + | info | Validating authentication response | present | + | notice | /Authenticated user ".*" in session ".*"/ | present | + | info | response is valid | present | + | info | Verifying if authentication is finalized | present | + | info | Authentication is finalized, returning to SP | present | + | notice | Application authenticates the user | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*", request ID: ".*"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | /SAMLResponse with id ".*" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | + | info | /Verifying signature of Assertion with id ".*"/ | | + diff --git a/src/Features/mfaFatigueMitigation.feature b/src/Features/mfaFatigueMitigation.feature new file mode 100644 index 00000000..899d093a --- /dev/null +++ b/src/Features/mfaFatigueMitigation.feature @@ -0,0 +1,55 @@ +Feature: When an user needs to authenticate + As a service provider + I need to send an AuthnRequest with a nameID to the identity provider + + Background: + Given the registration QR code is scanned + And the user registers the service with notification type "APNS" address: "0000000000111111111122222222223333333333" + Then we have a registered user + And the logs should mention: Writing a trusted-device cookie with fingerprint + And I clear the logs + And the trusted device cookie is cleared + + Scenario: When a user authenticates using a qr code it should set a trusted cookie + Given I am on "/demo/sp" + And I fill in "NameID" with my identifier + When I press "authenticate" + Then I should see "Log in with tiqr" + And I should be on "/authentication" + + Then I scan the tiqr authentication qrcode + And the app authenticates to the service with notification type "APNS" address: "0000000000111111111122222222223333333333" + Then we have a authenticated app + And we have a trusted cookie for address: "0000000000111111111122222222223333333333" + + Scenario: When a user authenticates without a trusted cookie, a push notification should not be sent + Given I am on "/demo/sp" + And I fill in "NameID" with my identifier + When I press "authenticate" + Then I should see "Log in with tiqr" + And I should be on "/authentication" + + When a push notification is sent + Then it should fail with "no-trusted-device" + Then the logs should say: no trusted cookie for address "0000000000111111111122222222223333333333" + + Scenario: When a user tries to authenticates with a trusted cookie, a notification should be sent + Given I am on "/demo/sp" + And I fill in "NameID" with my identifier + When I press "authenticate" + Then I should see "Log in with tiqr" + And I should be on "/authentication" + + When push notification is sent with a trusted-device cookie with address "0000000000111111111122222222223333333333" + Then it should send a notification for the user with type "APNS" and address "0000000000111111111122222222223333333333" + + Scenario: When a user tries to authenticates with a trusted cookie, but changes the address, a notification should not be sent + Given I am on "/demo/sp" + And I fill in "NameID" with my identifier + When I press "authenticate" + Then I should see "Log in with tiqr" + And I should be on "/authentication" + + When push notification is sent with a trusted-device cookie with address "0000000000111111111122222222223333333333" and cookie value "1000000000111111111122222222223333333333" + Then the logs should mention: Trusted device cookie "0000000000111111111122222222223333333333" does not match: "1000000000111111111122222222223333333333" + And it should fail with "no-trusted-device" diff --git a/src/Features/registration.feature b/src/Features/registration.feature index c80f00fc..72ebed16 100644 --- a/src/Features/registration.feature +++ b/src/Features/registration.feature @@ -1,4 +1,3 @@ -@skip Feature: When an user needs to register for a new token To register an user for a new token As a service provider @@ -24,58 +23,83 @@ Feature: When an user needs to register for a new token Then I should see "urn:oasis:names:tc:SAML:2.0:status:Success" And the logs are: - | level | message | sari | + | level | message | sari | + + | info | User made a request without a session cookie. | present | + | info | Created new session. | | + | info | User made a request without a session cookie. | present | + | info | Created new session. | | + | info | User made a request without a session cookie. | present | + | notice | Received sso request | | + | warning | There is already state present, clear previous state | | + | info | Processing AuthnRequest | | + | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application registration route /registration | | + | info | Created new session. | | + | info | User made a request without a session cookie. | present | + | info | Verifying if there is a pending registration from SP | present | + | info | There is a pending registration | present | + | info | Verifying if registration is finalized | present | + | info | Created new session. | | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Registration is not finalized return QR code | present | + | info | Generating enrollment key | present | + | notice | /Starting new enrollment session with sessionId .* and userId .*/ | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Request for registration QR img | present | + | info | Returning registration QR response | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | notice | Got GET request to metadata endpoint with enrollment key | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | notice | Returned metadata response | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | notice | Got POST with registration response | present | + | notice | Received register action from client with User-Agent "Behat UA" and version "" | present | + | info | Start validating enrollment secret | present | + | info | Setting user secret and notification type and address | present | + | info | Finalizing enrollment | present | + | notice | Enrollment finalized | present | + | notice | /Writing a trusted-device cookie with fingerprint .*/ | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Verifying if there is a pending registration from SP | present | + | info | There is a pending registration | present | + | info | Verifying if registration is finalized | present | + | info | Registration is finalized returning to service provider | present | + | notice | /Application sets the subject nameID to .*/ | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*", request ID: ".*"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | /SAMLResponse with id ".*?" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | + | info | /Verifying signature of Assertion with id ".*"/ | | - # GSSP bundle handling the AuthnRequest - | notice | Received sso request | | - | info | Processing AuthnRequest | | - | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application registration route /registration | present | - - # Tiqr page with qr code. - | info | Verifying if there is a pending registration from SP | present | - | info | There is a pending registration | present | - | info | Verifying if registration is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Registration is not finalized return QR code | present | - - # Generate qr img. - | info | Generating enrollment key | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | info | Request for registration QR img | present | - | info | Returning registration QR response | present | - - # Get metadata call from tiqr client - | info | Using dummy as UserStorage encryption type | present | - | notice | Got GET request to metadata endpoint with enrollment key | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | notice | Returned metadata response | present | - - # Post with user secret from tiqr client - | info | Using dummy as UserStorage encryption type | present | - | notice | Got POST with registration response | present | - | notice | Received register action from client with User-Agent "Behat UA" and version "" | present | - | info | Start validating enrollment secret | present | - | info | Setting user secret and notification type and address | present | - | info | Finalizing enrollment | present | - | notice | Enrollment finalized | present | - - # Tiqr page, finalized return to SP. - | info | Verifying if there is a pending registration from SP | present | - | info | There is a pending registration | present | - | info | Verifying if registration is finalized | present | - | info | Registration is finalized returning to service provider | present | - - # SP - | notice | /Application sets the subject nameID to .*/ | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*?", request ID: ".*?"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | present | - | info | /SAMLResponse with id ".*?" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | - | info | /Verifying signature of Assertion with id ".*"/ | | Scenario: When an user needs to register for a new token but is unable to scan the QR code Given I am on "/demo/sp" @@ -94,54 +118,75 @@ Feature: When an user needs to register for a new token Then I should see "urn:oasis:names:tc:SAML:2.0:status:Success" And the logs are: - | level | message | sari | - - # GSSP bundle handling the AuthnRequest - | notice | Received sso request | | - | info | Processing AuthnRequest | | + | level | message | sari | + + | info | User made a request without a session cookie. | | + | info | Created new session. | | + | info | User made a request without a session cookie. | | + | info | Created new session. | | + | info | User made a request without a session cookie. | | + | notice | Received sso request | | + | info | Processing AuthnRequest | | | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application registration route /registration | present | - - # Tiqr page with qr code. - | info | Verifying if there is a pending registration from SP | present | - | info | There is a pending registration | present | - | info | Verifying if registration is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Registration is not finalized return QR code | present | - | info | Generating enrollment key | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | info | Using dummy as UserStorage encryption type | present | - - # Get metadata from Tiqr app - | notice | Got GET request to metadata endpoint with enrollment key | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | notice | Returned metadata response | present | - | info | Using dummy as UserStorage encryption type | present | - - # POST response from Tiqr app - | notice | Got POST with registration response | present | - | notice | Received register action from client with User-Agent "Behat UA" and version "" | present | - | info | Start validating enrollment secret | present | - | info | Setting user secret and notification type and address | present | - | info | Finalizing enrollment | present | - | notice | Enrollment finalized | present | - - # Tiqr page, finalized return to SP. - | info | Verifying if there is a pending registration from SP | present | - | info | There is a pending registration | present | - | info | Verifying if registration is finalized | present | - | info | Registration is finalized returning to service provider | present | - - # SP - | notice | /Application sets the subject nameID to .*/ | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*?", request ID: ".*?"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | present | - | info | /SAMLResponse with id ".*?" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | - | info | /Verifying signature of Assertion with id ".*"/ | | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application registration route /registration | | + | info | Created new session. | | + | info | User made a request without a session cookie. | present | + | info | Verifying if there is a pending registration from SP | present | + | info | There is a pending registration | present | + | info | Verifying if registration is finalized | present | + | info | Created new session. | | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Registration is not finalized return QR code | present | + | info | Generating enrollment key | present | + | notice | /Starting new enrollment session with sessionId .* and userId .*/ | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | notice | Got GET request to metadata endpoint with enrollment key | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | notice | Returned metadata response | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | info | Using "plain" as UserSecretStorage encryption type | present | + | notice | Got POST with registration response | present | + | notice | Received register action from client with User-Agent "Behat UA" and version "" | present | + | info | Start validating enrollment secret | present | + | info | Setting user secret and notification type and address | present | + | info | Finalizing enrollment | present | + | notice | Enrollment finalized | present | + | notice | /Writing a trusted-device cookie with fingerprint .*/ | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | info | Verifying if there is a pending registration from SP | present | + | info | There is a pending registration | present | + | info | Verifying if registration is finalized | present | + | info | Registration is finalized returning to service provider | present | + | notice | /Application sets the subject nameID to .*/ | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*", request ID: ".*"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + | info | /SAMLResponse with id ".*" was not signed at root level, not attempting to verify the signature of the reponse itself/ | | + | info | /Verifying signature of Assertion with id ".*"/ | | Scenario: When an user needs to cancel the registration @@ -159,47 +204,67 @@ Feature: When an user needs to register for a new token # Service prodvider Then I should see "Cannot process response, preconditions not met: \"Responder/AuthnFailed User cancelled the request\"" - And the logs are: - | level | message | sari | - # GSSP bundle handling the AuthnRequest - | notice | Received sso request | | - | info | Processing AuthnRequest | | - | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | - | info | AuthnRequest stored in state | present | - | notice | Redirect user to the application registration route /registration | present | - - # Tiqr registration endpoint - | info | Verifying if there is a pending registration from SP | present | - | info | There is a pending registration | present | - | info | Verifying if registration is finalized | present | - | notice | Unable to retrieve the state storage value, file not found | present | - | info | Registration is not finalized return QR code | present | - | info | Generating enrollment key | present | - | info | /Setting SARI '.*' for identifier '.*'/ | present | - | notice | User cancelled the request | present | - | critical | User cancelled the request | present | - | info | Redirect to sso return endpoint with registration reject response | present | - | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | - | notice | Received sso return request | present | - | info | Create sso response | present | - | notice | /Saml response created with id ".*?", request ID: ".*?"/ | present | - | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | present | - - Scenario: When the user is redirected from an unknown service provider he should see an error page - Given a normal SAML 2.0 AuthnRequest form a unknown service provider - Then the response status code should be 406 - And I should see "Error - Unknown service provider" - And the logs are: - | level | message | sari | - | notice | Received sso request | | - | info | Processing AuthnRequest | | - - Scenario: When an user request the sso endpoint without AuthnRequest the request should be denied - When I am on "/saml/sso" - Then the response status code should be 406 - And I should see "Something went wrong. Please try again." And the logs are: - | level | message | sari | - | notice | Received sso request | | - | info | Processing AuthnRequest | | + | level | message | sari | + + | info | User made a request without a session cookie. | | + | info | Created new session. | | + | info | User made a request without a session cookie. | | + | info | Created new session. | | + | info | User made a request without a session cookie. | | + | notice | Received sso request | | + | info | Processing AuthnRequest | | + | notice | /AuthnRequest processing complete, received AuthnRequest from "https:\/\/tiqr\.dev\.openconext\.local\/saml\/metadata", request ID: ".*"/ | | + | info | AuthnRequest stored in state | | + | notice | Redirect user to the application registration route /registration | | + | info | Created new session. | | + | info | User made a request without a session cookie. | present | + | info | Verifying if there is a pending registration from SP | present | + | info | There is a pending registration | present | + | info | Verifying if registration is finalized | present | + | info | Created new session. | | + | notice | Unable to retrieve the state storage value, file not found | present | + | info | Registration is not finalized return QR code | present | + | info | Generating enrollment key | present | + | notice | /Starting new enrollment session with sessionId .* and userId .*/ | present | + | info | /Setting SARI '.*' for identifier '.*'/ | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | User cancelled the request | present | + | critical | User cancelled the request | present | + | info | Redirect to sso return endpoint with registration reject response | present | + | notice | Created redirect response for sso return endpoint "/saml/sso_return" | present | + | info | User made a request with a session cookie. | present | + | info | Created new session. | | + | info | User has a session. | present | + | info | User session matches the session cookie. | present | + | notice | Received sso return request | | + | info | Create sso response | | + | notice | /Saml response created with id ".*?", request ID: ".*?"/ | | + | notice | Invalidate current state and redirect user to service provider assertion consumer url "https://tiqr.dev.openconext.local/demo/sp/acs" | | + | info | User made a request with a session cookie. | | + | info | Created new session. | | + | info | User has a session. | | + | info | User session matches the session cookie. | | + + +# Scenario: When the user is redirected from an unknown service provider he should see an error page +# Given a normal SAML 2.0 AuthnRequest form a unknown service provider +# Then the response status code should be 406 +# And I should see "Error - Unknown service provider" +# And the logs are: +# | level | message | sari | +# | notice | Received sso request | | +# | info | Processing AuthnRequest | | + +# Scenario: When an user request the sso endpoint without AuthnRequest the request should be denied +# When I am on "/saml/sso" +# Then the response status code should be 406 +# And I should see "Something went wrong. Please try again." +# And the logs are: +# | level | message | sari | +# | notice | Received sso request | | +# | info | Processing AuthnRequest | | diff --git a/src/Service/TrustedDevice/Crypto/CryptoHelperInterface.php b/src/Service/TrustedDevice/Crypto/CryptoHelperInterface.php new file mode 100644 index 00000000..70c01644 --- /dev/null +++ b/src/Service/TrustedDevice/Crypto/CryptoHelperInterface.php @@ -0,0 +1,30 @@ +encryptionKey = new EncryptionKey(new HiddenString($configuration->encryptionKey)); + } + + /** + * Halite always uses authenticated encryption. + * See: https://github.com/paragonie/halite/blob/v4.x/doc/Classes/Symmetric/Crypto.md#encrypt + * + * It uses XSalsa20 for encryption and BLAKE2b for message Authentication (MAC) + * The keys used for encryption and message authentication are derived from the secret key using a + * HKDF using a salt This means that learning either derived key cannot lead to learning the other + * derived key, or the secret key input in the HKDF. Encrypting many messages using the same + * secret key is not a problem in this design. + * + * @throws EncryptionFailedException + * @throws JsonException + */ + public function encrypt(CookieValue $cookieValue): string + { + try { + $plainTextCookieValue = new HiddenString($cookieValue->serialize()); + // Encryption (we use the default encoding: Halite::ENCODE_BASE64URLSAFE) + $encryptedData = Crypto::encrypt( + $plainTextCookieValue, + $this->encryptionKey + ); + } catch (Exception $e) { + throw new EncryptionFailedException( + 'Encrypting the CookieValue for failed', + $e + ); + } + return $encryptedData; + } + + /** + * Decrypt the cookie ciphertext back to plain text. + * Again using the encryption key, used to encrypt the data. + * The decrypt method will return a deserialized CookieValue value object + * + * @throws DecryptionFailedException + * @throws JsonException + */ + public function decrypt(string $cookieData): CookieValue + { + try { + // Decryption: (we use the default encoding: Halite::DECODE_BASE64URLSAFE) + $decryptedData = Crypto::decrypt( + $cookieData, + $this->encryptionKey + ); + } catch (Exception $e) { + throw new DecryptionFailedException( + 'Decrypting the CookieValue failed, see embedded error message for details', + $e + ); + } + return CookieValue::deserialize($decryptedData->getString()); + } +} diff --git a/src/Service/TrustedDevice/DateTime/ExpirationHelper.php b/src/Service/TrustedDevice/DateTime/ExpirationHelper.php new file mode 100644 index 00000000..c076b057 --- /dev/null +++ b/src/Service/TrustedDevice/DateTime/ExpirationHelper.php @@ -0,0 +1,75 @@ +now = $now; + } + + public function isExpired(CookieValue $cookieValue): bool + { + try { + $authenticationTimestamp = $cookieValue->authenticationTime(); + } catch (TypeError $error) { + throw new InvalidAuthenticationTimeException( + 'The authentication time contained a non-int value', + 0, + $error + ); + } + + if ($authenticationTimestamp < 0) { + throw new InvalidAuthenticationTimeException( + 'The authentication time is from before the Unix timestamp epoch' + ); + } + + if ($authenticationTimestamp > $this->now->getTimestamp()) { + throw new InvalidAuthenticationTimeException( + 'The authentication time is from the future, which indicates the clock settings ' . + 'are incorrect, or the time in the cookie value was tampered with.' + ); + } + + $expirationTimestamp = $authenticationTimestamp + $this->configuration->lifetimeInSeconds; + $currentTimestamp = $this->now->getTimestamp(); + + // Is the current time greater than the expiration time? + return $currentTimestamp > $expirationTimestamp; + } +} diff --git a/src/Service/TrustedDevice/DateTime/ExpirationHelperInterface.php b/src/Service/TrustedDevice/DateTime/ExpirationHelperInterface.php new file mode 100644 index 00000000..b77ef96f --- /dev/null +++ b/src/Service/TrustedDevice/DateTime/ExpirationHelperInterface.php @@ -0,0 +1,38 @@ +encryptionHelper->encrypt($value); + $fingerprint = $this->hashFingerprint($encryptedCookieValue); + $this->logger->notice(sprintf('Writing a trusted-device cookie with fingerprint %s', $fingerprint)); + // Create a Symfony HttpFoundation cookie object + $cookie = $this->createCookieWithValue($encryptedCookieValue, $this->configuration->cookieName); + // Which is added to the response headers + $response->headers->setCookie($cookie); + } + + /** + * Retrieve the current cookie from the Request if it exists. + */ + public function read(Request $request): CookieValue + { + if (!$request->cookies->has($this->configuration->cookieName)) { + throw new CookieNotFoundException(); + } + $cookie = $request->cookies->get($this->configuration->cookieName); + if (!is_string($cookie)) { + throw new InvalidArgumentException('Cookie payload must be string.'); + } + $fingerprint = $this->hashFingerprint($cookie); + $this->logger->notice(sprintf('Reading a trusted-device cookie with fingerprint %s', $fingerprint)); + return $this->encryptionHelper->decrypt($cookie); + } + + public function fingerprint(Request $request): string + { + if (!$request->cookies->has($this->configuration->cookieName)) { + throw new CookieNotFoundException(); + } + $cookie = $request->cookies->get($this->configuration->cookieName); + if (!is_string($cookie)) { + throw new InvalidArgumentException('Cookie payload must be string.'); + } + return $this->hashFingerprint($cookie); + } + + private function createCookieWithValue(string $value, string $name): Cookie + { + return new Cookie( + $name, + $value, + $this->getTimestamp($this->configuration->lifetimeInSeconds), + '/', + null, + true, + true, + false, + $this->configuration->sameSite->value + ); + } + + private function hashFingerprint(string $encryptedCookieValue): string + { + return hash('sha256', $encryptedCookieValue); + } + + private function getTimestamp(int $expiresInSeconds): int + { + $currentTimestamp = time(); + return $currentTimestamp + $expiresInSeconds; + } +} diff --git a/src/Service/TrustedDevice/Http/CookieHelperInterface.php b/src/Service/TrustedDevice/Http/CookieHelperInterface.php new file mode 100644 index 00000000..d5cd1b77 --- /dev/null +++ b/src/Service/TrustedDevice/Http/CookieHelperInterface.php @@ -0,0 +1,34 @@ +store($response, CookieValue::from($notificationAddress)); + } + + public function isTrustedDevice( + CookieValue $cookie, + string $notificationAddress, + ): bool { + return $this->isCookieValid($cookie, $notificationAddress); + } + + public function read(Request $request): ?CookieValue + { + try { + return $this->cookieHelper->read($request); + } catch (CookieNotFoundException $e) { + $this->logger->notice('A trusted-device cookie is not found'); + return null; + } catch (DecryptionFailedException $e) { + $this->logger->notice('Decryption of the trusted-device cookie failed'); + return null; + } catch (Exception $e) { + $this->logger->notice( + 'Decryption failed, see original message in context', + ['original-exception-message' => $e->getMessage()] + ); + return null; + } + } + + private function store(Response $response, CookieValue $cookieValue): void + { + $this->cookieHelper->write($response, $cookieValue); + } + + private function isCookieValid(CookieValue $cookie, string $notificationAddress): bool + { + if ($cookie->getNotificationAddress() !== $notificationAddress) { + $this->logger->error( + sprintf( + 'Trusted device cookie "%s" does not match: "%s"', + $notificationAddress, + $cookie->getNotificationAddress(), + ) + ); + return false; + } + try { + $isExpired = $this->expirationHelper->isExpired($cookie); + if ($isExpired) { + $this->logger->notice( + 'The trusted-device cookie has expired. Meaning [authentication time] + [cookie lifetime] is in the past' + ); + return false; + } + } catch (InvalidAuthenticationTimeException $e) { + $this->logger->error('The trusted-device cookie contained an invalid authentication time', [$e->getMessage()]); + return false; + } + return true; + } +} diff --git a/src/Service/TrustedDevice/ValueObject/Configuration.php b/src/Service/TrustedDevice/ValueObject/Configuration.php new file mode 100644 index 00000000..7e34c0da --- /dev/null +++ b/src/Service/TrustedDevice/ValueObject/Configuration.php @@ -0,0 +1,74 @@ +sameSite = CookieSameSite::from($sameSite); + if ($lifetimeInSeconds === 0) { + throw new InvalidCookieLifetimeException( + 'When using a persistent cookie, you must configure a non zero cookie lifetime' + ); + } + + // Convert the key from the configuration from hex to binary. sodium_hex2bin + try { + $this->encryptionKey = sodium_hex2bin($encryptionKey); + } catch (Exception $e) { + // The key contains non-hexadecimal values. Show a custom error message in logs. + throw new InvalidEncryptionKeyException( + 'The configured trusted device encryption key contains illegal characters. It should be a 64 digits long ' . + 'hexadecimal value. Example value: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + 0, + $e + ); + } + + // The key length, converted back to binary must be 32 bytes long + if (Binary::safeStrlen($this->encryptionKey) < SODIUM_CRYPTO_STREAM_KEYBYTES) { + throw new InvalidEncryptionKeyException( + sprintf( + 'The configured trusted device encryption key must be exactly %d bytes. ' . + 'This comes down to 64 hex digits value, configured in the trusted_device_encryption_key configuration option', + SODIUM_CRYPTO_STREAM_KEYBYTES + ) + ); + } + } +} diff --git a/src/Service/TrustedDevice/ValueObject/CookieValue.php b/src/Service/TrustedDevice/ValueObject/CookieValue.php new file mode 100644 index 00000000..85bb52ea --- /dev/null +++ b/src/Service/TrustedDevice/ValueObject/CookieValue.php @@ -0,0 +1,91 @@ +format(DATE_ATOM)); + } + + /** + * @throws JsonException + */ + public static function deserialize(string $serializedData): CookieValue + { + $data = json_decode($serializedData, true, 512, JSON_THROW_ON_ERROR); + + if (!is_array($data)) { + throw new InvalidArgumentException('Invalid serialized data'); + } + + if (!is_string($data['notificationAddress'])) { + throw new InvalidArgumentException('notificationAddress is not a valid string'); + } + + if (!is_string($data['authenticationTime'])) { + throw new InvalidArgumentException('authenticationTime is not a valid string'); + } + + return new self($data['notificationAddress'], $data['authenticationTime']); + } + + /** + * @throws JsonException + */ + public function serialize(): string + { + return json_encode([ + 'notificationAddress' => $this->notificationAddress, + 'authenticationTime' => $this->authenticationTime, + ], JSON_THROW_ON_ERROR); + } + + public function getNotificationAddress(): string + { + return $this->notificationAddress; + } + + public function authenticationTime(): int + { + return strtotime($this->authenticationTime) ?: throw new InvalidArgumentException('Invalid authentication time format'); + } +} diff --git a/tests/Unit/Service/TrustedCookie/Crypto/HaliteCryptoHelperTest.php b/tests/Unit/Service/TrustedCookie/Crypto/HaliteCryptoHelperTest.php new file mode 100644 index 00000000..096bbaff --- /dev/null +++ b/tests/Unit/Service/TrustedCookie/Crypto/HaliteCryptoHelperTest.php @@ -0,0 +1,72 @@ +value + ); + + $this->helper = new HaliteCryptoHelper($configuration); + } + + public function test_encrypt_decrypt_with_authentication(): void + { + $cookie = $this->createCookieValue(); + $data = $this->helper->encrypt($cookie); + $cookieDecrypted = $this->helper->decrypt($data); + + self::assertEquals($cookie, $cookieDecrypted); + } + + public function test_encrypt_decrypt_with_authentication_decryption_impossible_if_tampered_with(): void + { + $cookie = $this->createCookieValue(); + $data = $this->helper->encrypt($cookie); + $data = substr($data, 1, strlen($data)); + $this->expectException(DecryptionFailedException::class); + $this->helper->decrypt($data); + } + + private function createCookieValue(): CookieValue + { + return CookieValue::from('abc12345'); + } +} diff --git a/tests/Unit/Service/TrustedCookie/DateTime/ExpirationHelperTest.php b/tests/Unit/Service/TrustedCookie/DateTime/ExpirationHelperTest.php new file mode 100644 index 00000000..f8104ce8 --- /dev/null +++ b/tests/Unit/Service/TrustedCookie/DateTime/ExpirationHelperTest.php @@ -0,0 +1,145 @@ +isExpired($cookieValue)); + } + + /** + * @dataProvider expirationValues + */ + public function test_expiration_period(bool $isExpired, ExpirationHelper $helper, CookieValue $cookieValue): void + { + self::assertEquals($isExpired, $helper->isExpired($cookieValue)); + } + + public function expirationValues(): array + { + // Cookie lifetime 3600 + $helper = $this->makeExpirationHelper(3600, time()); + return [ + 'within period' => [false, $helper, $this->makeCookieValue(time() - 3600)], + 'outside period' => [true, $helper, $this->makeCookieValue(time() - 3601)], + ]; + } + + public function invalidTimeExpectations(): array + { + $goodOldHelper = $this->makeExpirationHelper(3600, time()); + return [ + 'from the future' => [$goodOldHelper, $this->makeCookieValue(time() + 42)], + ]; + } + + public function invalidTimeArgumentExpectations(): array + { + $goodOldHelper = $this->makeExpirationHelper(3600, time()); + return [ + 'before epoch' => [$goodOldHelper, fn() => $this->makeCookieValue(-1)], + 'invalid time input 1' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime('aint-no-time')], + 'invalid time input 2' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime('9999-01-01')], + 'invalid time input 3' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime('0001-01-01')], + 'invalid time input 4' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(-1.0)], + 'invalid time input 5' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(2.999)], + 'invalid time input 6' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(42)], + 'invalid time input 7' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(true)], + 'invalid time input 8' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(false)], + 'invalid time input 9' => [$goodOldHelper, fn() => $this->makeCookieValueUnrestrictedAuthTime(null)], + ]; + } + + /** + * @dataProvider invalidTimeExpectations + */ + public function test_strange_authentication_time_values(ExpirationHelper $helper, CookieValue $cookieValue): void + { + $this->expectException(InvalidAuthenticationTimeException::class); + $helper->isExpired($cookieValue); + } + + /** + * @dataProvider invalidTimeArgumentExpectations + */ + public function test_strange_authentication_time_arguments(ExpirationHelper $helper, callable $callback): void + { + $this->expectException(InvalidArgumentException::class); + $helper->isExpired($callback()); + } + + public function expirationExpectations(): array + { + return [ + 'not expired' => [false, $this->makeExpirationHelper(3600, time()), $this->makeCookieValue(time())], + 'not expired but about to be' => [false, $this->makeExpirationHelper(3600, time() + 3600), $this->makeCookieValue(time())], + 'expired' => [true, $this->makeExpirationHelper(3600, time() + 3601), $this->makeCookieValue(time())], + 'expired more' => [true, $this->makeExpirationHelper(3600, time() + 36000), $this->makeCookieValue(time())], + ]; + } + + private function makeExpirationHelper(int $expirationTime, int $now) : ExpirationHelper + { + $time = new \DateTime(); + $time->setTimestamp($now); + + $config = new Configuration( + 'trusted-device-cookie', + $expirationTime, + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + 'none', + ); + + return new ExpirationHelper($config, $time); + } + + private function makeCookieValue(int $authenticationTime) : CookieValue + { + $dateTime = new \DateTime(); + $dateTime->setTimestamp($authenticationTime); + $data = [ + 'userId' => 'userId', + 'notificationAddress' => 'notificationAddress', + 'authenticationTime' => $dateTime->format(DATE_ATOM), + ]; + return CookieValue::deserialize(json_encode($data)); + } + + private function makeCookieValueUnrestrictedAuthTime($authenticationTime) : CookieValue + { + $data = [ + 'userId' => 'userId', + 'notificationAddress' => 'notificationAddress', + 'authenticationTime' => $authenticationTime, + ]; + return CookieValue::deserialize(json_encode($data)); + } +} diff --git a/tests/Unit/Service/TrustedCookie/TrustedDeviceServiceTest.php b/tests/Unit/Service/TrustedCookie/TrustedDeviceServiceTest.php new file mode 100644 index 00000000..18cb96a3 --- /dev/null +++ b/tests/Unit/Service/TrustedCookie/TrustedDeviceServiceTest.php @@ -0,0 +1,273 @@ +logger = new NullLogger(); + parent::setUp(); + } + + protected function buildService(Configuration $configuration, DateTime $now = null): void + { + $this->configuration = $configuration; + $encryptionHelper = new HaliteCryptoHelper($configuration); + $expirationHelper = new ExpirationHelper($this->configuration, $now); + $cookieHelper = new CookieHelper($this->configuration, $encryptionHelper, $this->logger); + $this->service = new TrustedDeviceService( + $cookieHelper, + $expirationHelper, + $this->logger + ); + } + + public function test_storing_a_persistent_cookie(): void + { + $this->buildService( + new Configuration( + 'tiqr-trusted-device-cookie', + 60, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value, + ) + ); + $response = new Response('

hi

', 200); + + $this->service->registerTrustedDevice($response, '01011001'); + + $cookieJar = $response->headers->getCookies(); + self::assertCount(1, $cookieJar); + $cookie = reset($cookieJar); + // The name and lifetime of the cookie should match the one we configured it to be + self::assertEquals($this->configuration->cookieName, $cookie->getName()); + self::assertEquals(time() + $this->configuration->lifetimeInSeconds, $cookie->getExpiresTime()); + // By default, we set same-site header to none + self::assertEquals(Cookie::SAMESITE_STRICT, $cookie->getSameSite()); + } + + public function test_untrusted_when_id_doesnt_match(): void + { + $this->buildService( + new Configuration( + 'test-cookie', + 2592000, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value + ) + ); + + $cookieValue = CookieValue::from('notAddr#321'); + self::assertFalse( + $this->service->isTrustedDevice( + $cookieValue, + 'notAddr#322' + ) + ); + } + + public function test_is_trusted_when_identity_matches(): void + { + $this->buildService( + new Configuration( + 'test-cookie', + 2592000, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value + ), + new DateTime('+2592000 seconds') + ); + + $cookieValue = CookieValue::from( 'notAddr#321'); + self::assertTrue( + $this->service->isTrustedDevice( + $cookieValue, + 'notAddr#321', + ) + ); + } + + public function test_is_untrusted_when_token_expired(): void + { + $this->buildService( + new Configuration( + 'test-cookie', + 2592000, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value + ), new DateTime('+2592001 seconds') // lifetime + 1 second + ); + + $cookieValue = CookieValue::from('notAddr#321'); + self::assertFalse( + $this->service->isTrustedDevice( + $cookieValue, + 'notAddr#321', + ) + ); + } + + public function test_read_write_cookie(): void + { + $this->buildService( + new Configuration( + 'tiqr-trusted-device-cookie', + 60, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value, + ) + ); + $response = new Response('

hi

', 200); + + $notificationAddress = '01011001'; + + $this->service->registerTrustedDevice($response, $notificationAddress); + + $cookieJar = $response->headers->getCookies(); + self::assertCount(1, $cookieJar); + + $request = new Request(); + foreach ($cookieJar as $cookie) { + $request->cookies->set($cookie->getName(), $cookie->getValue()); + } + + $readCookie = $this->service->read($request); + $this->assertTrue($this->service->isTrustedDevice($readCookie, $notificationAddress)); + } + + public function test_does_not_read_tampered_cookie(): void + { + $this->buildService( + new Configuration( + 'tiqr-trusted-device-cookie', + 60, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value, + ) + ); + $response = new Response('

hi

', 200); + + $notificationAddress = '01011001'; + + $this->service->registerTrustedDevice($response, $notificationAddress); + + $cookieJar = $response->headers->getCookies(); + self::assertCount(1, $cookieJar); + + $request = new Request(); + $request->cookies->set($cookieJar[0]->getName(), '1' . $cookieJar[0]->getValue()); + + $readCookie = $this->service->read($request); + $this->assertNull($readCookie); + } + + public function test_it_overwrites_existing_cookies(): void + { + $this->buildService( + new Configuration( + 'qki_', + 3600, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value, + ) + ); + $response = new Response('

hi

', 200); + + $store = [ + [ + 'notificationAddress' => '1', + ], + [ + 'notificationAddress' => '3', + ], + [ + 'notificationAddress' => '1', + ], + ]; + + foreach ($store as $storedDevice) { + $this->service->registerTrustedDevice($response, $storedDevice['notificationAddress']); + } + + $cookieJar = $response->headers->getCookies(); + self::assertCount(1, $cookieJar); + + $request = new Request(); + foreach ($cookieJar as $cookie) { + $request->cookies->set($cookie->getName(), $cookie->getValue()); + } + + shuffle($store); + + $readCookie = $this->service->read($request); + $this->assertTrue($this->service->isTrustedDevice($readCookie, '1')); + } + + public function test_userId_is_irrelevant_for_cookie_validity(): void + { + $this->buildService( + new Configuration( + 'tiqr-trusted-device-cookie', + 60, + '0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f', + CookieSameSite::SAMESITE_STRICT->value, + ) + ); + + $response = new Response('

hi

', 200); + $notificationAddress = '01011001'; + $this->service->registerTrustedDevice($response, $notificationAddress); + + $cookieJar = $response->headers->getCookies(); + self::assertCount(1, $cookieJar); + + $request = new Request(); + foreach ($cookieJar as $cookie) { + $request->cookies->set($cookie->getName(), $cookie->getValue()); + } + + $readCookie = $this->service->read($request); + $this->assertTrue($this->service->isTrustedDevice($readCookie, $notificationAddress)); + } + + +} diff --git a/tests/Unit/Service/TrustedCookie/ValueObject/ConfigurationTest.php b/tests/Unit/Service/TrustedCookie/ValueObject/ConfigurationTest.php new file mode 100644 index 00000000..73b17a36 --- /dev/null +++ b/tests/Unit/Service/TrustedCookie/ValueObject/ConfigurationTest.php @@ -0,0 +1,49 @@ +expectException(InvalidCookieLifetimeException::class); + $this->expectExceptionMessage('When using a persistent cookie, you must configure a non zero cookie lifetime'); + new Configuration('name', 0, 'LORUM IPSUM DOLOR SIT AMOR VINCIT OMIA', CookieSameSite::SAMESITE_STRICT->value); + } + + public function test_encryption_key_must_be_hexadecimal(): void + { + $this->expectException(InvalidEncryptionKeyException::class); + $this->expectExceptionMessage('The configured trusted device encryption key contains illegal characters. It should be a 64 digits long hexadecimal value. Example value: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'); + new Configuration('name', 60, 'Monkey nut Mies', CookieSameSite::SAMESITE_STRICT->value); + } + + public function test_encryption_key_must_be_amply_strong(): void + { + $this->expectException(InvalidEncryptionKeyException::class); + $this->expectExceptionMessage('The configured trusted device encryption key must be exactly 32 bytes. This comes down to 64 hex digits value, configured in the trusted_device_encryption_key configuration option'); + new Configuration('name', 60, '0f0f0f', CookieSameSite::SAMESITE_STRICT->value); + } +} diff --git a/tests/Unit/Service/TrustedCookie/ValueObject/CookieValueTest.php b/tests/Unit/Service/TrustedCookie/ValueObject/CookieValueTest.php new file mode 100644 index 00000000..d824f9e2 --- /dev/null +++ b/tests/Unit/Service/TrustedCookie/ValueObject/CookieValueTest.php @@ -0,0 +1,41 @@ +serialize(); + self::assertNotEmpty($serialized); + self::assertIsString($serialized); + } + + public function test_deserialization(): void + { + $cookie = CookieValue::from( '2'); + $serialized = $cookie->serialize(); + $cookieValue = CookieValue::deserialize($serialized); + self::assertInstanceOf(CookieValue::class, $cookieValue); + } +}