diff --git a/modules/multiauth/lib/Auth/Source/MultiAuth.php b/modules/multiauth/lib/Auth/Source/MultiAuth.php index 9e5273146c489bd01d28d0f9f3f987681558e663..3304ad27456f44b64a255af4b986b9767e1a97ea 100644 --- a/modules/multiauth/lib/Auth/Source/MultiAuth.php +++ b/modules/multiauth/lib/Auth/Source/MultiAuth.php @@ -10,6 +10,7 @@ use SimpleSAML\Assert\Assert; use SimpleSAML\Auth; use SimpleSAML\Configuration; use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; use SimpleSAML\Module; use SimpleSAML\Session; use SimpleSAML\Utils; @@ -187,7 +188,7 @@ class MultiAuth extends Auth\Source /* Redirect to the select source page. We include the identifier of the * saved state array as a parameter to the login form */ - $url = Module::getModuleURL('multiauth/selectsource.php'); + $url = Module::getModuleURL('multiauth/discovery'); $params = ['AuthState' => $id]; // Allows the user to specify the auth source to be used @@ -212,9 +213,10 @@ class MultiAuth extends Auth\Source * * @param string $authId Selected authentication source * @param array $state Information about the current authentication. + * @return \SimpleSAML\HTTP\RunnableResponse * @throws \Exception */ - public static function delegateAuthentication(string $authId, array $state): void + public static function delegateAuthentication(string $authId, array $state): RunnableResponse { $as = Auth\Source::getById($authId); $valid_sources = array_map( @@ -240,6 +242,17 @@ class MultiAuth extends Auth\Source Session::DATA_TIMEOUT_SESSION_END ); + return new RunnableResponse([self::class, '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) { diff --git a/modules/multiauth/lib/Controller/DiscoController.php b/modules/multiauth/lib/Controller/DiscoController.php new file mode 100644 index 0000000000000000000000000000000000000000..e4d3eba97124092c83cfff739e28997d37330c18 --- /dev/null +++ b/modules/multiauth/lib/Controller/DiscoController.php @@ -0,0 +1,167 @@ +<?php + +namespace SimpleSAML\Module\multiauth\Controller; + +use SimpleSAML\Auth; +use SimpleSAML\Auth\AuthenticationFactory; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Logger; +use SimpleSAML\Module; +use SimpleSAML\Module\multiauth\Auth\Source\MultiAuth; +use SimpleSAML\Session; +use SimpleSAML\Utils; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; + +/** + * Controller class for the multiauth module. + * + * This class serves the different views available in the module. + * + * @package SimpleSAML\Module\multiauth + */ +class DiscoController +{ + /** @var \SimpleSAML\Configuration */ + protected $config; + + /** @var \SimpleSAML\Session */ + protected $session; + + /** + * @var \SimpleSAML\Auth\Source|string + * @psalm-var \SimpleSAML\Auth\Source|class-string + */ + protected $authSource = Auth\Source::class; + + /** + * @var \SimpleSAML\Auth\State|string + * @psalm-var \SimpleSAML\Auth\State|class-string + */ + protected $authState = Auth\State::class; + + + /** + * Controller constructor. + * + * It initializes the global configuration and auth source configuration for the controllers implemented here. + * + * @param \SimpleSAML\Configuration $config The configuration to use by the controllers. + * @param \SimpleSAML\Session $session The session to use by the controllers. + * + * @throws \Exception + */ + public function __construct( + Configuration $config, + Session $session + ) { + $this->config = $config; + $this->session = $session; + } + + + /** + * Inject the \SimpleSAML\Auth\Source dependency. + * + * @param \SimpleSAML\Auth\Source $authSource + */ + public function setAuthSource(Auth\Source $authSource): void + { + $this->authSource = $authSource; + } + + + /** + * Inject the \SimpleSAML\Auth\State dependency. + * + * @param \SimpleSAML\Auth\State $authState + */ + public function setAuthState(Auth\State $authState): void + { + $this->authState = $authState; + } + + + /** + * This controller shows a list of authentication sources. When the user selects + * one of them if pass this information to the + * \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth class and call the + * delegateAuthentication method on it. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * @return \SimpleSAML\XHTML\Template|\SimpleSAML\HTTP\RunnableResponse + * An HTML template or a redirection if we are not authenticated. + */ + public function discovery(Request $request) + { + // Retrieve the authentication state + $authStateId = $request->get('AuthState', null); + if (is_null($authStateId)) { + throw new Error\BadRequest('Missing AuthState parameter.'); + } + + /** @var array $state */ + $state = $this->authState::loadState($authStateId, MultiAuth::STAGEID); + + $as = null; + if (array_key_exists("\SimpleSAML\Auth\Source.id", $state)) { + $authId = $state["\SimpleSAML\Auth\Source.id"]; + + /** @var \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth $as */ + $as = Auth\Source::getById($authId); + } + + $source = $request->get('source', null); + + if ($source !== null) { + if ($as !== null) { + $as->setPreviousSource($source); + } + return MultiAuth::delegateAuthentication($source, $state); + } + + if (array_key_exists('multiauth:preselect', $state)) { + $source = $state['multiauth:preselect']; + return MultiAuth::delegateAuthentication($source, $state); + } + + $t = new Template($this->config, 'multiauth:selectsource.twig'); + + $defaultLanguage = $this->config->getString('language.default', 'en'); + $language = $t->getTranslator()->getLanguage()->getLanguage(); + + $sources = $state[MultiAuth::SOURCESID]; + foreach ($sources as $key => $source) { + $sources[$key]['source64'] = base64_encode($sources[$key]['source']); + if (isset($sources[$key]['text'][$language])) { + $sources[$key]['text'] = $sources[$key]['text'][$language]; + } else { + $sources[$key]['text'] = $sources[$key]['text'][$defaultLanguage]; + } + + if (isset($sources[$key]['help'][$language])) { + $sources[$key]['help'] = $sources[$key]['help'][$language]; + } else { + $sources[$key]['help'] = $sources[$key]['help'][$defaultLanguage]; + } + } + + $baseurl = explode("/", Utils\HTTP::getBaseURL()); + $elements = array_slice($baseurl, 3 - count($baseurl), count($baseurl) - 4); + $path = implode("/", $elements); + + $t->data['selfUrl'] = '/' . $path; + $t->data['authstate'] = $authStateId; + $t->data['sources'] = $sources; + + if ($as !== null) { + $t->data['preferred'] = $as->getPreviousSource(); + } else { + $t->data['preferred'] = null; + } + return $t; + } +} diff --git a/modules/multiauth/routing/routes/routes.yaml b/modules/multiauth/routing/routes/routes.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0bf81d9bde16e7d2fd61330ac66efcc9a3d9e5d2 --- /dev/null +++ b/modules/multiauth/routing/routes/routes.yaml @@ -0,0 +1,3 @@ +multiauth-discovery: + path: /discovery + defaults: { _controller: 'SimpleSAML\Module\multiauth\Controller\DiscoController::discovery' } diff --git a/modules/multiauth/templates/selectsource.twig b/modules/multiauth/templates/selectsource.twig index 7b212cbd85858a893ab38085c3d03680a1eb611a..aa7482c884aef8c22a41fd5a0a7f17d3cb6d8826 100644 --- a/modules/multiauth/templates/selectsource.twig +++ b/modules/multiauth/templates/selectsource.twig @@ -9,10 +9,9 @@ <input type="hidden" name="AuthState" value="{{ authstate|escape('html') }}"> <ul> {% for key, source in sources %} - {% set name = ('src-' ~ source.source64) %} {% set button = ('button-' ~ source.source) %} <li class="{{ source.css_class|escape('html') }} authsource"> - <input type="submit" name="{{ name|escape('html') }}" id="{{ button|escape('html') }}" value="{{ source.text|escape('html') }}"{%- if source.source == preferred %} autofocus{% endif -%}> + <input type="submit" name="source" id="{{ button|escape('html') }}" value="{{ source.text|escape('html') }}"{%- if source.source == preferred %} autofocus{% endif -%}> {% if source.help %} <p>{{ source.help|escape('html') }}</p> {% endif %} diff --git a/modules/multiauth/www/selectsource.php b/modules/multiauth/www/selectsource.php index fcc5a96729165b65ec544a8082d217fab8147b57..6e7c870ed290ff91253b9ed51b44b16eb1bef049 100644 --- a/modules/multiauth/www/selectsource.php +++ b/modules/multiauth/www/selectsource.php @@ -9,76 +9,16 @@ * @package SimpleSAMLphp */ -// Retrieve the authentication state -if (!array_key_exists('AuthState', $_REQUEST)) { - throw new \SimpleSAML\Error\BadRequest('Missing AuthState parameter.'); -} -$authStateId = $_REQUEST['AuthState']; +namespace SimpleSAML\Module\multiauth; -/** @var array $state */ -$state = \SimpleSAML\Auth\State::loadState($authStateId, \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::STAGEID); +use SimpleSAML\Configuration; +use SimpleSAML\Session; +use Symfony\Component\HttpFoundation\Request; -if (array_key_exists("\SimpleSAML\Auth\Source.id", $state)) { - $authId = $state["\SimpleSAML\Auth\Source.id"]; - /** @var \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth $as */ - $as = \SimpleSAML\Auth\Source::getById($authId); -} else { - $as = null; -} +$config = Configuration::getInstance(); +$session = Session::getSessionFromRequest(); +$request = Request::createFromGlobals(); -$source = null; -if (array_key_exists('source', $_REQUEST)) { - $source = $_REQUEST['source']; -} else { - foreach ($_REQUEST as $k => $v) { - $k = explode('-', $k, 2); - if (count($k) === 2 && $k[0] === 'src') { - $source = base64_decode($k[1]); - } - } -} -if ($source !== null) { - if ($as !== null) { - $as->setPreviousSource($source); - } - \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::delegateAuthentication($source, $state); -} - -if (array_key_exists('multiauth:preselect', $state)) { - $source = $state['multiauth:preselect']; - \SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::delegateAuthentication($source, $state); -} - -$globalConfig = \SimpleSAML\Configuration::getInstance(); -$t = new \SimpleSAML\XHTML\Template($globalConfig, 'multiauth:selectsource.twig'); - -$defaultLanguage = $globalConfig->getString('language.default', 'en'); -$language = $t->getTranslator()->getLanguage()->getLanguage(); - -$sources = $state[\SimpleSAML\Module\multiauth\Auth\Source\MultiAuth::SOURCESID]; -foreach ($sources as $key => $source) { - $sources[$key]['source64'] = base64_encode($sources[$key]['source']); - if (isset($sources[$key]['text'][$language])) { - $sources[$key]['text'] = $sources[$key]['text'][$language]; - } else { - $sources[$key]['text'] = $sources[$key]['text'][$defaultLanguage]; - } - - if (isset($sources[$key]['help'][$language])) { - $sources[$key]['help'] = $sources[$key]['help'][$language]; - } else { - $sources[$key]['help'] = $sources[$key]['help'][$defaultLanguage]; - } -} - -$t->data['authstate'] = $authStateId; -$t->data['sources'] = $sources; -$t->data['selfUrl'] = $_SERVER['PHP_SELF']; - -if ($as !== null) { - $t->data['preferred'] = $as->getPreviousSource(); -} else { - $t->data['preferred'] = null; -} -$t->send(); -exit(); +$controller = new Controller\DiscoController($config, $session); +$response = $controller->discovery($request); +$response->send(); diff --git a/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php b/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e6b082dc60355ae94c854d1ad0de01a2c5650bfe --- /dev/null +++ b/tests/modules/multiauth/lib/Controller/DiscoControllerTest.php @@ -0,0 +1,366 @@ +<?php + +declare(strict_types=1); + +namespace SimpleSAML\Test\Module\multiauth\Controller; + +use PHPUnit\Framework\TestCase; +use SimpleSAML\Auth\Source; +use SimpleSAML\Auth\State; +use SimpleSAML\Configuration; +use SimpleSAML\Error; +use SimpleSAML\HTTP\RunnableResponse; +use SimpleSAML\Module\multiauth\Auth\Source\MultiAuth; +use SimpleSAML\Module\multiauth\Controller; +use SimpleSAML\Session; +use SimpleSAML\XHTML\Template; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Set of tests for the controllers in the "multiauth" module. + * + * @package SimpleSAML\Test + */ +class DiscoControllerTest extends TestCase +{ + /** @var \SimpleSAML\Configuration */ + protected $config; + + /** @var \SimpleSAML\Session */ + protected $session; + + + /** + * Set up for each test. + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->config = Configuration::loadFromArray( + [ + 'module.enable' => ['multiauth' => true], + ], + '[ARRAY]', + 'simplesaml' + ); + + $this->session = Session::getSessionFromRequest(); + + Configuration::setPreLoadedConfig( + Configuration::loadFromArray( + [ + 'admin' => ['core:AdminPassword'], + 'admin2' => ['core:AdminPassword'], + 'multi' => [ + 'multiauth:MultiAuth', + 'sources' => [ + 'admin', + 'admin2' + ] + ], + ], + '[ARRAY]', + 'simplesaml' + ), + 'authsources.php', + 'simplesaml' + ); + } + + + /** + * Test that a missing AuthState results in a BadRequest-error + * @return void + * @throws \SimpleSAML\Error\BadRequest + */ + public function testDiscoveryMissingState(): void + { + $request = Request::create( + '/discovery', + 'GET' + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $this->expectException(Error\BadRequest::class); + $this->expectExceptionMessage('Missing AuthState parameter.'); + + $c->discovery($request); + } + + + /** + * Test that a valid requests results in a Twig template + * @return void + */ + public function testDiscoveryFallthru(): void + { + $request = Request::create( + '/discovery', + 'GET', + ['AuthState' => 'someState'] + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $c->setAuthState(new class () extends State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return [ + 'LogoutState' => [ + 'multiauth:discovery' => 'foo' + ], + MultiAuth::SOURCESID => [ + 'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']], + 'source2' => ['source' => 'test', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']] + ] + ]; + } + }); + + $c->setAuthSource(new class () extends MultiAuth { + public function __construct() + { + // stub + } + + public function authenticate(array &$state): void + { + // stub + } + + public static function getById(string $authId, ?string $type = null): ?Source + { + return new static(); + } + }); + + $response = $c->discovery($request); + + $this->assertInstanceOf(Template::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + + /** + * Test that a valid requests results in a Twig template + * @return void + */ + public function testDiscoveryFallthruWithSource(): void + { + $request = Request::create( + '/discovery', + 'GET', + ['AuthState' => 'someState'] + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $c->setAuthState(new class () extends State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return [ + 'LogoutState' => [ + 'multiauth:discovery' => 'foo' + ], + '\SimpleSAML\Auth\Source.id' => 'multi', + MultiAuth::SOURCESID => [ + 'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']], + 'source2' => ['source' => 'test', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']] + ] + ]; + } + }); + + $c->setAuthSource(new class () extends MultiAuth { + public function __construct() + { + // stub + } + + public function authenticate(array &$state): void + { + // stub + } + + public static function getById(string $authId, ?string $type = null): ?Source + { + return new static(); + } + }); + + $response = $c->discovery($request); + + $this->assertInstanceOf(Template::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + + /** + * Test that a valid requests results in a Twig template + * @return void + */ + public function testDiscoveryDelegateAuth1(): void + { + $request = Request::create( + '/discovery', + 'GET', + ['AuthState' => 'someState'] + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $c->setAuthState(new class () extends State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return [ + 'LogoutState' => [ + 'multiauth:discovery' => 'foo' + ], + 'multiauth:preselect' => 'admin', + '\SimpleSAML\Auth\Source.id' => 'multi', + MultiAuth::AUTHID => 'bar', + MultiAuth::SOURCESID => [ + 'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['nl' => 'text']], + 'source2' => ['source' => 'test', 'text' => ['en' => 'text'], 'help' => ['nl' => 'help']] + ] + ]; + } + }); + + $c->setAuthSource(new class () extends MultiAuth { + public function __construct() + { + // stub + } + + public function authenticate(array &$state): void + { + // stub + } + + public static function getById(string $authId, ?string $type = null): ?Source + { + return new static(); + } + }); + + $response = $c->discovery($request); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + + /** + * Test that a valid requests results in a Twig template + * @return void + */ + public function testDiscoveryDelegateAuth1WithPreviousSource(): void + { + $request = Request::create( + '/discovery', + 'GET', + ['AuthState' => 'someState', 'source' => 'admin'] + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $c->setAuthState(new class () extends State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return [ + 'LogoutState' => [ + 'multiauth:discovery' => 'foo' + ], + 'multiauth:preselect' => 'admin', + '\SimpleSAML\Auth\Source.id' => 'multi', + MultiAuth::AUTHID => 'bar', + MultiAuth::SOURCESID => [ + 'source1' => ['source' => 'admin', 'help' => ['en' => 'help']], + 'source2' => ['source' => 'test', 'text' => ['en' => 'text']] + ] + ]; + } + }); + + $c->setAuthSource(new class () extends MultiAuth { + public function __construct() + { + // stub + } + + public function authenticate(array &$state): void + { + // stub + } + + public static function getById(string $authId, ?string $type = null): ?Source + { + return new static(); + } + }); + + $response = $c->discovery($request); + + $this->assertInstanceOf(RunnableResponse::class, $response); + $this->assertTrue($response->isSuccessful()); + } + + + /** + * Test that a valid requests results in a RunnableResponse + * @return void + */ + public function testDiscoveryDelegateAuth2(): void + { + $request = Request::create( + '/discovery', + 'GET', + ['AuthState' => 'someState', 'src-YWRtaW4=' => 'admin'] + ); + + $c = new Controller\DiscoController($this->config, $this->session); + + $c->setAuthState(new class () extends State { + public static function loadState(string $id, string $stage, bool $allowMissing = false): ?array + { + return [ + 'LogoutState' => [ + 'multiauth:discovery' => 'foo' + ], + MultiAuth::AUTHID => 'bar', + MultiAuth::SOURCESID => [ + 'source1' => ['source' => 'admin', 'help' => ['en' => 'help'], 'text' => ['en' => 'text']], + 'source2' => ['source' => 'test', 'text' => ['en' => 'text'], 'help' => ['en' => 'help']] + ] + ]; + } + }); + + $c->setAuthSource(new class () extends MultiAuth { + public function __construct() + { + // stub + } + + public function authenticate(array &$state): void + { + // stub + } + + public static function getById(string $authId, ?string $type = null): ?Source + { + return new static(); + } + }); + + $response = $c->discovery($request); + + $this->assertInstanceOf(Response::class, $response); + $this->assertTrue($response->isSuccessful()); + } +}