diff --git a/ci/qa/phpstan-baseline.neon b/ci/qa/phpstan-baseline.neon index 271b2c137..b00606d6d 100644 --- a/ci/qa/phpstan-baseline.neon +++ b/ci/qa/phpstan-baseline.neon @@ -1220,11 +1220,6 @@ parameters: count: 1 path: ../../src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php - - - message: "#^Property Surfnet\\\\Stepup\\\\Identity\\\\Event\\\\SafeStoreSecretRecoveryTokenPossessionPromisedEvent\\:\\:\\$secret \\(Surfnet\\\\Stepup\\\\Identity\\\\Value\\\\RecoveryTokenIdentifier\\) does not accept Surfnet\\\\Stepup\\\\Identity\\\\Value\\\\RecoveryTokenIdentifier\\|null\\.$#" - count: 1 - path: ../../src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php - - message: "#^Method Surfnet\\\\Stepup\\\\Identity\\\\Event\\\\SecondFactorMigratedEvent\\:\\:deserialize\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" count: 1 @@ -1412,7 +1407,7 @@ parameters: - message: "#^Cannot call method count\\(\\) on Surfnet\\\\Stepup\\\\Identity\\\\Entity\\\\SecondFactorCollection\\|null\\.$#" - count: 2 + count: 5 path: ../../src/Surfnet/Stepup/Identity/Identity.php - @@ -1527,7 +1522,7 @@ parameters: - message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, Surfnet\\\\Stepup\\\\Identity\\\\Entity\\\\SecondFactorCollection\\|null given\\.$#" - count: 4 + count: 1 path: ../../src/Surfnet/Stepup/Identity/Identity.php - @@ -2572,12 +2567,12 @@ parameters: - message: "#^Cannot access property \\$commonName on Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity\\|null\\.$#" - count: 1 + count: 2 path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php - message: "#^Cannot access property \\$email on Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity\\|null\\.$#" - count: 1 + count: 2 path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php - @@ -2587,7 +2582,7 @@ parameters: - message: "#^Parameter \\#1 \\$identity of method Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Repository\\\\IdentityRepository\\:\\:save\\(\\) expects Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity, Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity\\|null given\\.$#" - count: 3 + count: 4 path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php - diff --git a/src/Surfnet/Stepup/Identity/Api/Identity.php b/src/Surfnet/Stepup/Identity/Api/Identity.php index 9027e291c..8eae07146 100644 --- a/src/Surfnet/Stepup/Identity/Api/Identity.php +++ b/src/Surfnet/Stepup/Identity/Api/Identity.php @@ -232,6 +232,11 @@ public function expressPreferredLocale(Locale $preferredLocale): void; */ public function forget(): void; + public function restore( + CommonName $commonName, + Email $email, + ): void; + /** * @return IdentityId */ diff --git a/src/Surfnet/Stepup/Identity/Event/IdentityRestoredEvent.php b/src/Surfnet/Stepup/Identity/Event/IdentityRestoredEvent.php new file mode 100644 index 000000000..3fb65f47e --- /dev/null +++ b/src/Surfnet/Stepup/Identity/Event/IdentityRestoredEvent.php @@ -0,0 +1,117 @@ +identityId = $this->id; + $metadata->identityInstitution = $this->institution; + + return $metadata; + } + + /** + * @param array $data + */ + public static function deserialize(array $data): self + { + return new self( + new IdentityId($data['id']), + new Institution($data['institution']), + new CommonName($data['common_name']), + new Email($data['email']), + ); + } + + /** + * @return array + */ + public function serialize(): array + { + return [ + 'id' => (string)$this->id, + 'institution' => (string)$this->institution, + 'common_name' => (string)$this->commonName, + 'email' => (string)$this->email, + ]; + } + + public function getSensitiveData(): SensitiveData + { + return (new SensitiveData) + ->withCommonName($this->commonName) + ->withEmail($this->email); + } + + public function setSensitiveData(SensitiveData $sensitiveData): void + { + $this->commonName = $sensitiveData->getCommonName(); + $this->email = $sensitiveData->getEmail(); + } + + /** + * @return array + */ + public function obtainUserData(): array + { + $serializedPublicUserData = $this->serialize(); + $serializedSensitiveUserData = $this->getSensitiveData()->serialize(); + return array_merge($serializedPublicUserData, $serializedSensitiveUserData); + } + + /** + * @return string[] + */ + public function getAllowlist(): array + { + return $this->allowlist; + } +} diff --git a/src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php b/src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php index 7ec7f3149..609304ba8 100644 --- a/src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php +++ b/src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php @@ -116,9 +116,14 @@ public function getSensitiveData(): SensitiveData public function setSensitiveData(SensitiveData $sensitiveData): void { + $secret = $sensitiveData->getRecoveryTokenIdentifier(); + if ($secret === null) { + $secret = SafeStore::unknown(); + } + $this->email = $sensitiveData->getEmail(); $this->commonName = $sensitiveData->getCommonName(); - $this->secret = $sensitiveData->getRecoveryTokenIdentifier(); + $this->secret = $secret; } public function obtainUserData(): array diff --git a/src/Surfnet/Stepup/Identity/Identity.php b/src/Surfnet/Stepup/Identity/Identity.php index 4a16fb80c..63fce6ff0 100644 --- a/src/Surfnet/Stepup/Identity/Identity.php +++ b/src/Surfnet/Stepup/Identity/Identity.php @@ -52,6 +52,7 @@ use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent; +use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent; use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent; use Surfnet\Stepup\Identity\Event\LocalePreferenceExpressedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent; @@ -1010,6 +1011,17 @@ public function forget(): void $this->apply(new IdentityForgottenEvent($this->id, $this->institution)); } + public function restore( + CommonName $commonName, + Email $email, + ): void { + if (!$this->forgotten) { + return; + } + + $this->apply(new IdentityRestoredEvent($this->id, $this->institution, $commonName, $email)); + } + public function allVettedSecondFactorsRemoved(): void { $this->apply( @@ -1037,6 +1049,19 @@ protected function applyIdentityCreatedEvent(IdentityCreatedEvent $event): void $this->recoveryTokens = new RecoveryTokenCollection(); } + protected function applyIdentityRestoredEvent(IdentityRestoredEvent $event): void + { + $this->unverifiedSecondFactors = new SecondFactorCollection(); + $this->verifiedSecondFactors = new SecondFactorCollection(); + $this->vettedSecondFactors = new SecondFactorCollection(); + $this->registrationAuthorities = new RegistrationAuthorityCollection(); + $this->recoveryTokens = new RecoveryTokenCollection(); + + $this->commonName = $event->commonName; + $this->email = $event->email; + $this->forgotten = false; + } + public function applyIdentityRenamedEvent(IdentityRenamedEvent $event): void { $this->commonName = $event->commonName; @@ -1452,9 +1477,9 @@ private function assertNotForgotten(): void */ private function assertUserMayAddSecondFactor(int $maxNumberOfTokens): void { - if (count($this->unverifiedSecondFactors) + - count($this->verifiedSecondFactors) + - count($this->vettedSecondFactors) >= $maxNumberOfTokens + if ($this->unverifiedSecondFactors->count() + + $this->verifiedSecondFactors->count() + + $this->vettedSecondFactors->count() >= $maxNumberOfTokens ) { throw new DomainException( sprintf('User may not have more than %d token(s)', $maxNumberOfTokens), diff --git a/src/Surfnet/Stepup/Identity/Value/SafeStore.php b/src/Surfnet/Stepup/Identity/Value/SafeStore.php index 962287eb9..635d1417b 100644 --- a/src/Surfnet/Stepup/Identity/Value/SafeStore.php +++ b/src/Surfnet/Stepup/Identity/Value/SafeStore.php @@ -18,14 +18,20 @@ namespace Surfnet\Stepup\Identity\Value; +use Surfnet\Stepup\Exception\DomainException; +use Surfnet\Stepup\Exception\RuntimeException; + /** * Recovery token identifier for the SafeStore token type */ class SafeStore implements RecoveryTokenIdentifier { + private ?Secret $secret = null; + public function __construct( - private readonly Secret $secret, + Secret $secret, ) { + $this->secret = $secret; } public static function unknown(): self @@ -40,6 +46,9 @@ public static function hidden(): self public function getValue(): string { + if ($this->secret === null) { + throw new RuntimeException("Secret should be set"); + } return $this->secret->getSecret(); } @@ -47,7 +56,7 @@ public function equals(RecoveryTokenIdentifier $other): bool { return $other instanceof self && $other->getValue() === $this->getValue(); } - + public function __toString(): string { return $this->getValue(); diff --git a/src/Surfnet/Stepup/Tests/Identity/Event/EventSerializationAndDeserializationTest.php b/src/Surfnet/Stepup/Tests/Identity/Event/EventSerializationAndDeserializationTest.php index 3d2b558f3..d0d376009 100644 --- a/src/Surfnet/Stepup/Tests/Identity/Event/EventSerializationAndDeserializationTest.php +++ b/src/Surfnet/Stepup/Tests/Identity/Event/EventSerializationAndDeserializationTest.php @@ -40,6 +40,7 @@ use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent; +use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent; use Surfnet\Stepup\Identity\Event\LocalePreferenceExpressedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent; use Surfnet\Stepup\Identity\Event\RegistrationAuthorityInformationAmendedEvent; @@ -293,6 +294,14 @@ public function eventProvider(): array new Locale('en_GB'), ), ], + 'IdentityRestoredEvent' => [ + new IdentityRestoredEvent( + new IdentityId($this->UUID()), + new Institution('BabelFish Inc'), + new CommonName('Henk Westbroek'), + new Email('info@example.invalid'), + ), + ], 'IdentityEmailChangedEvent' => [ new IdentityEmailChangedEvent( new IdentityId($this->UUID()), diff --git a/src/Surfnet/Stepup/Tests/Identity/Event/ForgettableEventsTest.php b/src/Surfnet/Stepup/Tests/Identity/Event/ForgettableEventsTest.php index 5df9935ee..a210d06ec 100644 --- a/src/Surfnet/Stepup/Tests/Identity/Event/ForgettableEventsTest.php +++ b/src/Surfnet/Stepup/Tests/Identity/Event/ForgettableEventsTest.php @@ -32,6 +32,7 @@ use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent; +use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent; use Surfnet\Stepup\Identity\Event\PhoneRecoveryTokenPossessionProvenEvent; @@ -93,6 +94,7 @@ public function certain_events_are_forgettable_events_and_others_are_not(): void YubikeyPossessionProvenAndVerifiedEvent::class, YubikeySecondFactorBootstrappedEvent::class, RegistrationAuthorityRetractedForInstitutionEvent::class, + IdentityRestoredEvent::class, ]; $otherIdentityEventFqcns = array_diff($this->getConcreteIdentityEventFqcns(), $forgettableEventFqcns); $forgettableFqcn = Forgettable::class; diff --git a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php index a6858698d..9b7ab7683 100644 --- a/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php +++ b/src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php @@ -18,6 +18,7 @@ namespace Surfnet\StepupMiddleware\ApiBundle\Identity\Projector; +use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent; use Surfnet\Stepup\Projector\Projector; use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; @@ -75,6 +76,16 @@ public function applyLocalePreferenceExpressedEvent(LocalePreferenceExpressedEve $this->identityRepository->save($identity); } + public function applyIdentityRestoredEvent(IdentityRestoredEvent $event): void + { + $identity = $this->identityRepository->find((string)$event->identityId); + $identity->email = $event->email; + $identity->commonName = $event->commonName; + + $this->identityRepository->save($identity); + } + + public function applySecondFactorVettedEvent(SecondFactorVettedEvent $event): void { $this->determinePossessionOfSelfAssertedToken($event->vettingType, (string)$event->identityId); diff --git a/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Identity/CommandHandler/IdentityCommandHandler.php b/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Identity/CommandHandler/IdentityCommandHandler.php index 96b28f315..6894a9c7a 100644 --- a/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Identity/CommandHandler/IdentityCommandHandler.php +++ b/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Identity/CommandHandler/IdentityCommandHandler.php @@ -122,6 +122,7 @@ public function handleUpdateIdentityCommand(UpdateIdentityCommand $command): voi /** @var IdentityApi $identity */ $identity = $this->eventSourcedRepository->load(new IdentityId($command->id)); + $identity->restore(new CommonName($command->commonName), new Email($command->email)); $identity->rename(new CommonName($command->commonName)); $identity->changeEmail(new Email($command->email)); diff --git a/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Tests/Identity/CommandHandler/IdentityCommandHandlerTest.php b/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Tests/Identity/CommandHandler/IdentityCommandHandlerTest.php index 96aec0d76..51c176ddc 100644 --- a/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Tests/Identity/CommandHandler/IdentityCommandHandlerTest.php +++ b/src/Surfnet/StepupMiddleware/CommandHandlingBundle/Tests/Identity/CommandHandler/IdentityCommandHandlerTest.php @@ -38,7 +38,9 @@ use Surfnet\Stepup\Identity\Event\GssfPossessionProvenEvent; use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent; use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent; +use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent; use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent; +use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent; use Surfnet\Stepup\Identity\Event\LocalePreferenceExpressedEvent; use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent; use Surfnet\Stepup\Identity\Event\SecondFactorVettedEvent; @@ -1167,6 +1169,47 @@ public function an_identity_can_be_updated_twice_only_emitting_events_when_chang ]); } + + /** + * @test + * @group command-handler + */ + public function a_deprovisioned_identity_is_restored_when_updated(): void + { + $id = new IdentityId('42'); + $institution = new Institution('A Corp.'); + $email = new Email('info@domain.invalid'); + $commonName = new CommonName('Henk Westbroek'); + + $createdEvent = new IdentityCreatedEvent( + $id, + $institution, + new NameId('3'), + $commonName, + $email, + new Locale('de_DE'), + ); + + $forgottenEvent = new IdentityForgottenEvent( + $id, + $institution, + ); + + $updateCommand = new UpdateIdentityCommand(); + $updateCommand->id = $id->getIdentityId(); + $updateCommand->email = 'new-email@domain.invalid'; + $updateCommand->commonName = 'Henk Hendriksen'; + + $this->scenario + ->withAggregateId($id) + ->given([$createdEvent, $forgottenEvent]) + ->when($updateCommand) + ->then([ + new IdentityRestoredEvent($id, $institution, new CommonName($updateCommand->commonName), new Email($updateCommand->email)), + ]); + } + + /** * @test * @group command-handler