Skip to content

Commit 23bdd57

Browse files
authored
RequestedAuthnContext source selector (#1749)
* Fix missing state parameter * Slightly rationalize naming convention for selectors * Add RequestedAuthnContextSelector * Allow source-selector to return multiple sources * Fix docs * Revert return-type change * Throw exception for incomplete config * Remove test-data for non-implemented cases * Codesniffer fix
1 parent ad96417 commit 23bdd57

File tree

6 files changed

+403
-40
lines changed

6 files changed

+403
-40
lines changed

modules/core/docs/authsource_selector.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ act as an Authentication Source. Any derivative classes must implement the
1111
abstract `selectAuthSource` method. This method must return the name of the
1212
Authentication Source to use, based on whatever logic is necessary.
1313

14-
## IPSourceSelector
14+
## SourceIPSelector
1515

16-
The IPSourceSelector is an implementation of the `AbstractSourceSelector` and
16+
The SourceIPSelector is an implementation of the `AbstractSourceSelector` that
1717
uses the client IP to decide what Authentication Source is called.
1818
It works by defining zones with corresponding IP-ranges and Authentication
1919
Sources. The 'default' zone is required and acts as a fallback when none
@@ -23,7 +23,7 @@ An example configuration would look like this:
2323

2424
```php
2525
'selector' => [
26-
'core:IPSourceSelector',
26+
'core:SourceIPSelector',
2727

2828
'zones' => [
2929
'internal' => [
@@ -47,6 +47,36 @@ An example configuration would look like this:
4747
],
4848
```
4949

50+
## RequestedAuthnContextSelector
51+
52+
The RequestedAuthnContextSelector is an implementation of the `AbstractSourceSelector` that
53+
uses the RequestedAuthnContext to decide what Authentication Source is called.
54+
It works by defining AuthnContexts with their corresponding Authentication
55+
Sources. The 'default' will be used as a fallback when no RequestedAuthnContext
56+
is passed in the request.
57+
58+
An example configuration would look like this:
59+
60+
```php
61+
'selector' => [
62+
'core:RequestedAuthnContextSelector',
63+
64+
'contexts' => [
65+
'userpass' => [
66+
'identifier' => 'urn:x-simplesamlphp:loa1',
67+
'source' => 'ldap',
68+
],
69+
70+
'mfa' => [
71+
'identifier' => 'urn:x-simplesamlphp:loa2',
72+
'source' => 'radius',
73+
],
74+
75+
'default' => 'ldap',
76+
],
77+
],
78+
```
79+
5080
## YourCustomSourceSelector
5181

5282
If you have a use-case for a custom Authentication source selector, all you

modules/core/src/Auth/Source/AbstractSourceSelector.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,8 @@ public function __construct(array $info, array $config)
5858
*/
5959
public function authenticate(array &$state): void
6060
{
61-
$source = $this->selectAuthSource();
61+
$source = $this->selectAuthSource($state);
6262
$as = Auth\Source::getById($source);
63-
6463
if ($as === null || !in_array($source, $this->validSources, true)) {
6564
throw new Exception('Invalid authentication source: ' . $source);
6665
}
@@ -95,5 +94,5 @@ public static function doAuthentication(Auth\Source $as, array $state): void
9594
* @param array &$state Information about the current authentication.
9695
* @return string
9796
*/
98-
abstract protected function selectAuthSource(): string;
97+
abstract protected function selectAuthSource(array &$state): string;
9998
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Module\core\Auth\Source\Selector;
6+
7+
use SAML2\Constants as C;
8+
use SAML2\Exception\Protocol\NoAuthnContextException;
9+
use SimpleSAML\Assert\Assert;
10+
use SimpleSAML\Error;
11+
use SimpleSAML\Logger;
12+
use SimpleSAML\Module\core\Auth\Source\AbstractSourceSelector;
13+
14+
use function array_key_exists;
15+
use function sprintf;
16+
17+
/**
18+
* Authentication source which delegates authentication to secondary
19+
* authentication sources based on the RequestedAuthnContext
20+
*
21+
* @package simplesamlphp/simplesamlphp
22+
*/
23+
class RequestedAuthnContextSelector extends AbstractSourceSelector
24+
{
25+
/**
26+
* The key of the AuthId field in the state.
27+
*/
28+
public const AUTHID = '\SimpleSAML\Module\core\Auth\Source\Selector\RequestedAuthnContextSelector.AuthId';
29+
30+
/**
31+
* The string used to identify our states.
32+
*/
33+
public const STAGEID = '\SimpleSAML\Module\core\Auth\Source\Selector\RequestedAuthnContextSelector.StageId';
34+
35+
/**
36+
* The key where the sources is saved in the state.
37+
*/
38+
public const SOURCESID = '\SimpleSAML\Module\core\Auth\Source\Selector\RequestedAuthnContextSelector.SourceId';
39+
40+
/**
41+
* @var string The default authentication source to use when no RequestedAuthnContext is passed
42+
* @psalm-suppress PropertyNotSetInConstructor
43+
*/
44+
protected string $defaultSource;
45+
46+
/**
47+
* @var array<int, array> An array of AuthnContexts, indexed by its weight (higher = better).
48+
* Each entry is in the format of:
49+
* `weight` => [`identifier` => 'identifier', `source` => 'source']
50+
*
51+
* i.e.:
52+
*
53+
* '10' => [
54+
* 'identifier' => 'urn:x-simplesamlphp:loa1',
55+
* 'source' => 'exampleauth',
56+
* ],
57+
* '20' => [
58+
* 'identifier' => 'urn:x-simplesamlphp:loa2',
59+
* 'source' => 'exampleauth-mfa',
60+
* ]
61+
*/
62+
protected array $contexts = [];
63+
64+
65+
/**
66+
* Constructor for this authentication source.
67+
*
68+
* @param array $info Information about this authentication source.
69+
* @param array $config Configuration.
70+
*/
71+
public function __construct(array $info, array $config)
72+
{
73+
// Call the parent constructor first, as required by the interface
74+
parent::__construct($info, $config);
75+
76+
Assert::keyExists($config, 'contexts');
77+
78+
foreach ($config['contexts'] as $key => $context) {
79+
if ($key === 'default') {
80+
Assert::stringNotEmpty($config['contexts']['default']);
81+
$this->defaultSource = $config['contexts']['default'];
82+
} else {
83+
Assert::natural($key);
84+
if (!array_key_exists('identifier', $context)) {
85+
throw new Exception(sprintf("Incomplete context '%d' due to missing `identifier` key.", $key));
86+
} elseif (!array_key_exists('source', $context)) {
87+
throw new Exception(sprintf("Incomplete context '%d' due to missing `source` key.", $key));
88+
} else {
89+
$this->contexts[$key] = $context;
90+
}
91+
}
92+
}
93+
}
94+
95+
96+
/**
97+
* Decide what authsource to use.
98+
*
99+
* @param array &$state Information about the current authentication.
100+
* @return string
101+
*/
102+
protected function selectAuthSource(array &$state): string
103+
{
104+
$requestedContexts = $state['saml:RequestedAuthnContext'];
105+
if ($requestedContexts['AuthnContextClassRef'] === null) {
106+
Logger::info(
107+
"core:RequestedAuthnContextSelector: no RequestedAuthnContext provided; selecting default authsource"
108+
);
109+
return $this->defaultSource;
110+
}
111+
112+
Assert::isArray($requestedContexts['AuthnContextClassRef']);
113+
$comparison = $requestedContexts['Comparison'] ?? 'exact';
114+
Assert::oneOf($comparison, ['exact', 'minimum', 'maximum', 'better']);
115+
116+
/**
117+
* The set of supplied references MUST be evaluated as an ordered set, where the first element
118+
* is the most preferred authentication context class or declaration.
119+
*/
120+
$index = false;
121+
foreach ($requestedContexts['AuthnContextClassRef'] as $requestedContext) {
122+
switch ($comparison) {
123+
case 'exact':
124+
foreach ($this->contexts as $index => $context) {
125+
if ($context['identifier'] === $requestedContext) {
126+
return $context['source'];
127+
}
128+
}
129+
break 2;
130+
case 'minimum':
131+
case 'maximum':
132+
case 'better':
133+
// Not implemented
134+
throw new Error\Exception('Not implemented.');
135+
}
136+
}
137+
138+
throw new NoAuthnContextException();
139+
}
140+
}

modules/core/src/Auth/Source/IPSourceSelector.php renamed to modules/core/src/Auth/Source/Selector/SourceIPSelector.php

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,40 +2,38 @@
22

33
declare(strict_types=1);
44

5-
namespace SimpleSAML\Module\core\Auth\Source;
5+
namespace SimpleSAML\Module\core\Auth\Source\Selector;
66

7-
use Exception;
87
use SimpleSAML\Assert\Assert;
9-
use SimpleSAML\Auth;
10-
use SimpleSAML\Configuration;
11-
use SimpleSAML\Error;
12-
use SimpleSAML\HTTP\RunnableResponse;
138
use SimpleSAML\Logger;
14-
use SimpleSAML\Session;
9+
use SimpleSAML\Module\core\Auth\Source\AbstractSourceSelector;
1510
use SimpleSAML\Utils;
1611

12+
use function array_key_exists;
13+
use function sprintf;
14+
1715
/**
1816
* Authentication source which delegates authentication to secondary
1917
* authentication sources based on the client's source IP
2018
*
2119
* @package simplesamlphp/simplesamlphp
2220
*/
23-
class IPSourceSelector extends AbstractSourceSelector
21+
class SourceIPSelector extends AbstractSourceSelector
2422
{
2523
/**
2624
* The key of the AuthId field in the state.
2725
*/
28-
public const AUTHID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.AuthId';
26+
public const AUTHID = '\SimpleSAML\Module\core\Auth\Source\Selector\SourceIPSelector.AuthId';
2927

3028
/**
3129
* The string used to identify our states.
3230
*/
33-
public const STAGEID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.StageId';
31+
public const STAGEID = '\SimpleSAML\Module\core\Auth\Source\Selector\SourceIPSelector.StageId';
3432

3533
/**
3634
* The key where the sources is saved in the state.
3735
*/
38-
public const SOURCESID = '\SimpleSAML\Module\core\Auth\Source\IPSourceSelector.SourceId';
36+
public const SOURCESID = '\SimpleSAML\Module\core\Auth\Source\Selector\SourceIPSelector.SourceId';
3937

4038
/**
4139
* @param string The default authentication source to use when none of the zones match
@@ -71,9 +69,9 @@ public function __construct(array $info, array $config)
7169

7270
foreach ($zones as $key => $zone) {
7371
if (!array_key_exists('source', $zone)) {
74-
Logger::warning(sprintf('Discarding zone %s due to missing `source` key.', $key));
72+
throw new Exception(sprintf("Incomplete zone-configuration '%s' due to missing `source` key.", $key));
7573
} elseif (!array_key_exists('subnet', $zone)) {
76-
Logger::warning(sprintf('Discarding zone %s due to missing `subnet` key.', $key));
74+
throw new Exception(sprintf("Incomplete zone-configuration '%s' due to missing `subnet` key.", $key));
7775
} else {
7876
$this->zones[$key] = $zone;
7977
}
@@ -87,7 +85,7 @@ public function __construct(array $info, array $config)
8785
* @param array &$state Information about the current authentication.
8886
* @return string
8987
*/
90-
protected function selectAuthSource(): string
88+
protected function selectAuthSource(/** @scrutinizer ignore-unused */ array &$state): string
9189
{
9290
$netUtils = new Utils\Net();
9391
$ip = $_SERVER['REMOTE_ADDR'];
@@ -98,7 +96,7 @@ protected function selectAuthSource(): string
9896
if ($netUtils->ipCIDRcheck($subnet, $ip)) {
9997
// Client's IP is in one of the ranges for the secondary auth source
10098
Logger::info(sprintf(
101-
"core:IPSourceSelector: Selecting zone `%s` based on client IP %s",
99+
"core:SourceIPSelector: Selecting zone `%s` based on client IP %s",
102100
$name,
103101
$ip
104102
));
@@ -109,7 +107,7 @@ protected function selectAuthSource(): string
109107
}
110108

111109
if ($source === $this->defaultSource) {
112-
Logger::info("core:IPSourceSelector: no match on client IP; selecting default zone");
110+
Logger::info("core:SourceIPSelector: no match on client IP; selecting default zone");
113111
}
114112

115113
return $source;

0 commit comments

Comments
 (0)