Skip to content
Snippets Groups Projects
Unverified Commit 3f36fe25 authored by Tim van Dijen's avatar Tim van Dijen Committed by GitHub
Browse files

Add authproc-filters to generate Subject-id & Pairwise-id (#1435)

* Add SubjectID authproc

* Add PairwiseID authproc

* Add tests
parent 07dbd3da
No related branches found
No related tags found
No related merge requests found
`core:PairwiseID`
===================
Filter to insert a pairwise-id that complies with the following specification;
http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf
This filter will take an attribute and a scope as input and transforms this into a anonymized and scoped
identifier that is globally unique for a given user & service provider combination.
Examples
--------
'authproc' => [
50 => [
'class' => 'core:PairwiseID',
'identifyingAttribute' => 'uid',
'scope' => 'example.org',
],
],
`core:SubjectID`
===================
Filter to insert a subject-id that complies with the following specification;
http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/saml-subject-id-attr-v1.0.pdf
This filter will take an attribute and a scope as input and transforms this into a scoped identifier that is globally unique for a given user.
Note:
-----
If privacy is of your concern, you may want to use the PairwiseID-filter instead.
Examples
--------
'authproc' => [
50 => [
'class' => 'core:SubjectID',
'identifyingAttribute' => 'uid',
'scope' => 'example.org',
],
],
<?php
declare(strict_types=1);
namespace SimpleSAML\Module\core\Auth\Process;
use Exception;
use SAML2\Constants;
use SAML2\XML\saml\NameID;
use SimpleSAML\Assert\Assert;
use SimpleSAML\Auth;
use SimpleSAML\Utils;
/**
* Filter to generate the Pairwise ID attribute.
*
* See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html
*
* By default, this filter will generate the ID based on the UserID of the current user.
* This is generated from the attribute configured in 'identifyingAttribute' in the
* authproc-configuration.
*
* Example - generate from attribute:
* <code>
* 'authproc' => [
* 50 => [
* 'core:PairwiseID',
* 'identifyingAttribute' => 'uid',
* 'scope' => 'example.org',
* ]
* ]
* </code>
*
* @package SimpleSAMLphp
*/
class PairwiseID extends SubjectID
{
/**
* The name for this class
*/
public const NAME = 'PairwiseID';
/**
* @var \SimpleSAML\Utils\Config
*/
protected $configUtils;
/**
* Initialize this filter.
*
* @param array &$config Configuration information about this filter.
* @param mixed $reserved For future use.
*/
public function __construct(array &$config, $reserved)
{
parent::__construct($config, $reserved);
$this->configUtils = new Utils\Config();
}
/**
* Apply filter to add the Pairwise ID.
*
* @param array &$state The current state.
*/
public function process(array &$state): void
{
$userID = $this->getIdentifyingAttribute($state);
if (!empty($state['saml:RequesterID'])) {
// Proxied request - use actual SP entity ID
$sp_entityid = $state['saml:RequesterID'][0];
} else {
$sp_entityid = $state['core:SP'];
}
// Calculate hash
$salt = $this->configUtils->getSecretSalt();
$hash = hash('sha256', $salt . '|' . $userID . '|' . $sp_entityid, false);
$value = strtolower($hash . '@' . $this->scope);
$this->validateGeneratedIdentifier($value);
$state['Attributes'][Constants::ATTR_PAIRWISE_ID] = [$value];
}
/**
* Inject the \SimpleSAML\Utils\Config dependency.
*
* @param \SimpleSAML\Utils\Config $configUtils
*/
public function setConfigUtils(Utils\Config $configUtils): void
{
$this->configUtils = $configUtils;
}
}
<?php
declare(strict_types=1);
namespace SimpleSAML\Module\core\Auth\Process;
use Exception;
use SAML2\Constants;
//use SAML2\Exception\ProtocolViolationException
use SAML2\XML\saml\NameID;
use SimpleSAML\Assert\Assert;
use SimpleSAML\Auth;
use SimpleSAML\Logger;
/**
* Filter to generate the subject ID attribute.
*
* See: http://docs.oasis-open.org/security/saml-subject-id-attr/v1.0/csprd01/saml-subject-id-attr-v1.0-csprd01.html
*
* By default, this filter will generate the ID based on the UserID of the current user.
* This is generated from the attribute configured in 'identifyingAttribute' in the
* authproc-configuration.
*
* Example - generate from attribute:
* <code>
* 'authproc' => [
* 50 => [
* 'core:SubjectID',
* 'identifyingAttribute' => 'uid',
* 'scope' => 'example.org',
* ]
* ]
* </code>
*
* @package SimpleSAMLphp
*/
class SubjectID extends Auth\ProcessingFilter
{
/**
* The name for this class
*/
public const NAME = 'SubjectID';
/**
* The regular expression to match the scope
*
* @var string
*/
public const SCOPE_PATTERN = '/^[a-z0-9][a-z0-9.-]{0,126}$/i';
/**
* The regular expression to match the specifications
*
* @var string
*/
public const SPEC_PATTERN = '/^[a-z0-9][a-z0-9=-]{0,126}@[a-z0-9][a-z0-9.-]{0,126}$/i';
/**
* The regular expression to match worrisome identifiers that need to raise a warning
*
* @var string
*/
public const WARN_PATTERN = '/^[a-z0-9][a-z0-9=-]{3,126}@[a-z0-9][a-z0-9.-]{3,126}$/i';
/**
* The attribute we should generate the subject id from.
*
* @var string
*/
protected string $identifyingAttribute;
/**
* The scope to use for this attribute.
*
* @var string
*/
protected string $scope;
/**
* @var \SimpleSAML\Logger|string
* @psalm-var \SimpleSAML\Logger|class-string
*/
protected $logger = Logger::class;
/**
* Initialize this filter.
*
* @param array &$config Configuration information about this filter.
* @param mixed $reserved For future use.
*/
public function __construct(array &$config, $reserved)
{
parent::__construct($config, $reserved);
Assert::keyExists($config, 'identifyingAttribute', "Missing mandatory 'identifyingAttribute' config setting.");
Assert::keyExists($config, 'scope', "Missing mandatory 'scope' config setting.");
Assert::stringNotEmpty($config['identifyingAttribute']);
Assert::regex(
$config['scope'],
self::SCOPE_PATTERN,
'core:' . static::NAME . ': \'scope\' contains illegal characters.'
// ProtocolViolationException::class
);
$this->identifyingAttribute = $config['identifyingAttribute'];
$this->scope = $config['scope'];
}
/**
* Apply filter to add the subject ID.
*
* @param array &$state The current state.
*/
public function process(array &$state): void
{
$userID = $this->getIdentifyingAttribute($state);
$value = strtolower($userID . '@' . $this->scope);
$this->validateGeneratedIdentifier($value);
$state['Attributes'][Constants::ATTR_SUBJECT_ID] = [$value];
}
/**
* Retrieve the identifying attribute from the state and test it for erroneous conditions
*
* @param array $state
* @return string
* @throws \SimpleSAML\Assert\AssertionFailedException if the pre-conditions are not met
*/
protected function getIdentifyingAttribute(array $state): string
{
Assert::keyExists($state, 'Attributes');
Assert::keyExists(
$state['Attributes'],
$this->identifyingAttribute,
sprintf(
"core:" . static::NAME . ": Missing attribute '%s', which is needed to generate the ID.",
$this->identifyingAttribute
)
);
$userID = $state['Attributes'][$this->identifyingAttribute][0];
Assert::stringNotEmpty($userID, 'core' . static::NAME . ': \'identifyingAttribute\' cannot be an empty string.');
return $userID;
}
/**
* Test the generated identifier to ensure compliancy with the specifications.
* Log a warning when the generated value is considered to be weak
*
* @param string $value
* @return void
* @throws \SimpleSAML\Assert\AssertionFailedException if the post-conditions are not met
*/
protected function validateGeneratedIdentifier(string $value): void
{
Assert::regex(
$value,
self::SPEC_PATTERN,
'core:' . static::NAME . ': Generated ID \'' . $value . '\' contains illegal characters.'
// ProtocolViolationException::class
);
if (preg_match(self::WARN_PATTERN, $value) === 0) {
$this->logger::warning('core:' . static::NAME . ': Generated ID \'' . $value . '\' can hardly be considered globally unique.');
}
}
/**
* Inject the \SimpleSAML\Logger dependency.
*
* @param \SimpleSAML\Logger $logger
*/
public function setLogger(Logger $logger): void
{
$this->logger = $logger;
}
}
<?php
declare(strict_types=1);
namespace SimpleSAML\Test\Module\core\Auth\Process;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use SAML2\Constants;
use SAML2\XML\saml\NameID;
use SimpleSAML\Assert\AssertionFailedException;
use SimpleSAML\Configuration;
use SimpleSAML\Logger;
use SimpleSAML\Module\core\Auth\Process\PairwiseID;
use SimpleSAML\Utils;
/**
* Test for the core:PairwiseID filter.
*
* @covers \SimpleSAML\Module\core\Auth\Process\PairwiseID
*/
class PairwiseIDTest extends TestCase
{
/** @var \SimpleSAML\Configuration */
protected Configuration $config;
/** @var \SimpleSAML\Utils\Config */
protected static Utils\Config $configUtils;
/** @var \SimpleSAML\Logger */
protected static Logger $logger;
/**
* Set up for each test.
*/
protected function setUp(): void
{
parent::setUp();
self::$configUtils = new class () extends Utils\Config {
public function getSecretSalt(): string
{
// stub
return 'secretsalt';
}
};
self::$logger = new class () extends Logger {
public static function warning(string $string): void
{
// stub
throw new RuntimeException($string);
}
};
}
/**
* Helper function to run the filter with a given configuration.
*
* @param array $config The filter configuration.
* @param array $request The request state.
* @return array The state array after processing.
*/
private static function processFilter(array $config, array $request): array
{
$filter = new PairwiseID($config, null);
$filter->setConfigUtils(self::$configUtils);
$filter->setLogger(self::$logger);
$filter->process($request);
return $request;
}
/**
* Test the most basic functionality
*/
public function testBasic(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex-ample.org'];
$request = [
'Attributes' => ['uid' => ['u=se-r2']],
'core:SP' => 'urn:sp',
];
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$this->assertMatchesRegularExpression(
PairwiseID::SPEC_PATTERN,
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
$this->assertEquals(
'53d4f7fe57fb597ada481e81e0f15048bc610774cbb5614ea38f08ea918ba199@ex-ample.org',
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
}
/**
* Test the most basic functionality on proxied request
*/
public function testBasicProxiedRequest(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex-ample.org'];
$request = [
'Attributes' => ['uid' => ['u=se-r2']],
'saml:RequesterID' => [0 => 'urn:sp'],
];
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$this->assertMatchesRegularExpression(
PairwiseID::SPEC_PATTERN,
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
$this->assertEquals(
'53d4f7fe57fb597ada481e81e0f15048bc610774cbb5614ea38f08ea918ba199@ex-ample.org',
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
}
/**
* Test the proxied request with multiple hops
*/
public function testProxiedRequestMultipleHops(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex-ample.org'];
$request = [
'Attributes' => ['uid' => ['u=se-r2']],
'saml:RequesterID' => [0 => 'urn:sp', 1 => 'urn:some:sp', 2 => 'urn:some:other:sp'],
];
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$this->assertMatchesRegularExpression(
PairwiseID::SPEC_PATTERN,
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
$this->assertEquals(
'53d4f7fe57fb597ada481e81e0f15048bc610774cbb5614ea38f08ea918ba199@ex-ample.org',
$attributes[Constants::ATTR_PAIRWISE_ID][0]
);
}
/**
* Test that illegal characters in scope throws an exception.
*/
public function testScopeIllegalCharacterThrowsException(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex%ample.org'];
$request = [
'Attributes' => ['uid' => ['user2']],
'core:SP' => 'urn:sp',
];
$this->expectException(AssertionFailedException::class);
self::processFilter($config, $request);
}
/**
* Test that generated ID's for the same user, but different SP's are NOT equal
*/
public function testUniqueIdentifierPerSPSameUser(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
'core:SP' => 'urn:sp',
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
// Switch SP
$request['core:SP'] = 'urn:some:other:sp';
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
$this->assertNotSame($value1, $value2);
}
/**
* Test that generated ID's for different users, but the same SP's are NOT equal
*/
public function testUniqueIdentifierPerUserSameSP(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
'core:SP' => 'urn:sp',
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
// Switch user
$request['Attributes'] = ['uid' => ['user2']];
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
$this->assertNotSame($value1, $value2);
}
/**
* Test that generated ID's for the same user and same SP, but with a different salt are NOT equal
*/
public function testUniqueIdentifierDifferentSalts(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
'core:SP' => 'urn:sp',
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
// Change the salt
self::$configUtils = new class () extends Utils\Config {
public function getSecretSalt(): string
{
// stub
return 'pepper';
}
};
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
$this->assertNotSame($value1, $value2);
}
/**
* Test that generated ID's for the same user and same SP, but with a different scope are NOT equal
*/
public function testUniqueIdentifierDifferentScopes(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
'core:SP' => 'urn:sp',
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value1 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
// Change the scope
$config['scope'] = 'example.edu';
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_PAIRWISE_ID, $attributes);
$value2 = $attributes[Constants::ATTR_PAIRWISE_ID][0];
$this->assertNotSame($value1, $value2);
$this->assertMatchesRegularExpression(
'/@example.org$/i',
$value1
);
$this->assertMatchesRegularExpression(
'/@example.edu$/i',
$value2
);
}
/**
* Test that weak identifiers log a warning
*/
public function testWeakIdentifierLogsWarning(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'b'];
$request = [
'Attributes' => ['uid' => ['a']],
'core:SP' => 'urn:sp',
];
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage(
'core:PairwiseID: Generated ID \'c5b54935db5e291a6b94688921fa77ced8ce425ce8c61a448bd4997f494dbebe@b\' can hardly be considered globally unique.'
);
self::processFilter($config, $request);
}
}
<?php
declare(strict_types=1);
namespace SimpleSAML\Test\Module\core\Auth\Process;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use SAML2\Constants;
use SAML2\XML\saml\NameID;
use SimpleSAML\Assert\AssertionFailedException;
use SimpleSAML\Configuration;
use SimpleSAML\Logger;
use SimpleSAML\Module\core\Auth\Process\SubjectID;
use SimpleSAML\Utils;
/**
* Test for the core:SubjectID filter.
*
* @covers \SimpleSAML\Module\core\Auth\Process\SubjectID
*/
class SubjectIDTest extends TestCase
{
/** @var \SimpleSAML\Configuration */
protected Configuration $config;
/** @var \SimpleSAML\Logger */
protected static Logger $logger;
/**
* Set up for each test.
*/
protected function setUp(): void
{
parent::setUp();
self::$logger = new class () extends Logger {
public static function warning(string $string): void
{
// stub
throw new RuntimeException($string);
}
};
}
/**
* Helper function to run the filter with a given configuration.
*
* @param array $config The filter configuration.
* @param array $request The request state.
* @return array The state array after processing.
*/
private static function processFilter(array $config, array $request): array
{
$filter = new SubjectID($config, null);
$filter->setLogger(self::$logger);
$filter->process($request);
return $request;
}
/**
* Test the most basic functionality
*/
public function testBasic(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex-ample.org'];
$request = [
'Attributes' => ['uid' => ['u=se-r2']],
];
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
$this->assertMatchesRegularExpression(
SubjectID::SPEC_PATTERN,
$attributes[Constants::ATTR_SUBJECT_ID][0]
);
$this->assertEquals('u=se-r2@ex-ample.org', $attributes[Constants::ATTR_SUBJECT_ID][0]);
}
/**
* Test that illegal characters in userID throws an exception.
*/
public function testUserIDIllegalCharacterThrowsException(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['u=se+r2']],
];
$this->expectException(AssertionFailedException::class);
self::processFilter($config, $request);
}
/**
* Test that illegal characters in scope throws an exception.
*/
public function testScopeIllegalCharacterThrowsException(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'ex%ample.org'];
$request = [
'Attributes' => ['uid' => ['user2']],
];
$this->expectException(AssertionFailedException::class);
self::processFilter($config, $request);
}
/**
* Test that generated ID's for different users, but the same SP's are NOT equal
*/
public function testUniqueIdentifierPerUserSameSP(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
$value1 = $attributes[Constants::ATTR_SUBJECT_ID][0];
// Switch user
$request['Attributes'] = ['uid' => ['user2']];
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
$value2 = $attributes[Constants::ATTR_SUBJECT_ID][0];
$this->assertNotSame($value1, $value2);
}
/**
* Test that generated ID's for the same user and same SP, but with a different scope are NOT equal
*/
public function testUniqueIdentifierDifferentScopes(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'example.org'];
$request = [
'Attributes' => ['uid' => ['user1']],
];
// Generate first ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
$value1 = $attributes[Constants::ATTR_SUBJECT_ID][0];
// Change the scope
$config['scope'] = 'example.edu';
// Generate second ID
$result = self::processFilter($config, $request);
$attributes = $result['Attributes'];
$this->assertArrayHasKey(Constants::ATTR_SUBJECT_ID, $attributes);
$value2 = $attributes[Constants::ATTR_SUBJECT_ID][0];
$this->assertNotSame($value1, $value2);
$this->assertMatchesRegularExpression(
'/@example.org$/i',
$value1
);
$this->assertMatchesRegularExpression(
'/@example.edu$/i',
$value2
);
}
/**
* Test that weak identifiers log a warning
*/
public function testWeakIdentifierLogsWarning(): void
{
$config = ['identifyingAttribute' => 'uid', 'scope' => 'b'];
$request = [
'Attributes' => ['uid' => ['a']],
];
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('core:SubjectID: Generated ID \'a@b\' can hardly be considered globally unique.');
self::processFilter($config, $request);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment