Skip to content

Commit ea45af0

Browse files
committed
Allow identity restoration
Earlier on it was not possible to restore identities after they were forgotten. Now logic is added to restore and reset the identity email, name and tokens in the identity aggregate so the identity is reset. #525
1 parent ad83840 commit ea45af0

11 files changed

Lines changed: 237 additions & 15 deletions

File tree

ci/qa/phpstan-baseline.neon

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,11 +1220,6 @@ parameters:
12201220
count: 1
12211221
path: ../../src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php
12221222

1223-
-
1224-
message: "#^Property Surfnet\\\\Stepup\\\\Identity\\\\Event\\\\SafeStoreSecretRecoveryTokenPossessionPromisedEvent\\:\\:\\$secret \\(Surfnet\\\\Stepup\\\\Identity\\\\Value\\\\RecoveryTokenIdentifier\\) does not accept Surfnet\\\\Stepup\\\\Identity\\\\Value\\\\RecoveryTokenIdentifier\\|null\\.$#"
1225-
count: 1
1226-
path: ../../src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php
1227-
12281223
-
12291224
message: "#^Method Surfnet\\\\Stepup\\\\Identity\\\\Event\\\\SecondFactorMigratedEvent\\:\\:deserialize\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#"
12301225
count: 1
@@ -1412,7 +1407,7 @@ parameters:
14121407

14131408
-
14141409
message: "#^Cannot call method count\\(\\) on Surfnet\\\\Stepup\\\\Identity\\\\Entity\\\\SecondFactorCollection\\|null\\.$#"
1415-
count: 2
1410+
count: 5
14161411
path: ../../src/Surfnet/Stepup/Identity/Identity.php
14171412

14181413
-
@@ -1527,7 +1522,7 @@ parameters:
15271522

15281523
-
15291524
message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, Surfnet\\\\Stepup\\\\Identity\\\\Entity\\\\SecondFactorCollection\\|null given\\.$#"
1530-
count: 4
1525+
count: 1
15311526
path: ../../src/Surfnet/Stepup/Identity/Identity.php
15321527

15331528
-
@@ -2572,12 +2567,12 @@ parameters:
25722567

25732568
-
25742569
message: "#^Cannot access property \\$commonName on Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity\\|null\\.$#"
2575-
count: 1
2570+
count: 2
25762571
path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php
25772572

25782573
-
25792574
message: "#^Cannot access property \\$email on Surfnet\\\\StepupMiddleware\\\\ApiBundle\\\\Identity\\\\Entity\\\\Identity\\|null\\.$#"
2580-
count: 1
2575+
count: 2
25812576
path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php
25822577

25832578
-
@@ -2587,7 +2582,7 @@ parameters:
25872582

25882583
-
25892584
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\\.$#"
2590-
count: 3
2585+
count: 4
25912586
path: ../../src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php
25922587

25932588
-

src/Surfnet/Stepup/Identity/Api/Identity.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,11 @@ public function expressPreferredLocale(Locale $preferredLocale): void;
232232
*/
233233
public function forget(): void;
234234

235+
public function restore(
236+
CommonName $commonName,
237+
Email $email,
238+
): void;
239+
235240
/**
236241
* @return IdentityId
237242
*/
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2025 SURFnet bv
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace Surfnet\Stepup\Identity\Event;
20+
21+
use Surfnet\Stepup\Identity\AuditLog\Metadata;
22+
use Surfnet\Stepup\Identity\Value\CommonName;
23+
use Surfnet\Stepup\Identity\Value\Email;
24+
use Surfnet\Stepup\Identity\Value\IdentityId;
25+
use Surfnet\Stepup\Identity\Value\Institution;
26+
use Surfnet\StepupMiddleware\CommandHandlingBundle\SensitiveData\Forgettable;
27+
use Surfnet\StepupMiddleware\CommandHandlingBundle\SensitiveData\RightToObtainDataInterface;
28+
use Surfnet\StepupMiddleware\CommandHandlingBundle\SensitiveData\SensitiveData;
29+
30+
class IdentityRestoredEvent extends IdentityEvent implements Forgettable, RightToObtainDataInterface
31+
{
32+
33+
/**
34+
* @var string[]
35+
*/
36+
private array $allowlist = [
37+
'id',
38+
'common_name',
39+
'email',
40+
'institution',
41+
];
42+
43+
public function __construct(
44+
private readonly IdentityId $id,
45+
private readonly Institution $institution,
46+
public CommonName $commonName,
47+
public Email $email,
48+
) {
49+
parent::__construct($id, $institution);
50+
}
51+
52+
public function getAuditLogMetadata(): Metadata
53+
{
54+
$metadata = new Metadata();
55+
$metadata->identityId = $this->id;
56+
$metadata->identityInstitution = $this->institution;
57+
58+
return $metadata;
59+
}
60+
61+
/**
62+
* @param array<string,string> $data
63+
*/
64+
public static function deserialize(array $data): self
65+
{
66+
return new self(
67+
new IdentityId($data['id']),
68+
new Institution($data['institution']),
69+
new CommonName($data['common_name']),
70+
new Email($data['email']),
71+
);
72+
}
73+
74+
/**
75+
* @return array<string,string>
76+
*/
77+
public function serialize(): array
78+
{
79+
return [
80+
'id' => (string)$this->id,
81+
'institution' => (string)$this->institution,
82+
'common_name' => (string)$this->commonName,
83+
'email' => (string)$this->email,
84+
];
85+
}
86+
87+
public function getSensitiveData(): SensitiveData
88+
{
89+
return (new SensitiveData)
90+
->withCommonName($this->commonName)
91+
->withEmail($this->email);
92+
}
93+
94+
public function setSensitiveData(SensitiveData $sensitiveData): void
95+
{
96+
$this->commonName = $sensitiveData->getCommonName();
97+
$this->email = $sensitiveData->getEmail();
98+
}
99+
100+
/**
101+
* @return array<string,string>
102+
*/
103+
public function obtainUserData(): array
104+
{
105+
$serializedPublicUserData = $this->serialize();
106+
$serializedSensitiveUserData = $this->getSensitiveData()->serialize();
107+
return array_merge($serializedPublicUserData, $serializedSensitiveUserData);
108+
}
109+
110+
/**
111+
* @return string[]
112+
*/
113+
public function getAllowlist(): array
114+
{
115+
return $this->allowlist;
116+
}
117+
}

src/Surfnet/Stepup/Identity/Event/SafeStoreSecretRecoveryTokenPossessionPromisedEvent.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,14 @@ public function getSensitiveData(): SensitiveData
116116

117117
public function setSensitiveData(SensitiveData $sensitiveData): void
118118
{
119+
$secret = $sensitiveData->getRecoveryTokenIdentifier();
120+
if ($secret === null) {
121+
$secret = SafeStore::unknown();
122+
}
123+
119124
$this->email = $sensitiveData->getEmail();
120125
$this->commonName = $sensitiveData->getCommonName();
121-
$this->secret = $sensitiveData->getRecoveryTokenIdentifier();
126+
$this->secret = $secret;
122127
}
123128

124129
public function obtainUserData(): array

src/Surfnet/Stepup/Identity/Identity.php

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent;
5353
use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent;
5454
use Surfnet\Stepup\Identity\Event\IdentityForgottenEvent;
55+
use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent;
5556
use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent;
5657
use Surfnet\Stepup\Identity\Event\LocalePreferenceExpressedEvent;
5758
use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent;
@@ -1010,6 +1011,17 @@ public function forget(): void
10101011
$this->apply(new IdentityForgottenEvent($this->id, $this->institution));
10111012
}
10121013

1014+
public function restore(
1015+
CommonName $commonName,
1016+
Email $email,
1017+
): void {
1018+
if (!$this->forgotten) {
1019+
return;
1020+
}
1021+
1022+
$this->apply(new IdentityRestoredEvent($this->id, $this->institution, $commonName, $email));
1023+
}
1024+
10131025
public function allVettedSecondFactorsRemoved(): void
10141026
{
10151027
$this->apply(
@@ -1037,6 +1049,19 @@ protected function applyIdentityCreatedEvent(IdentityCreatedEvent $event): void
10371049
$this->recoveryTokens = new RecoveryTokenCollection();
10381050
}
10391051

1052+
protected function applyIdentityRestoredEvent(IdentityRestoredEvent $event): void
1053+
{
1054+
$this->unverifiedSecondFactors = new SecondFactorCollection();
1055+
$this->verifiedSecondFactors = new SecondFactorCollection();
1056+
$this->vettedSecondFactors = new SecondFactorCollection();
1057+
$this->registrationAuthorities = new RegistrationAuthorityCollection();
1058+
$this->recoveryTokens = new RecoveryTokenCollection();
1059+
1060+
$this->commonName = $event->commonName;
1061+
$this->email = $event->email;
1062+
$this->forgotten = false;
1063+
}
1064+
10401065
public function applyIdentityRenamedEvent(IdentityRenamedEvent $event): void
10411066
{
10421067
$this->commonName = $event->commonName;
@@ -1452,9 +1477,9 @@ private function assertNotForgotten(): void
14521477
*/
14531478
private function assertUserMayAddSecondFactor(int $maxNumberOfTokens): void
14541479
{
1455-
if (count($this->unverifiedSecondFactors) +
1456-
count($this->verifiedSecondFactors) +
1457-
count($this->vettedSecondFactors) >= $maxNumberOfTokens
1480+
if ($this->unverifiedSecondFactors->count() +
1481+
$this->verifiedSecondFactors->count() +
1482+
$this->vettedSecondFactors->count() >= $maxNumberOfTokens
14581483
) {
14591484
throw new DomainException(
14601485
sprintf('User may not have more than %d token(s)', $maxNumberOfTokens),

src/Surfnet/Stepup/Identity/Value/SafeStore.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@
1818

1919
namespace Surfnet\Stepup\Identity\Value;
2020

21+
use Surfnet\Stepup\Exception\DomainException;
22+
use Surfnet\Stepup\Exception\RuntimeException;
23+
2124
/**
2225
* Recovery token identifier for the SafeStore token type
2326
*/
2427
class SafeStore implements RecoveryTokenIdentifier
2528
{
29+
private ?Secret $secret = null;
30+
2631
public function __construct(
27-
private readonly Secret $secret,
32+
Secret $secret,
2833
) {
34+
$this->secret = $secret;
2935
}
3036

3137
public static function unknown(): self
@@ -40,6 +46,9 @@ public static function hidden(): self
4046

4147
public function getValue(): string
4248
{
49+
if ($this->secret === null) {
50+
throw new RuntimeException("Secret should be set");
51+
}
4352
return $this->secret->getSecret();
4453
}
4554

src/Surfnet/Stepup/Tests/Identity/Event/EventSerializationAndDeserializationTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent;
4141
use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent;
4242
use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent;
43+
use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent;
4344
use Surfnet\Stepup\Identity\Event\LocalePreferenceExpressedEvent;
4445
use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent;
4546
use Surfnet\Stepup\Identity\Event\RegistrationAuthorityInformationAmendedEvent;
@@ -293,6 +294,14 @@ public function eventProvider(): array
293294
new Locale('en_GB'),
294295
),
295296
],
297+
'IdentityRestoredEvent' => [
298+
new IdentityRestoredEvent(
299+
new IdentityId($this->UUID()),
300+
new Institution('BabelFish Inc'),
301+
new CommonName('Henk Westbroek'),
302+
new Email('info@example.invalid'),
303+
),
304+
],
296305
'IdentityEmailChangedEvent' => [
297306
new IdentityEmailChangedEvent(
298307
new IdentityId($this->UUID()),

src/Surfnet/Stepup/Tests/Identity/Event/ForgettableEventsTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent;
3333
use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent;
3434
use Surfnet\Stepup\Identity\Event\IdentityRenamedEvent;
35+
use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent;
3536
use Surfnet\Stepup\Identity\Event\PhonePossessionProvenAndVerifiedEvent;
3637
use Surfnet\Stepup\Identity\Event\PhonePossessionProvenEvent;
3738
use Surfnet\Stepup\Identity\Event\PhoneRecoveryTokenPossessionProvenEvent;
@@ -93,6 +94,7 @@ public function certain_events_are_forgettable_events_and_others_are_not(): void
9394
YubikeyPossessionProvenAndVerifiedEvent::class,
9495
YubikeySecondFactorBootstrappedEvent::class,
9596
RegistrationAuthorityRetractedForInstitutionEvent::class,
97+
IdentityRestoredEvent::class,
9698
];
9799
$otherIdentityEventFqcns = array_diff($this->getConcreteIdentityEventFqcns(), $forgettableEventFqcns);
98100
$forgettableFqcn = Forgettable::class;

src/Surfnet/StepupMiddleware/ApiBundle/Identity/Projector/IdentityProjector.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
namespace Surfnet\StepupMiddleware\ApiBundle\Identity\Projector;
2020

21+
use Surfnet\Stepup\Identity\Event\IdentityRestoredEvent;
2122
use Surfnet\Stepup\Projector\Projector;
2223
use Surfnet\Stepup\Identity\Event\IdentityCreatedEvent;
2324
use Surfnet\Stepup\Identity\Event\IdentityEmailChangedEvent;
@@ -75,6 +76,16 @@ public function applyLocalePreferenceExpressedEvent(LocalePreferenceExpressedEve
7576
$this->identityRepository->save($identity);
7677
}
7778

79+
public function applyIdentityRestoredEvent(IdentityRestoredEvent $event): void
80+
{
81+
$identity = $this->identityRepository->find((string)$event->identityId);
82+
$identity->email = $event->email;
83+
$identity->commonName = $event->commonName;
84+
85+
$this->identityRepository->save($identity);
86+
}
87+
88+
7889
public function applySecondFactorVettedEvent(SecondFactorVettedEvent $event): void
7990
{
8091
$this->determinePossessionOfSelfAssertedToken($event->vettingType, (string)$event->identityId);

src/Surfnet/StepupMiddleware/CommandHandlingBundle/Identity/CommandHandler/IdentityCommandHandler.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ public function handleUpdateIdentityCommand(UpdateIdentityCommand $command): voi
122122
/** @var IdentityApi $identity */
123123
$identity = $this->eventSourcedRepository->load(new IdentityId($command->id));
124124

125+
$identity->restore(new CommonName($command->commonName), new Email($command->email));
125126
$identity->rename(new CommonName($command->commonName));
126127
$identity->changeEmail(new Email($command->email));
127128

0 commit comments

Comments
 (0)