diff --git a/modules/core/docs/authsource_selector.md b/modules/core/docs/authsource_selector.md new file mode 100644 index 0000000000000000000000000000000000000000..b4afa124ae6e5093e8f5de059ca438be72ef3735 --- /dev/null +++ b/modules/core/docs/authsource_selector.md @@ -0,0 +1,56 @@ +# Authentication source selector + +The Authentication source selector is a special kind of Authentication Source +that delegates the actual authentication to a secondary Authentication Source +based on some form of policy decision. + +## AbstractSourceSelector + +The AbstractSourceSelector extends from `\SimpleSAML\Auth\Source` and as such +act as an Authentication Source. Any derivative classes must implement the +abstract `selectAuthSource` method. This method must return the name of the +Authentication Source to use, based on whatever logic is necessary. + +## IPSourceSelector + +The IPSourceSelector is an implementation of the `AbstractSourceSelector` and +uses the client IP to decide what Authentication Source is called. +It works by defining zones with corresponding IP-ranges and Authentication +Sources. The 'default' zone is required and acts as a fallback when none +of the zones match a client's IP-address. + +An example configuration would look like this: + +```php + 'selector' => [ + 'core:IPSourceSelector', + + 'zones' => [ + 'internal' => [ + 'source' => 'ldap', + 'subnet' => [ + '10.0.0.0/8', + '2001:0DB8::/108', + ], + ], + + 'other' => [ + 'source' => 'radius', + 'subnet' => [ + '172.16.0.0/12', + '2002:1234::/108', + ], + ], + + 'default' => 'yubikey', + ], + ], +``` + +## YourCustomSourceSelector + +If you have a use-case for a custom Authentication source selector, all you +have to do is to create your own class, make it extend `AbstractSourceSelector` +and make it implement the abstract `selectAuthSource` method containing +your own logic. The method should return the name of the Authentication +source to use. diff --git a/modules/core/lib/Auth/Source/AbstractSourceSelector.php b/modules/core/lib/Auth/Source/AbstractSourceSelector.php new file mode 100644 index 0000000000000000000000000000000000000000..48a061be8ab52772059eccb13c8ba6d3f682e13c --- /dev/null +++ b/modules/core/lib/Auth/Source/AbstractSourceSelector.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\core\Auth\Source; + +use Exception; +use SimpleSAML\Assert\Assert; +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Session; + +/** + * Authentication source which delegates authentication to secondary + * authentication sources based on policy decision + * + * @package SimpleSAMLphp + */ +abstract class AbstractSourceSelector extends Auth\Source +{ + /** + * @var array The names of all the configured auth sources + */ + protected array $validSources; + + + /** + * Constructor for this authentication source. + * + * @param array $info Information about this authentication source. + * @param array $config Configuration. + */ + public function __construct(array $info, array $config) + { + // Call the parent constructor first, as required by the interface + parent::__construct($info, $config); + + $authsources = Configuration::getConfig('authsources.php'); + $this->validSources = array_keys($authsources->toArray()); + } + + + /** + * Process a request. + * + * If an authentication source returns from this function, it is assumed to have + * authenticated the user, and should have set elements in $state with the attributes + * of the user. + * + * If the authentication process requires additional steps which make it impossible to + * complete before returning from this function, the authentication source should + * save the state, and at a later stage, load the state, update it with the authentication + * information about the user, and call completeAuth with the state array. + * + * @param array &$state Information about the current authentication. + */ + public function authenticate(array &$state): void + { + $source = $this->selectAuthSource(); + $as = Auth\Source::getById($source); + + if ($as === null || !in_array($source, $this->validSources, true)) { + throw new Exception('Invalid authentication source: ' . $source); + } + + static::doAuthentication($as, $state); + } + + + /** + * @param \SimpleSAML\Auth\Source $as + * @param array $state + * @return void + */ + public static function doAuthentication(Auth\Source $as, array $state): void + { + try { + $as->authenticate($state); + } catch (Error\Exception $e) { + Auth\State::throwException($state, $e); + } catch (Exception $e) { + $e = new Error\UnserializableException($e); + Auth\State::throwException($state, $e); + } + + Auth\Source::completeAuth($state); + } + + + /** + * Decide what authsource to use. + * + * @param array &$state Information about the current authentication. + * @return string + */ + abstract protected function selectAuthSource(): string; +} diff --git a/modules/core/lib/Auth/Source/IPSourceSelector.php b/modules/core/lib/Auth/Source/IPSourceSelector.php new file mode 100644 index 0000000000000000000000000000000000000000..3264513dee005f5614ca5cafeed1f9cef3a74c78 --- /dev/null +++ b/modules/core/lib/Auth/Source/IPSourceSelector.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Module\core\Auth\Source; + +use Exception; +use SimpleSAML\Assert\Assert; +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Logger; +use SimpleSAML\Session; +use SimpleSAML\Utils; + +/** + * Authentication source which delegates authentication to secondary + * authentication sources based on the client's source IP + * + * @package simplesamlphp/simplesamlphp + */ +class IPSourceSelector extends AbstractSourceSelector +{ + /** + * The key of the AuthId field in the state. + */ + public const AUTHID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.AuthId'; + + /** + * The string used to identify our states. + */ + public const STAGEID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.StageId'; + + /** + * The key where the sources is saved in the state. + */ + public const SOURCESID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.SourceId'; + + /** + * @param string The default authentication source to use when none of the zones match + */ + protected string $defaultSource; + + /** + * @param array An array of zones. Each zone requires two keys; + * 'source' containing the authsource for the zone, + * 'subnet' containing an array of IP-ranges (CIDR notation). + */ + protected array $zones = []; + + + /** + * Constructor for this authentication source. + * + * @param array $info Information about this authentication source. + * @param array $config Configuration. + */ + public function __construct(array $info, array $config) + { + // Call the parent constructor first, as required by the interface + parent::__construct($info, $config); + + Assert::keyExists($config, 'zones'); + Assert::keyExists($config['zones'], 'default'); + Assert::stringNotEmpty($config['zones']['default']); + $this->defaultSource = $config['zones']['default']; + + unset($config['zones']['default']); + $zones = $config['zones']; + + foreach ($zones as $key => $zone) { + if (!array_key_exists('source', $zone)) { + Logger::warning(sprintf('Discarding zone %s due to missing `source` key.', $key)); + } elseif (!array_key_exists('subnet', $zone)) { + Logger::warning(sprintf('Discarding zone %s due to missing `subnet` key.', $key)); + } else { + $this->zones[$key] = $zone; + } + } + } + + + /** + * Decide what authsource to use. + * + * @param array &$state Information about the current authentication. + * @return string + */ + protected function selectAuthSource(): string + { + $netUtils = new Utils\Net(); + $ip = $_SERVER['REMOTE_ADDR']; + + $source = $this->defaultSource; + foreach ($this->zones as $name => $zone) { + foreach ($zone['subnet'] as $subnet) { + if ($netUtils->ipCIDRcheck($subnet, $ip)) { + // Client's IP is in one of the ranges for the secondary auth source + Logger::info(sprintf("core:IPSourceSelector: Selecting zone `%s` based on client IP %s", $name, $ip)); + $source = $zone['source']; + break; + } + } + } + + if ($source === $this->defaultSource) { + Logger::info("core:IPSourceSelector: no match on client IP; selecting default zone"); + } + + return $source; + } +} diff --git a/tests/modules/core/lib/Auth/Source/IPSourceSelectorTest.php b/tests/modules/core/lib/Auth/Source/IPSourceSelectorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..6addcf29991953dcca4f7f595efe9dd9de246e3e --- /dev/null +++ b/tests/modules/core/lib/Auth/Source/IPSourceSelectorTest.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\core\Auth\Source; + +use Error; +use Exception; +use PHPUnit\Framework\TestCase; +use SimpleSAML\Assert\AssertionFailedException; +use SimpleSAML\Auth; +use SimpleSAML\Configuration; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Module\core\Auth\Source\IPSourceSelector; + +/** + * @covers \SimpleSAML\Module\core\Auth\Source\AbstractSourceSelector + * @covers \SimpleSAML\Module\core\Auth\Source\IPSourceSelector + */ +class IPSourceSelectorTest extends TestCase +{ + /** @var \SimpleSAML\Configuration */ + private Configuration $config; + + /** @var \SimpleSAML\Configuration */ + private Configuration $sourceConfig; + + + /** + */ + public function setUp(): void + { + $this->config = Configuration::loadFromArray( + ['module.enable' => ['core' => true]], + '[ARRAY]', + 'simplesaml' + ); + Configuration::setPreLoadedConfig($this->config, 'config.php'); + + $this->sourceConfig = Configuration::loadFromArray([ + 'selector' => [ + 'core:IPSourceSelector', + + 'zones' => [ + 'internal' => [ + 'source' => 'internal', + 'subnet' => [ + '10.0.0.0/8', + '2001:0DB8::/108', + ], + ], + + 'other' => [ + 'source' => 'other', + 'subnet' => [ + '172.16.0.0/12', + '2002:1234::/108', + ], + ], + + 'default' => 'external', + ], + ], + + 'other' => [ + 'core:AdminPassword', + ], + + 'internal' => [ + 'core:AdminPassword', + ], + + 'external' => [ + 'core:AdminPassword', + ], + ]); + Configuration::setPreLoadedConfig($this->sourceConfig, 'authsources.php'); + } + + + /** + */ + public function testDefaultZoneIsRequired(): void + { + $this->expectException(AssertionFailedException::class); + $this->expectExceptionMessage('Expected the key "default" to exist.'); + + $sourceConfig = Configuration::loadFromArray([ + 'selector' => [ + 'core:IPSourceSelector', + + 'zones' => [ + 'internal' => [], + ], + ], + ]); + Configuration::setPreLoadedConfig($sourceConfig, 'authsources.php'); + + new IPSourceSelector(['AuthId' => 'selector'], $sourceConfig->getArray('selector')); + } + + + /** + */ + public function testAuthentication(): void + { + $info = ['AuthId' => 'selector']; + $config = $this->sourceConfig->getArray('selector'); + + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['REQUEST_URI'] = '/'; + + $selector = new class ($info, $config) extends IPSourceSelector { + /** + * @param \SimpleSAML\Auth\Source $as + * @param array $state + * @return void + */ + public static function doAuthentication(Auth\Source $as, array $state): void + { + // Dummy + } + }; + + $state = []; + $result = $selector->authenticate($state); + $this->assertNull($result); + } + + + /** + * @dataProvider provideClientIP + * @param string $ip The client IP + * @param string $expected The expected authsource + */ + public function testSelectAuthSource(string $ip, string $expected): void + { + $info = ['AuthId' => 'selector']; + $config = $this->sourceConfig->getArray('selector'); + + $_SERVER['REMOTE_ADDR'] = $ip; + + $selector = new class ($info, $config) extends IPSourceSelector { + /** + * @return string + */ + public function selectAuthSource(): string + { + return parent::selectAuthSource(); + } + }; + + $source = $selector->selectAuthSource(); + $this->assertEquals($expected, $source); + } + + + /** + * @return string + */ + public function provideClientIP(): array + { + return [ + ['127.0.0.2', 'external'], + ['10.4.13.2', 'internal'], + ['2001:0DB8:0000:0000:0000:0000:0000:0000', 'internal'], + ['145.21.93.97', 'external'], + ['172.16.1.2', 'other'], + ['2002:1234:0000:0000:0000:0000:0000:0000', 'other'], + ]; + } +}