Skip to content

Commit 18e5fa7

Browse files
MKoddejohanib
authored andcommitted
Implement stable consent hash with version tracking
The old consent hash was sensitive to attribute ordering, case changes, stray empty attributes, and sparse array indexes — causing spurious re-consent prompts. This commit introduces a stable hashing algorithm alongside the existing one, with a migration path that avoids invalidating all existing consent records at once. Key changes: - Add `attribute_stable` VARCHAR(80) nullable column to `consent` table; make existing `attribute` column nullable for future cleanup - Add ConsentVersion value object with three states: stable, unstable, not-given (replaces boolean return from consent-check methods) - Retrieve old-style and new-style attribute hash in a single query instead of two sequential lookups - When existing consent matches only the unstable hash, silently upgrade by writing the stable hash (no re-consent prompt shown) - Newly given consent is stored with the stable hash - Normalize NameID objects before hashing to prevent serialization errors - Only fetch stored consent when consent is enabled (short-circuit optimization) The migration strategy (from the Pivotal story): - Existing consent hashing is left untouched - Consent lookup checks both old and new hash: match on either means consent was given - New consent is stored with the new stable hash - Old consent that matches unstable hash is upgraded in-place Pivotal ticket: https://www.pivotaltracker.com/story/show/176513931
1 parent 7ccf448 commit 18e5fa7

24 files changed

Lines changed: 792 additions & 121 deletions

File tree

config/services/controllers/api.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ services:
1616
- '@security.token_storage'
1717
- '@security.access.decision_manager'
1818
- '@OpenConext\EngineBlockBundle\Configuration\FeatureConfiguration'
19-
- '@OpenConext\EngineBlock\Service\ConsentService'
19+
- '@OpenConext\EngineBlock\Service\Consent\ConsentService'
2020

2121
OpenConext\EngineBlockBundle\Controller\Api\DeprovisionController:
2222
arguments:

config/services/services.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ services:
7777
engineblock.service.consent.ConsentHashService:
7878
class: OpenConext\EngineBlock\Service\Consent\ConsentHashService
7979
public: false
80+
arguments:
81+
- '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository'
8082

81-
OpenConext\EngineBlock\Service\ConsentService:
83+
OpenConext\EngineBlock\Service\Consent\ConsentService:
8284
arguments:
8385
- '@OpenConext\EngineBlockBundle\Authentication\Repository\DbalConsentRepository'
8486
- '@OpenConext\EngineBlock\Service\MetadataService'

library/EngineBlock/Application/DiContainer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ public function getAuthenticationLoopGuard()
165165
*/
166166
public function getConsentService()
167167
{
168-
return $this->container->get(\OpenConext\EngineBlock\Service\ConsentService::class);
168+
return $this->container->get(\OpenConext\EngineBlock\Service\Consent\ConsentService::class);
169169
}
170170

171171
/**

library/EngineBlock/Corto/Model/Consent.php

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
* limitations under the License.
1717
*/
1818

19-
use Doctrine\DBAL\Statement;
19+
use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery;
20+
use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters;
21+
use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters;
22+
use OpenConext\EngineBlock\Authentication\Value\ConsentVersion;
2023
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
2124
use OpenConext\EngineBlock\Authentication\Value\ConsentType;
2225
use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface;
@@ -84,14 +87,36 @@ public function __construct(
8487

8588
public function explicitConsentWasGivenFor(ServiceProvider $serviceProvider): bool
8689
{
87-
return !$this->_consentEnabled ||
88-
$this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT);
90+
if (!$this->_consentEnabled) {
91+
return true;
92+
}
93+
$consent = $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_EXPLICIT);
94+
return $consent->given();
95+
}
96+
97+
/**
98+
* Although the user has given consent previously we want to upgrade the deprecated unstable consent
99+
* to the new stable consent type.
100+
* https://www.pivotaltracker.com/story/show/176513931
101+
*/
102+
public function upgradeAttributeHashFor(ServiceProvider $serviceProvider, string $consentType): void
103+
{
104+
if (!$this->_consentEnabled) {
105+
return;
106+
}
107+
$consentVersion = $this->_hasStoredConsent($serviceProvider, $consentType);
108+
if ($consentVersion->isUnstable()) {
109+
$this->_updateConsent($serviceProvider, $consentType);
110+
}
89111
}
90112

91113
public function implicitConsentWasGivenFor(ServiceProvider $serviceProvider): bool
92114
{
93-
return !$this->_consentEnabled ||
94-
$this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT);
115+
if (!$this->_consentEnabled) {
116+
return true;
117+
}
118+
$consent = $this->_hasStoredConsent($serviceProvider, ConsentType::TYPE_IMPLICIT);
119+
return $consent->given();
95120
}
96121

97122
public function giveExplicitConsentFor(ServiceProvider $serviceProvider): bool
@@ -138,38 +163,48 @@ private function _storeConsent(ServiceProvider $serviceProvider, $consentType):
138163
return false;
139164
}
140165

141-
$parameters = array(
142-
sha1($consentUuid),
143-
$serviceProvider->entityId,
144-
$this->_getStableAttributesHash($this->_responseAttributes),
145-
$consentType,
166+
$parameters = new ConsentStoreParameters(
167+
hashedUserId: sha1($consentUuid),
168+
serviceId: $serviceProvider->entityId,
169+
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
170+
consentType: $consentType,
146171
);
147172

148173
return $this->_hashService->storeConsentHash($parameters);
149174
}
150175

151-
private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType): bool
176+
private function _updateConsent(ServiceProvider $serviceProvider, $consentType): bool
152177
{
153-
$parameters = array(
154-
sha1($this->_getConsentUid()),
155-
$serviceProvider->entityId,
156-
$this->_getAttributesHash($this->_responseAttributes),
157-
$consentType,
178+
$consentUid = $this->_getConsentUid();
179+
if (!is_string($consentUid)) {
180+
return false;
181+
}
182+
183+
$parameters = new ConsentUpdateParameters(
184+
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
185+
attributeHash: $this->_getAttributesHash($this->_responseAttributes),
186+
hashedUserId: sha1($consentUid),
187+
serviceId: $serviceProvider->entityId,
188+
consentType: $consentType,
158189
);
159190

160-
$hasUnstableConsentHash = $this->_hashService->retrieveConsentHash($parameters);
191+
return $this->_hashService->updateConsentHash($parameters);
192+
}
161193

162-
if ($hasUnstableConsentHash) {
163-
return true;
194+
private function _hasStoredConsent(ServiceProvider $serviceProvider, $consentType): ConsentVersion
195+
{
196+
$consentUid = $this->_getConsentUid();
197+
if (!is_string($consentUid)) {
198+
return ConsentVersion::notGiven();
164199
}
165200

166-
$parameters[2] = array(
167-
sha1($this->_getConsentUid()),
168-
$serviceProvider->entityId,
169-
$this->_getStableAttributesHash($this->_responseAttributes),
170-
$consentType,
201+
$query = new ConsentHashQuery(
202+
hashedUserId: sha1($consentUid),
203+
serviceId: $serviceProvider->entityId,
204+
attributeHash: $this->_getAttributesHash($this->_responseAttributes),
205+
attributeStableHash: $this->_getStableAttributesHash($this->_responseAttributes),
206+
consentType: $consentType,
171207
);
172-
173-
return $this->_hashService->retrieveConsentHash($parameters);
208+
return $this->_hashService->retrieveConsentHash($query);
174209
}
175210
}

library/EngineBlock/Corto/Model/Consent/Factory.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* limitations under the License.
1717
*/
1818

19-
use OpenConext\EngineBlock\Service\Consent\ConsentHashService;
19+
use OpenConext\EngineBlock\Service\Consent\ConsentHashServiceInterface;
2020

2121
/**
2222
* @todo write a test
@@ -27,7 +27,7 @@ class EngineBlock_Corto_Model_Consent_Factory
2727
private $_filterCommandFactory;
2828

2929
/**
30-
* @var ConsentHashService
30+
* @var ConsentHashServiceInterface
3131
*/
3232
private $_hashService;
3333

@@ -36,7 +36,7 @@ class EngineBlock_Corto_Model_Consent_Factory
3636
*/
3737
public function __construct(
3838
EngineBlock_Corto_Filter_Command_Factory $filterCommandFactory,
39-
ConsentHashService $hashService
39+
ConsentHashServiceInterface $hashService
4040
) {
4141
$this->_filterCommandFactory = $filterCommandFactory;
4242
$this->_hashService = $hashService;

library/EngineBlock/Corto/Module/Service/ProcessConsent.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* limitations under the License.
1717
*/
1818

19+
use OpenConext\EngineBlock\Authentication\Value\ConsentType;
1920
use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface;
2021
use OpenConext\EngineBlock\Service\ProcessingStateHelperInterface;
2122
use SAML2\Constants;
@@ -101,6 +102,8 @@ public function serve($serviceName, Request $httpRequest)
101102

102103
if (!$consentRepository->explicitConsentWasGivenFor($serviceProvider)) {
103104
$consentRepository->giveExplicitConsentFor($destinationMetadata);
105+
} else {
106+
$consentRepository->upgradeAttributeHashFor($destinationMetadata, ConsentType::TYPE_EXPLICIT);
104107
}
105108

106109
$response->setConsent(Constants::CONSENT_OBTAINED);

library/EngineBlock/Corto/Module/Service/ProvideConsent.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* limitations under the License.
1717
*/
1818

19+
use OpenConext\EngineBlock\Authentication\Value\ConsentType;
1920
use OpenConext\EngineBlock\Metadata\Entity\IdentityProvider;
2021
use OpenConext\EngineBlock\Metadata\Entity\ServiceProvider;
2122
use OpenConext\EngineBlock\Service\AuthenticationStateHelperInterface;
@@ -145,6 +146,8 @@ public function serve($serviceName, Request $httpRequest)
145146
if ($this->isConsentDisabled($spMetadataChain, $identityProvider)) {
146147
if (!$consentRepository->implicitConsentWasGivenFor($serviceProviderMetadata)) {
147148
$consentRepository->giveImplicitConsentFor($serviceProviderMetadata);
149+
} else {
150+
$consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_IMPLICIT);
148151
}
149152

150153
$response->setConsent(Constants::CONSENT_INAPPLICABLE);
@@ -163,6 +166,8 @@ public function serve($serviceName, Request $httpRequest)
163166

164167
$priorConsent = $consentRepository->explicitConsentWasGivenFor($serviceProviderMetadata);
165168
if ($priorConsent) {
169+
$consentRepository->upgradeAttributeHashFor($serviceProviderMetadata, ConsentType::TYPE_EXPLICIT);
170+
166171
$response->setConsent(Constants::CONSENT_PRIOR);
167172

168173
$response->setDestination($response->getReturn());
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenConext\EngineBlock\Doctrine\Migrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
9+
/**
10+
* Change to the consent schema
11+
* 1. Added the `attribute_stable` column, string(80), nullable
12+
* 2. Changed the `attribute` column, has been made nullable
13+
*/
14+
final class Version20260315000001 extends AbstractEngineBlockMigration
15+
{
16+
public function getDescription(): string
17+
{
18+
return 'Add attribute_stable column to consent table and make attribute nullable';
19+
}
20+
21+
public function preUp(Schema $schema): void
22+
{
23+
parent::preUp($schema);
24+
}
25+
26+
public function up(Schema $schema): void
27+
{
28+
$this->addSql('ALTER TABLE consent ADD attribute_stable VARCHAR(80) DEFAULT NULL, CHANGE attribute attribute VARCHAR(80) DEFAULT NULL');
29+
}
30+
31+
public function down(Schema $schema): void
32+
{
33+
$this->addSql('ALTER TABLE consent DROP attribute_stable, CHANGE attribute attribute VARCHAR(80) CHARACTER SET utf8 NOT NULL COLLATE `utf8_unicode_ci`');
34+
}
35+
}

src/OpenConext/EngineBlock/Authentication/Repository/ConsentRepository.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
namespace OpenConext\EngineBlock\Authentication\Repository;
2020

2121
use OpenConext\EngineBlock\Authentication\Model\Consent;
22+
use OpenConext\EngineBlock\Authentication\Value\ConsentHashQuery;
23+
use OpenConext\EngineBlock\Authentication\Value\ConsentStoreParameters;
24+
use OpenConext\EngineBlock\Authentication\Value\ConsentUpdateParameters;
25+
use OpenConext\EngineBlock\Authentication\Value\ConsentVersion;
2226

2327
interface ConsentRepository
2428
{
@@ -38,9 +42,21 @@ public function deleteAllFor($userId);
3842

3943
public function deleteOneFor(string $userId, string $serviceProviderEntityId): bool;
4044

41-
public function hasConsentHash(array $parameters): bool;
45+
/**
46+
* Test if the consent row is set with an attribute hash either stable or unstable
47+
*/
48+
public function hasConsentHash(ConsentHashQuery $query): ConsentVersion;
4249

43-
public function storeConsentHash(array $parameters): bool;
50+
/**
51+
* By default stores the stable consent hash. The legacy consent hash is left.
52+
*/
53+
public function storeConsentHash(ConsentStoreParameters $parameters): bool;
54+
55+
/**
56+
* When a deprecated unstable consent hash is encoutered, we upgrade it to the new format using this
57+
* update consent hash method.
58+
*/
59+
public function updateConsentHash(ConsentUpdateParameters $parameters): bool;
4460

4561
public function countTotalConsent($consentUid): int;
4662
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2010 SURFnet B.V.
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 OpenConext\EngineBlock\Authentication\Value;
20+
21+
final class ConsentHashQuery
22+
{
23+
public function __construct(
24+
public readonly string $hashedUserId,
25+
public readonly string $serviceId,
26+
public readonly string $attributeHash,
27+
public readonly string $attributeStableHash,
28+
public readonly string $consentType,
29+
) {
30+
}
31+
}

0 commit comments

Comments
 (0)