diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index e16f8f972..654ee2bd8 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -28,3 +28,9 @@ jobs: cd ci/docker && docker compose exec -T gateway bash -c ' composer check ' + + - name: Output logs on failure + if: failure() + run: | + cd ci/docker + docker compose logs diff --git a/ci/docker/compose.override.yml b/ci/docker/compose.override.yml index b5626029f..4aea023c8 100644 --- a/ci/docker/compose.override.yml +++ b/ci/docker/compose.override.yml @@ -1,4 +1,18 @@ services: + haproxy: + networks: + openconextdev: + aliases: + - ra.dev.openconext.local + - ssp.dev.openconext.local + - selfservice.dev.openconext.local + - middleware.dev.openconext.local + - gateway.dev.openconext.local + - demogssp.dev.openconext.local + - webauthn.dev.openconext.local + - tiqr.dev.openconext.local + - azuremfa.dev.openconext.local + - mailcatcher.dev.openconext.local gateway: image: ghcr.io/openconext/openconext-basecontainers/php82-apache2-node20-composer2:latest container_name: gateway @@ -14,3 +28,9 @@ services: mariadb: volumes: - ../../devconf/stepup/dbschema/:/docker-entrypoint-initdb.d/ + azuremfa: + image: ghcr.io/openconext/stepup-azuremfa/stepup-azuremfa:test + environment: + APP_ENV: smoketest + volumes: + - ../../devconf/stepup/:/config diff --git a/ci/docker/init.sh b/ci/docker/init.sh index e6c30d431..fa55ed575 100755 --- a/ci/docker/init.sh +++ b/ci/docker/init.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash cp ../../devconf/stepup/gateway/surfnet_yubikey.yaml.dist ../../devconf/stepup/gateway/surfnet_yubikey.yaml echo "pulling the images" -docker compose pull gateway selenium haproxy ssp mariadb +docker compose pull gateway selenium haproxy ssp mariadb azuremfa chrome echo "starting the images" -docker compose up gateway selenium haproxy ssp mariadb -d +docker compose up gateway selenium haproxy ssp mariadb azuremfa chrome -d echo "intialising the environment" docker compose exec -T gateway bash -c ' cp /var/www/html/devconf/stepup/gateway/surfnet_yubikey.yaml.dist /var/www/html/devconf/stepup/gateway/surfnet_yubikey.yaml && \ @@ -14,5 +14,6 @@ docker compose exec -T gateway bash -c ' composer frontend-install && \global_view_parameters.yaml.dist ./bin/console assets:install --env=smoketest --verbose && \ ./bin/console cache:clear --env=smoketest && \ - chown -R www-data:www-data /var/www/html/var/ + mkdir /var/www/html/var/cache/smoketest/sessions && \ + chown -R www-data /var/www/html/var/ ' diff --git a/composer.json b/composer.json index b66e13189..a8f01387a 100644 --- a/composer.json +++ b/composer.json @@ -104,7 +104,8 @@ "@phpcs", "@phpmd", "@test", - "@behat" + "@behat", + "@behat-functional" ], "phplint": "./ci/qa/phplint", "validate-lockfile": "./ci/qa/validate", diff --git a/config/openconext/parameters.yaml.dist b/config/openconext/parameters.yaml.dist index 287ea3465..f5f2c66a2 100644 --- a/config/openconext/parameters.yaml.dist +++ b/config/openconext/parameters.yaml.dist @@ -180,3 +180,14 @@ parameters: # # Example value: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f sso_encryption_key: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f + + # The GSSP ID from samlstepupproviders.yaml to use as fallback GSSP + # Set fallback_gssp to false to disable the fallback_gssp functionality + # fallback_gssp: false + fallback_gssp: 'azuremfa' + + # The user attribute to use in the Subject of the AuthnRequest to the fallback GSSP + fallback_gssp_subject_attribute: 'urn:mace:dir:attribute-def:mail' + + # The user attribute to use to determine the user's home institution + fallback_gssp_institution_attribute: 'urn:mace:terena.org:attribute-def:schacHomeOrganization' \ No newline at end of file diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Controller/SecondFactorController.php b/src/Surfnet/StepupGateway/GatewayBundle/Controller/SecondFactorController.php index 2d5fcfcd6..f9d914720 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Controller/SecondFactorController.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Controller/SecondFactorController.php @@ -36,9 +36,11 @@ use Surfnet\StepupGateway\GatewayBundle\Form\Type\VerifySmsChallengeType; use Surfnet\StepupGateway\GatewayBundle\Form\Type\VerifyYubikeyOtpType; use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext; +use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactor\SecondFactorInterface; use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService; use Surfnet\StepupGateway\GatewayBundle\Sso2fa\CookieService; use Surfnet\StepupGateway\SamlStepupProviderBundle\Controller\SamlProxyController; +use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\GsspFallbackService; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -144,6 +146,19 @@ public function selectSecondFactorForVerification( } } + // Determine if the GSSP fallback flow is allowed so we can continue without a previous registered token + if ($this->getGsspFallbackService()->determineGsspFallbackNeeded( + $identityNameId, + $authenticationMode, + $requiredLoa, + $this->get('gateway.service.whitelist'), + $logger, + $request->getLocale(), + )) { + $secondFactor = $this->getGsspFallbackService()->createSecondFactor(); + return $this->selectAndRedirectTo($secondFactor, $context, $authenticationMode); + } + $secondFactorCollection = $this ->getStepupService() ->determineViableSecondFactors( @@ -154,7 +169,6 @@ public function selectSecondFactorForVerification( switch (count($secondFactorCollection)) { case 0: $logger->notice('No second factors can give the determined Loa'); - return $this->forward( 'Surfnet\StepupGateway\GatewayBundle\Controller\GatewayController::sendLoaCannotBeGiven', ['authenticationMode' => $authenticationMode], @@ -338,7 +352,6 @@ public function verifyGssf(Request $request): Response /** @var SecondFactorService $secondFactorService */ $secondFactorService = $this->get('gateway.service.second_factor_service'); - /** @var SecondFactor $secondFactor */ $secondFactor = $secondFactorService->findByUuid($selectedSecondFactor); if (!$secondFactor) { throw new RuntimeException( @@ -355,8 +368,8 @@ public function verifyGssf(Request $request): Response return $this->forward( SamlProxyController::class . '::sendSecondFactorVerificationAuthnRequest', [ - 'provider' => $secondFactor->secondFactorType, - 'subjectNameId' => $secondFactor->secondFactorIdentifier, + 'provider' => $secondFactor->getSecondFactorType(), + 'subjectNameId' => $secondFactor->getSecondFactorIdentifier(), 'responseContextServiceId' => $responseContextServiceId, 'relayState' => $context->getRelayState(), ], @@ -377,8 +390,13 @@ public function gssfVerified(Request $request): Response $selectedSecondFactor = $this->getSelectedSecondFactor($context, $logger); - /** @var SecondFactor $secondFactor */ - $secondFactor = $this->get('gateway.service.second_factor_service')->findByUuid($selectedSecondFactor); + if (!$this->getGsspFallbackService()->isSecondFactorFallback()) { + /** @var SecondFactor $secondFactor */ + $secondFactor = $this->get('gateway.service.second_factor_service')->findByUuid($selectedSecondFactor); + } else { + $secondFactor = $this->getGsspFallbackService()->createSecondFactor(); + } + if (!$secondFactor) { throw new RuntimeException( sprintf( @@ -682,6 +700,11 @@ private function getSecondFactorService(): SecondFactorService return $this->get('gateway.service.second_factor_service'); } + private function getGsspFallbackService(): GsspFallbackService + { + return $this->get('second_factor_only.gssp_fallback_service'); + } + private function getSelectedSecondFactor(ResponseContext $context, LoggerInterface $logger): string { $selectedSecondFactor = $context->getSelectedSecondFactor(); @@ -696,16 +719,16 @@ private function getSelectedSecondFactor(ResponseContext $context, LoggerInterfa } private function selectAndRedirectTo( - SecondFactor $secondFactor, + SecondFactorInterface $secondFactor, ResponseContext $context, $authenticationMode, ): RedirectResponse { $context->saveSelectedSecondFactor($secondFactor); - $this->getStepupService()->clearSmsVerificationState($secondFactor->secondFactorId); + $this->getStepupService()->clearSmsVerificationState($secondFactor->getSecondFactorId()); $secondFactorTypeService = $this->get('surfnet_stepup.service.second_factor_type'); - $secondFactorType = new SecondFactorType($secondFactor->secondFactorType); + $secondFactorType = new SecondFactorType($secondFactor->getSecondFactorType()); $route = 'gateway_verify_second_factor_'; if ($secondFactorTypeService->isGssf($secondFactorType)) { diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Entity/DoctrineSecondFactorRepository.php b/src/Surfnet/StepupGateway/GatewayBundle/Entity/DoctrineSecondFactorRepository.php index d14730d7e..7f739c63f 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Entity/DoctrineSecondFactorRepository.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Entity/DoctrineSecondFactorRepository.php @@ -45,6 +45,12 @@ public function getAllMatchingFor(Loa $highestLoa, $identityNameId, SecondFactor return $matches; } + public function hasTokens(string $identityNameId): bool + { + $secondFactors = $this->findAllByIdentityNameId($identityNameId); + return count($secondFactors) > 0; + } + public function findOneBySecondFactorId($secondFactorId) { if (!isset($this->secondFactorsById[$secondFactorId])) { diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Entity/EnabledSecondFactorRepository.php b/src/Surfnet/StepupGateway/GatewayBundle/Entity/EnabledSecondFactorRepository.php index 5ad8eaeef..c4fe87469 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Entity/EnabledSecondFactorRepository.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Entity/EnabledSecondFactorRepository.php @@ -77,6 +77,11 @@ public function getAllMatchingFor(Loa $highestLoa, $identityNameId, SecondFactor return $enabledSecondFactors; } + public function hasTokens($identityNameId): bool + { + return $this->secondFactorRepository->hasTokens($identityNameId); + } + public function findOneBySecondFactorId($secondFactorId) { $secondFactor = $this->secondFactorRepository->findOneBySecondFactorId($secondFactorId); diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Entity/InstitutionConfiguration.php b/src/Surfnet/StepupGateway/GatewayBundle/Entity/InstitutionConfiguration.php index 844599d9f..400900ed1 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Entity/InstitutionConfiguration.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Entity/InstitutionConfiguration.php @@ -39,9 +39,17 @@ class InstitutionConfiguration */ public $ssoOn2faEnabled; - private function __construct(string $institution, bool $ssoOn2faEnabled) + /** + * * @ORM\Column(type="boolean") + * + * @var bool is the SSO registration bypass feature enabled? + */ + public bool $ssoRegistrationBypass; + + private function __construct(string $institution, bool $ssoOn2faEnabled, bool $ssoRegistrationBypass) { $this->institution = $institution; $this->ssoOn2faEnabled = $ssoOn2faEnabled; + $this->ssoRegistrationBypass = $ssoRegistrationBypass; } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactor.php b/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactor.php index 8d5800039..fa99828bb 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactor.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactor.php @@ -23,6 +23,7 @@ use Surfnet\StepupBundle\Value\Loa; use Surfnet\StepupBundle\Value\SecondFactorType; use Surfnet\StepupBundle\Value\VettingType; +use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactor\SecondFactorInterface; /** * WARNING: Any schema change made to this entity should also be applied to the Middleware SecondFactor entity! @@ -37,7 +38,7 @@ * } * ) */ -class SecondFactor +class SecondFactor implements SecondFactorInterface { /** * @var int @@ -144,4 +145,29 @@ private function determineVettingType(bool $identityVetted): VettingType } return new VettingType(VettingType::TYPE_SELF_ASSERTED_REGISTRATION); } + + public function getSecondFactorId(): string + { + return $this->secondFactorId; + } + + public function getSecondFactorType(): string + { + return $this->secondFactorType; + } + + public function getDisplayLocale(): string + { + return $this->displayLocale; + } + + public function getSecondFactorIdentifier(): string + { + return $this->secondFactorIdentifier; + } + + public function getInstitution(): string + { + return $this->institution; + } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactorRepository.php b/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactorRepository.php index dd89a1ae4..cc0e6a812 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactorRepository.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Entity/SecondFactorRepository.php @@ -32,6 +32,8 @@ interface SecondFactorRepository */ public function getAllMatchingFor(Loa $highestLoa, $identityNameId, SecondFactorTypeService $service); + public function hasTokens(string $identityNameId): bool; + /** * Loads a second factor by its ID. Subsequent calls do not hit the database. * diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Monolog/Logger/AuthenticationLogger.php b/src/Surfnet/StepupGateway/GatewayBundle/Monolog/Logger/AuthenticationLogger.php index 2694bd9de..b4e0ba4a2 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Monolog/Logger/AuthenticationLogger.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Monolog/Logger/AuthenticationLogger.php @@ -20,75 +20,36 @@ use DateTime; use Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger; -use Surfnet\StepupBundle\Service\LoaResolutionService; -use Surfnet\StepupBundle\Service\SecondFactorTypeService; -use Surfnet\StepupBundle\Value\Loa; use Surfnet\StepupGateway\GatewayBundle\Exception\InvalidArgumentException; -use Surfnet\StepupGateway\GatewayBundle\Saml\Proxy\ProxyStateHandler; +use Surfnet\StepupGateway\GatewayBundle\Saml\ResponseContext; use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService; class AuthenticationLogger { - /** - * @var ProxyStateHandler - */ - private $ssoProxyStateHandler; - - /** - * @var ProxyStateHandler - */ - private $sfoProxyStateHandler; /** * @var SecondFactorService */ private $secondFactorService; - /** - * @var LoaResolutionService - */ - private $loaResolutionService; - /** * @var SamlAuthenticationLogger */ private $authenticationChannelLogger; + private ResponseContext $sfoResponseContext; + private ResponseContext $ssoResponseContext; - /** - * @var SecondFactorTypeService - */ - private $secondFactorTypeService; public function __construct( - LoaResolutionService $loaResolutionService, - ProxyStateHandler $ssoProxyStateHandler, - ProxyStateHandler $sfoProxyStateHandler, SecondFactorService $secondFactorService, SamlAuthenticationLogger $authenticationChannelLogger, - SecondFactorTypeService $service + ResponseContext $sfoResponseContext, + ResponseContext $ssoResponseContext, ) { - $this->loaResolutionService = $loaResolutionService; - $this->ssoProxyStateHandler = $ssoProxyStateHandler; - $this->sfoProxyStateHandler = $sfoProxyStateHandler; $this->secondFactorService = $secondFactorService; $this->authenticationChannelLogger = $authenticationChannelLogger; - $this->secondFactorTypeService = $service; - } - - /** - * @param string $requestId The SAML authentication request ID of the original request (not the proxy request). - */ - public function logIntrinsicLoaAuthentication($requestId): void - { - $context = [ - 'second_factor_id' => '', - 'second_factor_type' => '', - 'institution' => '', - 'authentication_result' => 'NONE', - 'resulting_loa' => (string) $this->loaResolutionService->getLoaByLevel(Loa::LOA_1), - ]; - - $this->log('Intrinsic Loa Requested', $context, $requestId); + $this->sfoResponseContext = $sfoResponseContext; + $this->ssoResponseContext = $ssoResponseContext; } /** @@ -97,62 +58,53 @@ public function logIntrinsicLoaAuthentication($requestId): void */ public function logSecondFactorAuthentication(string $requestId, string $authenticationMode): void { - $stateHandler = $this->getStateHandler($authenticationMode); - $secondFactor = $this->secondFactorService->findByUuid($stateHandler->getSelectedSecondFactorId()); - $loa = $this->loaResolutionService->getLoaByLevel($secondFactor->getLoaLevel($this->secondFactorTypeService)); - - $context = [ - 'second_factor_id' => $secondFactor->secondFactorId, - 'second_factor_type' => $secondFactor->secondFactorType, - 'institution' => $secondFactor->institution, - 'authentication_result' => $stateHandler->isSecondFactorVerified() ? 'OK' : 'FAILED', + $context = $this->getResponseContext($authenticationMode); + + $secondFactor = $this->secondFactorService->findByUuid($context->getSelectedSecondFactor()); + $loa = $this->secondFactorService->getLoaLevel($secondFactor); + + $data = [ + 'second_factor_id' => $secondFactor->getSecondFactorId(), + 'second_factor_type' => $secondFactor->getSecondFactorType(), + 'institution' => $secondFactor->getInstitution(), + 'authentication_result' => $context->isSecondFactorVerified() ? 'OK' : 'FAILED', 'resulting_loa' => (string) $loa, - 'sso' => $stateHandler->isVerifiedBySsoOn2faCookie() ? 'YES': 'NO', + 'sso' => $context->isVerifiedBySsoOn2faCookie() ? 'YES': 'NO', ]; - if ($stateHandler->isVerifiedBySsoOn2faCookie()) { - $context['sso_cookie_id'] = $stateHandler->getSsoOn2faCookieFingerprint(); + if ($context->isVerifiedBySsoOn2faCookie()) { + $data['sso_cookie_id'] = $context->getSsoOn2faCookieFingerprint(); } - $this->log('Second Factor Authenticated', $context, $requestId); + $this->log('Second Factor Authenticated', $data, $requestId, $authenticationMode); } /** * @param string $message - * @param array $context + * @param array $data * @param string $requestId */ - private function log($message, array $context, $requestId): void + private function log(string $message, array $data, string $requestId, string $authenticationMode): void { - if (!is_string($requestId)) { - throw InvalidArgumentException::invalidType('string', 'requestId', $requestId); - } - // Regardless of authentication type, the authentication mode can be retrieved from any state handler - // given you provide the request id - $authenticationMode = $this->getStateHandler('sso')->getAuthenticationModeForRequestId($requestId); - $stateHandler = $this->getStateHandler($authenticationMode); + $context = $this->getResponseContext($authenticationMode); - $context['identity_id'] = $stateHandler->getIdentityNameId(); - $context['authenticating_idp'] = $stateHandler->getAuthenticatingIdp(); - $context['requesting_sp'] = $stateHandler->getRequestServiceProvider(); - $context['datetime'] = (new DateTime())->format('Y-m-d\\TH:i:sP'); + $data['identity_id'] = $context->getIdentityNameId(); + $data['authenticating_idp'] = $context->getAuthenticatingIdp(); + $data['requesting_sp'] = $context->getRequestServiceProvider(); + $data['datetime'] = (new DateTime())->format('Y-m-d\\TH:i:sP'); - $this->authenticationChannelLogger->forAuthentication($requestId)->notice($message, $context); + $this->authenticationChannelLogger->forAuthentication($requestId)->notice($message, $data); } - /** - * @param string $authenticationMode - * @return ProxyStateHandler - */ - private function getStateHandler($authenticationMode) + private function getResponseContext(string $authenticationMode): ResponseContext { if ($authenticationMode === 'sfo') { - return $this->sfoProxyStateHandler; + return $this->sfoResponseContext; } elseif ($authenticationMode === 'sso') { - return $this->ssoProxyStateHandler; + return $this->ssoResponseContext; } throw new InvalidArgumentException( - sprintf('Retrieving a state handler for authentication type %s is not supported', $authenticationMode) + sprintf('Retrieving a response context for authentication type %s is not supported', $authenticationMode) ); } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Resources/config/services.yml b/src/Surfnet/StepupGateway/GatewayBundle/Resources/config/services.yml index 87b3d5f81..1d8039a94 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Resources/config/services.yml +++ b/src/Surfnet/StepupGateway/GatewayBundle/Resources/config/services.yml @@ -73,6 +73,9 @@ services: class: Surfnet\StepupGateway\GatewayBundle\Service\SecondFactorService arguments: - "@gateway.repository.second_factor" + - "@surfnet_stepup.service.loa_resolution" + - "@surfnet_stepup.service.second_factor_type" + - "@second_factor_only.gssp_fallback_service" gateway.service.response_proxy: class: Surfnet\StepupGateway\GatewayBundle\Service\ProxyResponseService @@ -172,12 +175,10 @@ services: gateway.authentication_logger: class: Surfnet\StepupGateway\GatewayBundle\Monolog\Logger\AuthenticationLogger arguments: - - "@surfnet_stepup.service.loa_resolution" - - "@gateway.proxy.sso.state_handler" - - "@gateway.proxy.sfo.state_handler" - "@gateway.service.second_factor_service" - "@gateway.authentication_logger.logger" - - "@surfnet_stepup.service.second_factor_type" + - "@second_factor_only.response_context" + - "@gateway.proxy.response_context" gateway.authentication_logger.logger: class: Surfnet\SamlBundle\Monolog\SamlAuthenticationLogger diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Saml/Proxy/ProxyStateHandler.php b/src/Surfnet/StepupGateway/GatewayBundle/Saml/Proxy/ProxyStateHandler.php index 38baf98cf..5ee0e8611 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Saml/Proxy/ProxyStateHandler.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Saml/Proxy/ProxyStateHandler.php @@ -21,6 +21,10 @@ use Surfnet\StepupGateway\GatewayBundle\Saml\Exception\RuntimeException; use Symfony\Component\HttpFoundation\RequestStack; +/** + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * @SuppressWarnings(PHPMD.ExcessivePublicCount) + */ class ProxyStateHandler { public function __construct( @@ -190,6 +194,37 @@ public function isSecondFactorVerified(): bool return $this->get('selected_second_factor_verified') === true; } + + public function setSecondFactorIsFallback(bool $fallback): ProxyStateHandler + { + $this->set('selected_second_factor_fallback', $fallback); + + return $this; + } + + public function isSecondFactorFallback(): bool + { + return (bool)$this->get('selected_second_factor_fallback'); + } + + public function setGsspUserAttributes(string $subject, string $institution): ProxyStateHandler + { + $this->set('user_attribute_subject', $subject); + $this->set('user_attribute_institution', $institution); + + return $this; + } + + public function getGsspUserAttributeSubject(): string + { + return (string)$this->get('user_attribute_subject'); + } + + public function getGsspUserAttributeInstitution(): string + { + return (string)$this->get('user_attribute_institution'); + } + public function setVerifiedBySsoOn2faCookie(bool $isVerifiedByCookie): ProxyStateHandler { $this->set('verified_by_sso_on_2fa_cookie', $isVerifiedByCookie); diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Saml/ResponseContext.php b/src/Surfnet/StepupGateway/GatewayBundle/Saml/ResponseContext.php index 0ae2fa610..f9ebf4e05 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Saml/ResponseContext.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Saml/ResponseContext.php @@ -31,7 +31,9 @@ use Surfnet\StepupGateway\GatewayBundle\Saml\Exception\RuntimeException; use Surfnet\StepupGateway\GatewayBundle\Saml\Proxy\ProxyStateHandler; use Surfnet\StepupGateway\GatewayBundle\Service\SamlEntityService; +use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactor\SecondFactorInterface; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Adfs\Exception\AcsLocationNotAllowedException; +use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\SecondfactorGsspFallback; /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) @@ -242,11 +244,17 @@ public function getNormalizedSchacHomeOrganization(): ?string /** * @param SecondFactor $secondFactor */ - public function saveSelectedSecondFactor(SecondFactor $secondFactor): void + public function saveSelectedSecondFactor(SecondFactorInterface $secondFactor): void { - $this->stateHandler->setSelectedSecondFactorId($secondFactor->secondFactorId); + $this->stateHandler->setSelectedSecondFactorId($secondFactor->getSecondFactorId()); $this->stateHandler->setSecondFactorVerified(false); - $this->stateHandler->setPreferredLocale($secondFactor->displayLocale); + $this->stateHandler->setSecondFactorIsFallback($secondFactor instanceof SecondfactorGsspFallback); + $this->stateHandler->setPreferredLocale($secondFactor->getDisplayLocale()); + } + + public function getSelectedLocale(): string + { + return $this->stateHandler->getPreferredLocale(); } /** @@ -273,6 +281,9 @@ public function finalizeAuthentication(): void // a real Second Factor token. That's why this value is purged from state at this very late // point in time. $this->stateHandler->unsetVerifiedBySsoOn2faCookie(); + + $this->stateHandler->setSecondFactorIsFallback(false); + $this->stateHandler->setGsspUserAttributes('', ''); } /** @@ -344,4 +355,18 @@ public function isVerifiedBySsoOn2faCookie(): bool { return $this->stateHandler->isVerifiedBySsoOn2faCookie(); } + public function getSsoOn2faCookieFingerprint(): bool + { + return $this->stateHandler->getSsoOn2faCookieFingerprint(); + } + + public function getAuthenticatingIdp(): ?string + { + return $this->stateHandler->getAuthenticatingIdp(); + } + + public function getRequestServiceProvider(): ?string + { + return $this->stateHandler->getRequestServiceProvider(); + } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Service/Gateway/RespondService.php b/src/Surfnet/StepupGateway/GatewayBundle/Service/Gateway/RespondService.php index 109638ab4..a12b23bd7 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Service/Gateway/RespondService.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Service/Gateway/RespondService.php @@ -83,14 +83,8 @@ public function respond(ResponseContext $responseContext) $grantedLoa = null; if ($responseContext->isSecondFactorVerified()) { - $secondFactor = $this->secondFactorService->findByUuid( - $responseContext->getSelectedSecondFactor() - ); - - $secondFactorTypeService = $this->secondFactorTypeService; - $grantedLoa = $this->loaResolutionService->getLoaByLevel( - $secondFactor->getLoaLevel($secondFactorTypeService) - ); + $secondFactor = $this->secondFactorService->findByUuid($responseContext->getSelectedSecondFactor()); + $grantedLoa = $this->secondFactorService->getLoaLevel($secondFactor); } $response = $this->responseProxy->createProxyResponse( diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Service/SecondFactor/SecondFactorInterface.php b/src/Surfnet/StepupGateway/GatewayBundle/Service/SecondFactor/SecondFactorInterface.php new file mode 100644 index 000000000..4f419d11a --- /dev/null +++ b/src/Surfnet/StepupGateway/GatewayBundle/Service/SecondFactor/SecondFactorInterface.php @@ -0,0 +1,32 @@ +repository = $repository; + $this->loaResolutionService = $loaResolutionService; + $this->secondFactorTypeService = $secondFactorTypeService; + $this->gsspFallbackService = $gsspFallbackService; } /** * @param $uuid - * @return null|SecondFactor + * @return null|SecondFactorInterface */ public function findByUuid($uuid) { + if ($this->gsspFallbackService->isSecondFactorFallback()) { + return $this->gsspFallbackService->createSecondFactor(); + } + return $this->repository->findOneBySecondFactorId($uuid); } + + public function getLoaLevel(SecondFactorInterface $secondFactor): Loa + { + if ($secondFactor instanceof SecondFactor) { + return $this->loaResolutionService->getLoaByLevel($secondFactor->getLoaLevel($this->secondFactorTypeService)); + } elseif ($secondFactor instanceof SecondfactorGsspFallback) { + return $this->loaResolutionService->getLoaByLevel(Loa::LOA_SELF_VETTED); + } + + throw new RuntimeException('Unknown second factor type to determine Loa level'); + } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/CookieService.php b/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/CookieService.php index fcd7784d1..dd8f12e77 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/CookieService.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/CookieService.php @@ -111,22 +111,22 @@ public function handleSsoOn2faCookieStorage( throw new RuntimeException(sprintf('Second Factor token not found with ID: %s', $secondFactorId)); } // Test if the institution of the Identity this SF belongs to has SSO on 2FA enabled - $isEnabled = $this->institutionConfigurationService->ssoOn2faEnabled($secondFactor->institution); + $isEnabled = $this->institutionConfigurationService->ssoOn2faEnabled($secondFactor->getInstitution()); $this->logger->notice( sprintf( 'SSO on 2FA is %senabled for %s', $isEnabled ? '' : 'not ', - $secondFactor->institution + $secondFactor->getInstitution() ) ); if ($isEnabled) { $identityId = $responseContext->getIdentityNameId(); - $loa = $secondFactor->getLoaLevel($this->secondFactorTypeService); + $loa = $this->secondFactorService->getLoaLevel($secondFactor); $isVerifiedBySsoOn2faCookie = $responseContext->isVerifiedBySsoOn2faCookie(); // Did the user perform a new second factor authentication? if (!$isVerifiedBySsoOn2faCookie) { - $cookie = CookieValue::from($identityId, $secondFactor->secondFactorId, $loa); + $cookie = CookieValue::from($identityId, $secondFactor->getSecondFactorId(), $loa->getLevel()); $this->store($httpResponse, $cookie); } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/ValueObject/CookieValue.php b/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/ValueObject/CookieValue.php index 315ea85c1..057eda39f 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/ValueObject/CookieValue.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Sso2fa/ValueObject/CookieValue.php @@ -19,6 +19,7 @@ namespace Surfnet\StepupGateway\GatewayBundle\Sso2fa\ValueObject; use DateTime; +use Surfnet\StepupGateway\GatewayBundle\Sso2fa\Exception\InvalidAuthenticationTimeException; use function strtolower; use function strtotime; @@ -43,7 +44,7 @@ public static function from(string $identityId, string $secondFactorId, float $l $cookieValue->identityId = $identityId; $cookieValue->loa = $loa; $dateTime = new DateTime(); - $cookieValue->authenticationTime = $dateTime->format(DATE_ATOM); + $cookieValue->authenticationTime = $dateTime->format(DATE_RFC3339_EXTENDED); return $cookieValue; } @@ -96,6 +97,13 @@ public function issuedTo(string $identityNameId): bool public function authenticationTime(): int { - return strtotime($this->authenticationTime); + $dateTime = DateTime::createFromFormat(DATE_RFC3339_EXTENDED, $this->authenticationTime); + if (!$dateTime) { + $dateTime = DateTime::createFromFormat(DATE_RFC3339, $this->authenticationTime); + } + if (!$dateTime) { + throw new InvalidAuthenticationTimeException(); + } + return $dateTime->getTimestamp(); } } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Service/Gateway/RespondServiceTest.php b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Service/Gateway/RespondServiceTest.php index 18d3669e4..2dfec4d68 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Service/Gateway/RespondServiceTest.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Service/Gateway/RespondServiceTest.php @@ -137,16 +137,20 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the // Mock second factor $secondFactor = Mockery::mock(SecondFactor::class); - $secondFactor->secondFactorId = 'mocked-second-factor-id'; - $secondFactor->displayLocale = 'nl_NL'; - $secondFactor->shouldReceive('getLoaLevel') - ->andReturn(2); + $secondFactor->shouldReceive('getSecondFactorId') + ->andReturn('mocked-second-factor-id'); + $secondFactor->shouldReceive('getDisplayLocale') + ->andReturn('nl_NL'); // Mock second factor service $this->secondFactorService->shouldReceive('findByUuid') ->with('mocked-second-factor-id') ->andReturn($secondFactor); + $this->secondFactorService->shouldReceive('getLoaLevel') + ->with($secondFactor) + ->andReturn(new Loa(Loa::LOA_2, 'http://stepup.example.com/assurance/loa2')); + $this->mockSessionData('_sf2_attributes', [ 'surfnet/gateway/requestrequest_id' => '_123456789012345678901234567890123456789012', 'surfnet/gateway/requestservice_provider' => 'https://sp.com/metadata', @@ -212,6 +216,7 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the ', 'surfnet/gateway/requestselected_second_factor' => 'mocked-second-factor-id', 'surfnet/gateway/requestselected_second_factor_verified' => true, + 'surfnet/gateway/requestselected_second_factor_fallback' => false, 'surfnet/gateway/requestlocale' => 'nl_NL', ], $this->getSessionData('attributes')); @@ -237,8 +242,9 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the // the second factor id is reset later, we need it to determine we should set the sso 2fa cookie 'surfnet/gateway/requestselected_second_factor' => 'mocked-second-factor-id', 'surfnet/gateway/requestselected_second_factor_verified' => false, + 'surfnet/gateway/requestselected_second_factor_fallback' => false, 'surfnet/gateway/requestlocale' => 'nl_NL', - 'surfnet/gateway/requestsso_on_2fa_cookie_fingerprint' => '' + 'surfnet/gateway/requestsso_on_2fa_cookie_fingerprint' => '', ], $this->getSessionData('attributes')); } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/CookieServiceTest.php b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/CookieServiceTest.php index 33cb96a49..05d1bf153 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/CookieServiceTest.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/CookieServiceTest.php @@ -231,6 +231,10 @@ public function test_storing_a_session_cookie(): void ->shouldReceive('findByUuid') ->with('sf-id-1234') ->andReturn($sfMock); + $this->secondFactorService + ->shouldReceive('getLoaLevel') + ->with($sfMock) + ->andReturn(new Loa(2.0, 'example.org:loa-2.0')); $this->institutionService ->shouldReceive('ssoOn2faEnabled') ->with('institution-a') @@ -288,6 +292,10 @@ public function test_storing_a_persistent_cookie(): void ->shouldReceive('findByUuid') ->with('sf-id-1234') ->andReturn($sfMock); + $this->secondFactorService + ->shouldReceive('getLoaLevel') + ->with($sfMock) + ->andReturn(new Loa(2.0, 'example.org:loa-2.0')); $this->institutionService ->shouldReceive('ssoOn2faEnabled') ->with('institution-a') @@ -343,6 +351,10 @@ public function test_storing_a_session_cookie_new_authentication(): void ->shouldReceive('findByUuid') ->with('sf-id-1234') ->andReturn($sfMock); + $this->secondFactorService + ->shouldReceive('getLoaLevel') + ->with($sfMock) + ->andReturn(new Loa(2.0, 'example.org:loa-2.0')); $this->institutionService ->shouldReceive('ssoOn2faEnabled') ->with('institution-a') @@ -498,7 +510,8 @@ public function test_skipping_authentication_succeeds(): void $this->service->maySkipAuthentication( 3.0, 'ident-1234', - $cookieValue + $cookieValue, + $this->responseContext ) ); } @@ -518,7 +531,8 @@ public function test_skipping_authentication_fails_when_no_sso_cookie_present(): $this->service->maySkipAuthentication( 3.0, 'abcdef-1234', - Mockery::mock(NullCookieValue::class) + Mockery::mock(NullCookieValue::class), + $this->responseContext ) ); } @@ -544,7 +558,8 @@ public function test_skipping_authentication_fails_when_no_sso_cookie_has_too_lo $this->service->maySkipAuthentication( 4.0, // LoA required by SP is 4.0, the one in the cookie is 3.0 'abcdef-1234', - $cookieValue + $cookieValue, + $this->responseContext ) ); } @@ -570,7 +585,8 @@ public function test_skipping_authentication_fails_when_identity_id_doesnt_match $this->service->maySkipAuthentication( 2.0, 'Jane Doe', // Not issued to Jane Doe but to abcdef-1234 - $cookieValue + $cookieValue, + $this->responseContext ) ); } @@ -601,7 +617,8 @@ public function test_skipping_authentication_fails_when_token_expired(): void $this->service->maySkipAuthentication( 3.0, 'ident-1234', - $cookieValue + $cookieValue, + $this->responseContext ) ); } @@ -637,7 +654,8 @@ public function test_skipping_authentication_fails_when_token_was_revoked(): voi $this->service->maySkipAuthentication( 3.0, 'ident-1234', - $cookieValue + $cookieValue, + $this->responseContext ) ); } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/DateTime/ExpirationHelperTest.php b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/DateTime/ExpirationHelperTest.php index d1aaf0083..af138798b 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/DateTime/ExpirationHelperTest.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/DateTime/ExpirationHelperTest.php @@ -106,7 +106,7 @@ private function makeCookieValue(int $authenticationTime) : CookieValueInterface 'tokenId' => 'tokenId', 'identityId' => 'identityId', 'loa' => 2.0, - 'authenticationTime' => $dateTime->format(DATE_ATOM), + 'authenticationTime' => $dateTime->format(DATE_RFC3339_EXTENDED), ]; return CookieValue::deserialize(json_encode($data)); } diff --git a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/ValueObject/CookieValueTest.php b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/ValueObject/CookieValueTest.php index ce62ee904..bfdd0a17b 100644 --- a/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/ValueObject/CookieValueTest.php +++ b/src/Surfnet/StepupGateway/GatewayBundle/Tests/Sso2fa/ValueObject/CookieValueTest.php @@ -39,7 +39,7 @@ public function test_serialize(): void self::assertIsString($serialized); } - public function test_deserialization(): void + public function test_serialize_and_deserialization(): void { $secondFactor = Mockery::mock(SecondFactor::class); $secondFactor->secondFactorId = 'abcdef-1234'; @@ -51,6 +51,24 @@ public function test_deserialization(): void self::assertInstanceOf(CookieValue::class, $cookieValue); } + + public function test_deserialization_authentication_time_without_millis(): void + { + $serialized = '{"tokenId":"abcdef-1234","identityId":"abcdef-1234","loa":3,"authenticationTime":"2025-05-07T11:01:38+02:00"}'; + $cookieValue = CookieValue::deserialize($serialized); + self::assertInstanceOf(CookieValue::class, $cookieValue); + self::assertSame(1746608498, $cookieValue->authenticationTime()); + } + + public function test_deserialization_authentication_time_with_millis(): void + { + $serialized = '{"tokenId":"abcdef-1234","identityId":"abcdef-1234","loa":3,"authenticationTime":"2025-05-07T11:01:38.457+02:00"}'; + $cookieValue = CookieValue::deserialize($serialized); + self::assertInstanceOf(CookieValue::class, $cookieValue); + self::assertSame(1746608498, $cookieValue->authenticationTime()); + } + + /** * @dataProvider loaProvider */ diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Controller/SecondFactorOnlyController.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Controller/SecondFactorOnlyController.php index 8c2d9256e..5973048a1 100644 --- a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Controller/SecondFactorOnlyController.php +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Controller/SecondFactorOnlyController.php @@ -24,6 +24,7 @@ use Surfnet\StepupGateway\SecondFactorOnlyBundle\Adfs\Exception\InvalidAdfsResponseException; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Exception\InvalidSecondFactorMethodException; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\AdfsService; +use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\GsspFallbackService; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\LoginService; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\RespondService; use Symfony\Component\HttpFoundation\Request; @@ -88,6 +89,10 @@ public function sso(Request $httpRequest): Response return $responseRendering->renderRequesterFailureResponse($this->getResponseContext(), $httpRequest); } + // Handle SAML GSSP user attibutes extension + $logger->notice('Determine if GSSP user attributes are present for processing later on'); + $this->getGsspFallbackService()->handleSamlGsspExtension($logger, $originalRequest); + $logger->notice('Forwarding to second factor controller for loa determination and handling'); // Forward to the selectSecondFactorForVerificationSsoAction, @@ -200,4 +205,12 @@ public function getSecondFactorAdfsService() { return $this->get('second_factor_only.adfs_service'); } + + /** + * @return AdfsService + */ + public function getGsspFallbackService(): GsspFallbackService + { + return $this->get('second_factor_only.gssp_fallback_service'); + } } diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Resources/config/services.yml b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Resources/config/services.yml index c8be43a39..6882ced9c 100644 --- a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Resources/config/services.yml +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Resources/config/services.yml @@ -170,3 +170,19 @@ services: arguments: - "@second_factor_only.adfs.request_helper" - "@second_factor_only.adfs.response_helper" + + + second_factor_only.gssp_fallback_config: + class: Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\GsspFallback\GsspFallbackConfig + arguments: + - "%fallback_gssp%" + - "%fallback_gssp_subject_attribute%" + - "%fallback_gssp_institution_attribute%" + + second_factor_only.gssp_fallback_service: + class: Surfnet\StepupGateway\SecondFactorOnlyBundle\Service\Gateway\GsspFallbackService + arguments: + - "@gateway.repository.second_factor" + - "@gateway.repository.institution_configuration" + - "@gateway.proxy.sfo.state_handler" + - "@second_factor_only.gssp_fallback_config" \ No newline at end of file diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallback/GsspFallbackConfig.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallback/GsspFallbackConfig.php new file mode 100644 index 000000000..047bbc817 --- /dev/null +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallback/GsspFallbackConfig.php @@ -0,0 +1,56 @@ +gssp = $gssp; + $this->subjectAttribute = $subjectAttribute; + $this->institutionAttribute = $institutionAttribute; + } + + public function getInstitutionAttribute(): string + { + return $this->institutionAttribute; + } + + public function getSubjectAttribute(): string + { + return $this->subjectAttribute; + } + + public function getGssp(): string + { + return $this->gssp; + } + + public function isConfigured(): bool + { + return !empty($this->gssp); + } +} diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallbackService.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallbackService.php new file mode 100644 index 000000000..66c89f35b --- /dev/null +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/GsspFallbackService.php @@ -0,0 +1,168 @@ +secondFactorRepository = $secondFactorRepository; + $this->institutionConfigurationRepository = $institutionConfigurationRepository; + $this->stateHandler = $stateHandler; + $this->config = $config; + } + + /** + * @param ReceivedAuthnRequest $originalRequest + */ + public function handleSamlGsspExtension(LoggerInterface $logger, ReceivedAuthnRequest $originalRequest): void + { + if (!$this->config->isConfigured()) { + return; + } + + $logger->info('GSSP fallback configured, parsing GSSP extension from AuthnRequest'); + + if ($originalRequest->getExtensions()->hasGsspUserAttributesChunk()) { + $logger->info( + sprintf('GSSP extension found, setting user attributes in state') + ); + + $gsspUserAttributes = $originalRequest->getExtensions()->getGsspUserAttributesChunk(); + + $subject = $gsspUserAttributes->getAttributeValue($this->config->getSubjectAttribute()); + $institution = $gsspUserAttributes->getAttributeValue($this->config->getInstitutionAttribute()); + + $logger->info( + sprintf( + 'GSSP extension found, setting user attributes in state: subject: %s, institution: %s', + $subject, + $institution + ) + ); + + $this->stateHandler->setGsspUserAttributes($subject, $institution); + } + } + + public function determineGsspFallbackNeeded( + string $identityNameId, + string $authenticationMode, + Loa $requestedLoa, + WhitelistService $whitelistService, + LoggerInterface $logger, + string $locale, + ): bool { + + // Determine if the GSSP fallback flow should be started based on the following conditions: + // - the authentication mode is SFO + // - a fallback GSSP is configured + // - a LoA1.5 (i.e. self asserted) authentication is requested + // - the GSSP user attributes are available in the AuthnRequest + // - the GSSP institution in the extension is whitelisted + // - this "fallback" option is enabled for the institution that the user belongs to. + // - the user has no registered tokens + + if ($authenticationMode !== SecondFactorController::MODE_SFO) { + $this->stateHandler->setSecondFactorIsFallback(false); + return false; + } + + if (!$this->config->isConfigured()) { + $this->stateHandler->setSecondFactorIsFallback(false); + return false; + } + + if (!$requestedLoa->levelIsLowerOrEqualTo(Loa::LOA_SELF_VETTED)) { + $logger->info('Gssp Fallback configured but not used, requested LoA is higher than self-vetted'); + $this->stateHandler->setSecondFactorIsFallback(false); + return false; + } + + $subject = $this->stateHandler->getGsspUserAttributeSubject(); + $institution = $this->stateHandler->getGsspUserAttributeInstitution(); + if (empty($subject) || empty($institution)) { + $this->stateHandler->setSecondFactorIsFallback(false); + $logger->info('Gssp Fallback configured but not used, GSSP user attributes are not set in AuthnRequest'); + return false; + } + + if (!$whitelistService->contains($institution)) { + $this->stateHandler->setSecondFactorIsFallback(false); + $logger->info('Gssp Fallback configured but not used, GSSP institution is not whitelisted'); + return false; + } + + $institutionConfiguration = $this->institutionConfigurationRepository->getInstitutionConfiguration($institution); + if (!$institutionConfiguration->ssoRegistrationBypass) { + $this->stateHandler->setSecondFactorIsFallback(false); + $logger->info('Gssp Fallback configured but not used, GSSP fallback is not enabled for the institution'); + return false; + } + + if ($this->secondFactorRepository->hasTokens($identityNameId)) { + $this->stateHandler->setSecondFactorIsFallback(false); + $logger->info('Gssp Fallback configured but not used, the identity has registered tokens'); + return false; + } + + $logger->info('Gssp Fallback flow started'); + + $this->stateHandler->setSecondFactorIsFallback(true); + $this->stateHandler->setPreferredLocale($locale); + + return true; + } + + public function isSecondFactorFallback(): bool + { + return $this->stateHandler->isSecondFactorFallback(); + } + + public function createSecondFactor(): SecondFactorInterface + { + return SecondfactorGsspFallback::create( + $this->stateHandler->getGsspUserAttributeSubject(), + $this->stateHandler->getGsspUserAttributeInstitution(), + $this->config->getGssp(), + (string)$this->stateHandler->getPreferredLocale() + ); + } +} diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/RespondService.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/RespondService.php index eb53f40b7..966764ba0 100644 --- a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/RespondService.php +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/RespondService.php @@ -102,12 +102,10 @@ public function respond(ResponseContext $responseContext, Request $request) } $secondFactor = $this->secondFactorService->findByUuid($selectedSecondFactorUuid); + $loaLevel = $this->secondFactorService->getLoaLevel($secondFactor); $this->responseValidator->validate($request, $secondFactor, $responseContext->getIdentityNameId()); - $grantedLoa = $this->loaResolutionService - ->getLoaByLevel($secondFactor->getLoaLevel($this->secondFactorTypeService)); - - $authnContextClassRef = $this->loaAliasLookupService->findAliasByLoa($grantedLoa); + $authnContextClassRef = $this->loaAliasLookupService->findAliasByLoa($loaLevel); $response = $this->responseFactory->createSecondFactorOnlyResponse( $responseContext->getIdentityNameId(), diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/ResponseValidator.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/ResponseValidator.php index cc26038e4..5c20becc3 100644 --- a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/ResponseValidator.php +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/ResponseValidator.php @@ -21,7 +21,7 @@ use Surfnet\SamlBundle\Http\PostBinding; use Surfnet\StepupBundle\Service\SecondFactorTypeService; use Surfnet\StepupBundle\Value\SecondFactorType; -use Surfnet\StepupGateway\GatewayBundle\Entity\SecondFactor; +use Surfnet\StepupGateway\GatewayBundle\Service\SecondFactor\SecondFactorInterface; use Surfnet\StepupGateway\SamlStepupProviderBundle\Provider\ProviderRepository; use Surfnet\StepupGateway\SecondFactorOnlyBundle\Exception\ReceivedInvalidSubjectNameIdException; use Symfony\Component\HttpFoundation\Request; @@ -50,9 +50,9 @@ public function __construct( /** * */ - public function validate(Request $request, SecondFactor $secondFactor, string $nameIdFromState): void + public function validate(Request $request, SecondFactorInterface $secondFactor, string $nameIdFromState): void { - $secondFactorType = new SecondFactorType($secondFactor->secondFactorType); + $secondFactorType = new SecondFactorType($secondFactor->getSecondFactorType()); $hasSamlResponse = $request->request->has('SAMLResponse'); // When dealing with a GSSP response. It is advised to receive the SAML response through POST Binding, // testing the preconditions. @@ -66,13 +66,13 @@ public function validate(Request $request, SecondFactor $secondFactor, string $n ); $subjectNameIdFromResponse = $samlResponse->getNameId()->getValue(); // Additionally test if the name id from the GSSP matches the SF identifier that we have in state - if ($subjectNameIdFromResponse !== $secondFactor->secondFactorIdentifier) { + if ($subjectNameIdFromResponse !== $secondFactor->getSecondFactorIdentifier()) { throw new ReceivedInvalidSubjectNameIdException( sprintf( 'The nameID received from the GSSP (%s) did not match the selected second factor (%s). This '. 'might be an indication someone is tampering with a GSSP. The authentication was started by %s', $subjectNameIdFromResponse, - $secondFactor->secondFactorIdentifier, + $secondFactor->getSecondFactorIdentifier(), $nameIdFromState ) ); diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/SecondfactorGsspFallback.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/SecondfactorGsspFallback.php new file mode 100644 index 000000000..f0624c0e6 --- /dev/null +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Service/Gateway/SecondfactorGsspFallback.php @@ -0,0 +1,68 @@ +secondFactorType = $secondFactorType; + $this->displayLocale = $displayLocale; + $this->subject = $subject; + $this->institution = $institution; + } + + public static function create(string $subject, string $institution, string $type, string $displayLocale) + { + return new self($subject, $institution, $type, $displayLocale); + } + + public function getSecondFactorId(): string + { + return self::SECOND_FACTOR_ID; + } + + public function getSecondFactorType(): string + { + return $this->secondFactorType; + } + + public function getDisplayLocale(): string + { + return $this->displayLocale; + } + + public function getSecondFactorIdentifier(): string + { + return $this->subject; + } + + public function getInstitution(): string + { + return $this->institution; + } +} diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/GsspFallback/GsspFallbackServiceTest.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/GsspFallback/GsspFallbackServiceTest.php new file mode 100644 index 000000000..5fb3f5005 --- /dev/null +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/GsspFallback/GsspFallbackServiceTest.php @@ -0,0 +1,302 @@ +secondFactorRepository = m::mock(SecondFactorRepository::class); + $this->institutionConfiguration = m::mock(InstitutionConfigurationRepository::class); + + $this->logger = m::mock(LoggerInterface::class); + $this->logger->shouldIgnoreMissing(); + + $this->stateHandler = m::mock(ProxyStateHandler::class); + + $this->config = new GsspFallbackConfig( + 'azuremfa', + 'urn:mace:dir:attribute-def:mail', + 'urn:mace:terena.org:attribute-def:schacHomeOrganization', + ); + + $this->service = new GsspFallbackService( + $this->secondFactorRepository, + $this->institutionConfiguration, + $this->stateHandler, + $this->config, + ); + + parent::setUp(); + } + + /** + * @test + */ + public function it_can_parse_gssp_extension_attributes(): void + { + + $data = <<https://ssp.dev.openconext.local/module.php/saml/sp/metadata.php/second-sp + + + uSHBxKay4eT+NJQl03Uor3zDlQsedbcI9xtZSCLZULc=d6kZDPJsLUyGfx1v597rmBIxjdUN5R8OnM4U1beX6HpKSl7CjkNtPXPkYdGnuDL0VEbZIIaS2TzbWYmw3JEQ+g+OL5NCAYGdo2hpOm00n6ygd5jPGbSsgVzhIMFMbRrxKoff8/WyNFv1kz2xtRNLqlqDmxVxptyoJbtj7FcoHBy33/0zASzLGZpWFa/VTfpEsG/ixyxsYBjPMPlfCkaXJa9w6XWUhBvNtRv5VUneA0pbIexSN185YnsMenIfmsMPU6dXq6c0Y4IbIkco/2VBH+W3o7yBLVSCfL2PsSk6eNjE6tHUb7Eilzz5GverfmP9vaV7ltnoJfdem6XO26iv089uNswIFaFaV/RqltGFQe+FeRNwori9SKwIF7Q14mkoBP7xxHzgdWIV/W9LLiYT0aZ+/pqBFg+VRjrUaNjS9hBlbVDiGmHnNPbwrkHkeqXEePnHZHLoKcVTuB7PxQb73px2nR7TQwVvXh4M1OgzgSgSBU5AhOxpmw6FGBBu8XAs +john_haak@dev.openconext.localdev.openconext.localurn:collab:person:dev.openconext.local:john_haackhttp://dev.openconext.local/assurance/sfo-level1.5 +AUTHNREQUEST; + + $authnRequest = ReceivedAuthnRequest::from($data); + + $this->stateHandler->expects('setGsspUserAttributes') + ->with('john_haak@dev.openconext.local', 'dev.openconext.local') + ->once(); + + $this->service->handleSamlGsspExtension($this->logger, $authnRequest); + + $this->assertInstanceOf(ReceivedAuthnRequest::class, $authnRequest); + } + + + /** + * @test + */ + public function it_can_determine_when_the_gssp_fallback_is_needed(): void + { + $subject = 'urn:collab:person:dev.openconext.local:john_haack'; + $gsspSubject = 'john_haack@dev.openconext.local'; + $gsspInstitution = 'dev.openconext.local'; + $locale = 'en_GB'; + $preferredLoa = 1.5; + $authenticationMode = SecondFactorController::MODE_SFO; + + $this->stateHandler->shouldReceive('getGsspUserAttributeSubject') + ->once() + ->andReturn($gsspSubject); + + $this->stateHandler->shouldReceive('getGsspUserAttributeInstitution') + ->once() + ->andReturn($gsspInstitution); + + $institutionConfiguration = m::mock(InstitutionConfiguration::class); + $institutionConfiguration->ssoRegistrationBypass = true; + + $this->institutionConfiguration->shouldReceive('getInstitutionConfiguration') + ->with($gsspInstitution) + ->andReturn($institutionConfiguration); + + $whitelistService = m::mock(WhitelistService::class); + $whitelistService->shouldReceive('contains') + ->once() + ->with($gsspInstitution) + ->andReturn(true); + + $this->secondFactorRepository->shouldReceive('hasTokens') + ->with($subject) + ->once() + ->andReturn(false); + + + $this->stateHandler->shouldReceive('setSecondFactorIsFallback') + ->with(true) + ->once(); + + $this->stateHandler->shouldReceive('setPreferredLocale') + ->with($locale) + ->once(); + + $result = $this->service->determineGsspFallbackNeeded( + $subject, + $authenticationMode, + new Loa($preferredLoa, 'example.org:loa-level'), + $whitelistService, + $this->logger, + $locale, + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function it_should_only_use_the_gssp_fallback_when_configured(): void + { + $this->config = new GsspFallbackConfig( + '', + 'urn:mace:dir:attribute-def:mail', + 'urn:mace:terena.org:attribute-def:schacHomeOrganization', + ); + + $this->service = new GsspFallbackService( + $this->secondFactorRepository, + $this->institutionConfiguration, + $this->stateHandler, + $this->config, + ); + + + $subject = 'urn:collab:person:dev.openconext.local:john_haack'; + $locale = 'en_GB'; + $preferredLoa = 1.5; + $authenticationMode = SecondFactorController::MODE_SFO; + + $whitelistService = m::mock(WhitelistService::class); + + $this->stateHandler->shouldReceive('setSecondFactorIsFallback') + ->with(false) + ->once(); + + $result = $this->service->determineGsspFallbackNeeded( + $subject, + $authenticationMode, + new Loa($preferredLoa, 'example.org:loa-level'), + $whitelistService, + $this->logger, + $locale, + ); + + $this->assertFalse($result); + } + + /** + * @test + * @dataProvider gsspFallbackNotAllowedDataProvider + */ + public function it_can_determine_when_the_gssp_fallback_is_not_needed( + string $authenticationMode, + float $preferredLoa, + string $gsspSubject, + string $gsspInstitution, + bool $isWhitelisted, + bool $ssoRegistrationBypass, + bool $userHasTokens, + ): void { + + $subject = 'urn:collab:person:dev.openconext.local:john_haack'; + $locale = 'en_GB'; + + $this->stateHandler->shouldReceive('getGsspUserAttributeSubject') + ->andReturn($gsspSubject); + + $this->stateHandler->shouldReceive('getGsspUserAttributeInstitution') + ->andReturn($gsspInstitution); + + $institutionConfiguration = m::mock(InstitutionConfiguration::class); + $institutionConfiguration->ssoRegistrationBypass = $ssoRegistrationBypass; + + $this->institutionConfiguration->shouldReceive('getInstitutionConfiguration') + ->with($gsspInstitution) + ->andReturn($institutionConfiguration); + + $whitelistService = m::mock(WhitelistService::class); + $whitelistService->shouldReceive('contains') + ->with($gsspInstitution) + ->andReturn($isWhitelisted); + + $this->secondFactorRepository->shouldReceive('hasTokens') + ->with($subject) + ->andReturn($userHasTokens); + + + $this->stateHandler->shouldReceive('setSecondFactorIsFallback') + ->with(false) + ->once(); + + $this->stateHandler->shouldNotReceive('setPreferredLocale') + ->with($locale); + + $result = $this->service->determineGsspFallbackNeeded( + $subject, + $authenticationMode, + new Loa($preferredLoa, 'example.org:loa-level'), + $whitelistService, + $this->logger, + $locale, + ); + + $this->assertFalse($result); + } + + + public function gsspFallbackNotAllowedDataProvider() + { + return [ + 'wrong authentication mode' => [SecondFactorController::MODE_SSO, 1.5, 'john_haack@dev.openconext.local', 'dev.openconext.local', true, true, false,], + 'invalid preferred loa level' => [SecondFactorController::MODE_SFO, 2.0, 'john_haack@dev.openconext.local', 'dev.openconext.local', true, true, false,], + 'no gssp subject attribute set' => [SecondFactorController::MODE_SFO, 1.5, '', 'dev.openconext.local', true, true, false,], + 'no gssp institution attribute set' => [SecondFactorController::MODE_SFO, 1.5, 'john_haack@dev.openconext.local', '', true, true, false,], + 'institution not whitelisted' => [SecondFactorController::MODE_SFO, 1.5, 'john_haack@dev.openconext.local', 'dev.openconext.local', false, true, false,], + 'fallback option disabled for institution' => [SecondFactorController::MODE_SFO, 1.5, 'john_haack@dev.openconext.local', 'dev.openconext.local', true, false, false,], + 'user has tokens' => [SecondFactorController::MODE_SFO, 1.5, 'john_haack@dev.openconext.local', 'dev.openconext.local', true, true, true,], + ]; + } + + + /** + * @test + */ + public function it_can_create_a_gssp_fallback_token(): void + { + $secondFactorId = 'gssp_fallback'; + $gsspSubject = 'john_haack@dev.openconext.local'; + $gsspInstitution = 'dev.openconext.local'; + $locale = 'en_GB'; + + + $this->stateHandler->shouldReceive('getGsspUserAttributeSubject') + ->once() + ->andReturn($gsspSubject); + $this->stateHandler->shouldReceive('getGsspUserAttributeInstitution') + ->once() + ->andReturn($gsspInstitution); + $this->stateHandler->shouldReceive('getPreferredLocale') + ->once() + ->andReturn($locale); + + $token = $this->service->createSecondFactor(); + + $this->assertSame($secondFactorId, $token->getSecondFactorId()); + $this->assertSame($gsspSubject, $token->getSecondFactorIdentifier()); + $this->assertSame($gsspInstitution, $token->getInstitution()); + $this->assertSame($locale, $token->getDisplayLocale()); + } + +} \ No newline at end of file diff --git a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/RespondServiceTest.php b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/RespondServiceTest.php index 29d92d2e3..bc20eee4f 100644 --- a/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/RespondServiceTest.php +++ b/src/Surfnet/StepupGateway/SecondFactorOnlyBundle/Tests/Service/Gateway/RespondServiceTest.php @@ -133,16 +133,20 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the // Mock second factor $secondFactor = Mockery::mock(SecondFactor::class); - $secondFactor->secondFactorId = 'mocked-second-factor-id'; - $secondFactor->displayLocale = 'nl_NL'; - $secondFactor->shouldReceive('getLoaLevel') - ->andReturn(2); + $secondFactor->shouldReceive('getSecondFactorId') + ->andReturn('mocked-second-factor-id'); + $secondFactor->shouldReceive('getDisplayLocale') + ->andReturn('nl_NL'); // Mock second factor service $this->secondFactorService->shouldReceive('findByUuid') ->with('mocked-second-factor-id') ->andReturn($secondFactor); + $this->secondFactorService->shouldReceive('getLoaLevel') + ->with($secondFactor) + ->andReturn(new Loa(2.0, 'http://stepup.example.com/assurance/loa2')); + // This should be done in the SecondFactorController on success $this->responseContext->saveSelectedSecondFactor($secondFactor); $this->responseContext->markSecondFactorVerified(); @@ -190,6 +194,7 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the 'surfnet/gateway/requestloa_identifier' => 'http://stepup.example.com/assurance/loa2', 'surfnet/gateway/requestselected_second_factor' => 'mocked-second-factor-id', 'surfnet/gateway/requestselected_second_factor_verified' => true, + 'surfnet/gateway/requestselected_second_factor_fallback' => false, 'surfnet/gateway/requestlocale' => 'nl_NL', ], $this->getSessionData('attributes')); @@ -210,8 +215,9 @@ public function it_should_return_a_valid_saml_response_and_update_state_when_the // This is reset right after setting or not setting the SSO on 2FA cookie. 'surfnet/gateway/requestselected_second_factor' => 'mocked-second-factor-id', 'surfnet/gateway/requestselected_second_factor_verified' => false, + 'surfnet/gateway/requestselected_second_factor_fallback' => false, 'surfnet/gateway/requestlocale' => 'nl_NL', - 'surfnet/gateway/requestsso_on_2fa_cookie_fingerprint' => '' + 'surfnet/gateway/requestsso_on_2fa_cookie_fingerprint' => '', ], $this->getSessionData('attributes')); } @@ -244,16 +250,20 @@ public function it_halts_execution_when_saml_response_is_invalid(): void // Mock second factor $secondFactor = Mockery::mock(SecondFactor::class); - $secondFactor->secondFactorId = 'mocked-second-factor-id'; - $secondFactor->displayLocale = 'nl_NL'; - $secondFactor->shouldReceive('getLoaLevel') - ->andReturn(2); + $secondFactor->shouldReceive('getSecondFactorId') + ->andReturn('mocked-second-factor-id'); + $secondFactor->shouldReceive('getDisplayLocale') + ->andReturn('nl_NL'); // Mock second factor service $this->secondFactorService->shouldReceive('findByUuid') ->with('mocked-second-factor-id') ->andReturn($secondFactor); + $this->secondFactorService->shouldReceive('getLoaLevel') + ->with($secondFactor) + ->andReturn(new Loa(2.0, 'http://stepup.example.com/assurance/loa2')); + // This should be done in the SecondFactorController on success $this->responseContext->saveSelectedSecondFactor($secondFactor); $this->responseContext->markSecondFactorVerified(); @@ -334,16 +344,20 @@ public function it_should_throw_an_exception_when_the_second_factor_is_not_verif // Mock second factor $secondFactor = Mockery::mock(SecondFactor::class); - $secondFactor->secondFactorId = 'mocked-second-factor-id'; - $secondFactor->displayLocale = 'nl_NL'; - $secondFactor->shouldReceive('getLoaLevel') - ->andReturn(2); + $secondFactor->shouldReceive('getSecondFactorId') + ->andReturn('mocked-second-factor-id'); + $secondFactor->shouldReceive('getDisplayLocale') + ->andReturn('nl_NL'); // Mock second factor service $this->secondFactorService->shouldReceive('findByUuid') ->with('mocked-second-factor-id') ->andReturn($secondFactor); + $this->secondFactorService->shouldReceive('getLoaLevel') + ->with($secondFactor) + ->andReturn(new Loa(2.0, 'http://stepup.example.com/assurance/loa2')); + // This should be done in the SecondFactorController on success $this->responseContext->saveSelectedSecondFactor($secondFactor); diff --git a/tests/features/bootstrap/FeatureContext.php b/tests/features/bootstrap/FeatureContext.php index e9bcaa1c5..235b33818 100644 --- a/tests/features/bootstrap/FeatureContext.php +++ b/tests/features/bootstrap/FeatureContext.php @@ -223,6 +223,9 @@ public function anInstitutionThatAllows(string $institution, string $option): vo case $option === 'sso_on_2fa': $optionColumnName = 'sso_on2fa_enabled'; break; + case $option === 'sso_registration_bypass': + $optionColumnName = 'sso_registration_bypass'; + break; default: throw new RuntimeException(sprintf('Option "%s" is not supported', $option)); } @@ -376,7 +379,7 @@ public function theSSO2FACookieIsRewritten(): void if ($this->previousSsoOn2faCookieValue === $cookieValue) { throw new ExpectationException( - 'The SSO on 2FA cookie did not change since the previous response', + sprintf('The SSO on 2FA cookie did not change since the previous response: "%s" !== "%s"', $this->previousSsoOn2faCookieValue, $cookieValue), $this->minkContext->getSession()->getDriver() ); } diff --git a/tests/features/bootstrap/ServiceProviderContext.php b/tests/features/bootstrap/ServiceProviderContext.php index e77aabc4e..a06c99ef4 100644 --- a/tests/features/bootstrap/ServiceProviderContext.php +++ b/tests/features/bootstrap/ServiceProviderContext.php @@ -30,6 +30,8 @@ use SAML2\Certificate\PrivateKeyLoader; use SAML2\Configuration\PrivateKey; use SAML2\Constants; +use SAML2\DOMDocumentFactory; +use SAML2\XML\Chunk; use SAML2\XML\saml\Issuer; use SAML2\XML\saml\NameID; use Surfnet\SamlBundle\Entity\IdentityProvider; @@ -159,7 +161,7 @@ public function iStartAnSFOAuthentication($nameId): void /** * @When /^([^\']*) starts an SFO authentication with LoA ([^\']*)$/ */ - public function iStartAnSFOAuthenticationWithLoa($nameId, string $loa, bool $forceAuthN = false): void + public function iStartAnSFOAuthenticationWithLoa($nameId, string $loa, bool $forceAuthN = false, ?string $gsspFallbackSubject = null, ?string $gsspFallbackInstitution = null): void { $authnRequest = new AuthnRequest(); // In order to later assert if the response succeeded or failed, set our own dummy ACS location @@ -194,6 +196,32 @@ public function iStartAnSFOAuthenticationWithLoa($nameId, string $loa, bool $for default: throw new RuntimeException(sprintf('The specified LoA-%s is not supported', $loa)); } + + if ($gsspFallbackSubject != null) { + $dom = DOMDocumentFactory::create(); + $ce = $dom->createElementNS('urn:mace:surf.nl:stepup:gssp-extensions', 'gssp:UserAttributes'); + $ce->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $ce->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xs', 'http://www.w3.org/2001/XMLSchema'); + + foreach ([ + 'urn:mace:dir:attribute-def:mail' => $gsspFallbackSubject, + 'urn:mace:terena.org:attribute-def:schacHomeOrganization' => $gsspFallbackInstitution, + + ] as $name => $value) { + $attrib = $ce->ownerDocument->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Attribute'); + $attrib->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'); + $attrib->setAttribute('Name', $name); + $attribValue = $ce->ownerDocument->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:AttributeValue', $value); + $attribValue->setAttribute('xsi:type', 'xs:string'); + $attrib->appendChild($attribValue); + + $ce->appendChild($attrib); + } + + $ext = $authnRequest->getExtensions(); + $ext['saml:Extensions'] = new Chunk($ce); + $authnRequest->setExtensions($ext); + } $request = Saml2AuthnRequest::createNew($authnRequest); $query = $request->buildRequestQuery(); @@ -215,6 +243,14 @@ public function iStartAForcedSFOAuthenticationWithLoaRequirement($nameId, $loa): $this->iStartAnSFOAuthenticationWithLoa($nameId, $loa, true); } + /** + * @When /^([^\']*) starts an SFO authentication with GSSP fallback requiring LoA ([^\']*) and Gssp extension subject ([^\']*) and institution ([^\']*)$/ + */ + public function iStartAForcedSFOAuthenticationWithLoaRequirementAndGsspExtension($nameId, $loa, $subject, $institution): void + { + $this->iStartAnSFOAuthenticationWithLoa($nameId, $loa, false, $subject, $institution); + } + /** * @When /^([^\']*) starts an ADFS authentication requiring ([^\']*)$/ */ @@ -315,18 +351,47 @@ public function iAuthenticateAtTheIdp($username): void } /** - * @return IdentityProvider + * @When /^I authenticate at AzureMFA as "([^"]*)"$/ + */ + public function iAuthenticateAtAzureMfaAs($username): void + { + $this->minkContext->assertPageAddress('https://azuremfa.dev.openconext.local/mock/sso'); + $attributes = sprintf('[ + { + "name":"urn:mace:dir:attribute-def:mail", + "value": [ + "%s" + ] + }, + { + "name": "http://schemas.microsoft.com/claims/authnmethodsreferences", + "value": [ + "http://schemas.microsoft.com/claims/multipleauthn" + ] + } + ] + ', $username); + $this->minkContext->fillField('attributes', $attributes); + $this->minkContext->pressButton('success'); + + $this->minkContext->assertPageAddress('https://azuremfa.dev.openconext.local/mock/sso'); + $this->minkContext->pressButton('Submit assertion'); + + $this->minkContext->assertPageAddress('https://gateway.dev.openconext.local/test/authentication/consume-assertion'); + } + + /** + * @When /^I cancel the authentication at AzureMFA$/ */ - public function getIdentityProvider() + public function iCancelTheAuthenticationAtAzureMfa(): void { - /** @var RequestStack $stack */ + $this->minkContext->assertPageAddress('https://azuremfa.dev.openconext.local/mock/sso'); + $this->minkContext->pressButton('cancel'); - $stack = $this->kernel->getContainer()->get('request_stack'); - $stack->push(Request::create('https://gateway.dev.openconext.local')); - $ip = $this->kernel->getContainer()->get('surfnet_saml.hosted.identity_provider'); - $stack->pop(); + $this->minkContext->assertPageAddress('https://azuremfa.dev.openconext.local/mock/sso'); + $this->minkContext->pressButton('Submit assertion'); - return $ip; + $this->minkContext->assertPageAddress('https://gateway.dev.openconext.local/test/authentication/consume-assertion'); } private static function loadPrivateKey(PrivateKey $key) diff --git a/tests/features/sfo-registration-bypass.feature b/tests/features/sfo-registration-bypass.feature new file mode 100644 index 000000000..a2ff63b26 --- /dev/null +++ b/tests/features/sfo-registration-bypass.feature @@ -0,0 +1,36 @@ +@functional +Feature: As an institution that uses the registration bypass feature + In order to do second factor authentications + I must be able to successfully authenticate with my second factor tokens without prior registration + + Scenario: An AzureMFA GSSP fallback SFO authentication + Given an SFO enabled SP with EntityID https://ssp.dev.openconext.local/module.php/saml/sp/metadata.php/second-sp + And an IdP with EntityID https://ssp.dev.openconext.local/saml2/idp/metadata.php + And a whitelisted institution dev.openconext.local + And an institution "dev.openconext.local" that allows "sso_registration_bypass" + When urn:collab:person:dev.openconext.local:john_haack starts an SFO authentication with GSSP fallback requiring LoA 1.5 and Gssp extension subject john_haak@institution-a.example.com and institution dev.openconext.local + And I authenticate at AzureMFA as "john_haak@institution-a.example.com" + Then the response should match xpath '//samlp:StatusCode[@Value="urn:oasis:names:tc:SAML:2.0:status:Success"]' + And the response should match xpath '//saml:Audience[text()="https://ssp.dev.openconext.local/module.php/saml/sp/metadata.php/second-sp"]' + And the response should match xpath '//saml:NameID[text()="urn:collab:person:dev.openconext.local:john_haack"]' + + Scenario: An AzureMFA GSSP fallback SFO authentication is cancelled + Given an SFO enabled SP with EntityID https://ssp.dev.openconext.local/module.php/saml/sp/metadata.php/second-sp + And an IdP with EntityID https://ssp.dev.openconext.local/saml2/idp/metadata.php + And a whitelisted institution dev.openconext.local + And a whitelisted institution dev.openconext.local + And an institution "dev.openconext.local" that allows "sso_registration_bypass" + When urn:collab:person:dev.openconext.local:john_haack starts an SFO authentication with GSSP fallback requiring LoA 1.5 and Gssp extension subject john_haak@institution-a.example.com and institution dev.openconext.local + And I cancel the authentication at AzureMFA + Then an error response is posted back to the SP + Then the response should match xpath '//samlp:StatusCode[@Value="urn:oasis:names:tc:SAML:2.0:status:AuthnFailed"]' + + Scenario: An AzureMFA GSSP fallback SFO authentication should not work when a token was already registered + Given an SFO enabled SP with EntityID https://ssp.dev.openconext.local/module.php/saml/sp/metadata.php/second-sp + And an IdP with EntityID https://ssp.dev.openconext.local/saml2/idp/metadata.php + And a whitelisted institution dev.openconext.local + And a whitelisted institution dev.openconext.local + And an institution "dev.openconext.local" that allows "sso_registration_bypass" + And a user from "dev.openconext.local" identified by "urn:collab:person:dev.openconext.local:john_haack" with a vetted "Yubikey" token + When urn:collab:person:dev.openconext.local:john_haack starts an SFO authentication with GSSP fallback requiring LoA 1.5 and Gssp extension subject john_haak@institution-a.example.com and institution dev.openconext.local + Then I should see the Yubikey OTP screen diff --git a/tests/src/Repository/InstitutionConfigurationRepository.php b/tests/src/Repository/InstitutionConfigurationRepository.php index 6496d3816..03cd0922f 100644 --- a/tests/src/Repository/InstitutionConfigurationRepository.php +++ b/tests/src/Repository/InstitutionConfigurationRepository.php @@ -48,10 +48,11 @@ public function configure(string $institution, string $option, bool $value) $data = [ 'institution' => $institution, 'sso_on2fa_enabled' => $value, + 'sso_registration_bypass' => $value, ]; $sql = <<connection->prepare($sql); if ($stmt->execute($data)) { @@ -70,8 +71,8 @@ public function configure(string $institution, string $option, bool $value) 'value' => $value, ]; $sql = <<