diff --git a/CHANGELOG.md b/CHANGELOG.md index 42045a7..6f18e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,4 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This CHANGELOG file to hopefully serve as an evolving example of a standardized open source project CHANGELOG. - PHP-CS-Fixer -- Markdownlint \ No newline at end of file +- Markdownlint +- Test Suite +- Psalm setup for static analysis +- Code formatting +- OpenID Connect Bundle: Upgraded from + `itk-dev/openid-connect` 1.0.0 to 2.1.0 +- OpenId Connect Bundle: Added CLI login feature. \ No newline at end of file diff --git a/README.md b/README.md index 3ec6f5a..38f9d66 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ the bundle authenticator, `OpenIdLoginAuthenticator`. ### Variable configuration -In `/config/packages/` you need the following `itk_dev_openid_connect.yaml` +In `/config/packages/` you need the following `itkdev_openid_connect.yaml` file for configuring OpenId Connect variables ```yaml -itk_dev_open_id_connect: - open_id_provider_options: +itkdev_openid_connect: + openid_provider_options: configuration_url: 'https://.../openid-configuration..' # url to OpenId Discovery document client_id: 'client_id' # Client id assigned by authorizer client_secret: 'client_secret' # Client password assigned by authorizer @@ -41,11 +41,11 @@ itk_dev_open_id_connect: ``` In `/config/routes/` you need a similar -`itk_dev_openid_connect.yaml` file for configuring the routing +`itkdev_openid_connect.yaml` file for configuring the routing ```yaml -itk_dev_openid_connect: - resource: "@ItkDevOpenIdConnectBundle/config/routes.yaml" +itkdev_openid_connect: + resource: "@ItkDevOpenIdConnectBundle/src/Resources/config/routes.yaml" prefix: "/openidconnect" # Prefix for bundle routes ``` @@ -53,6 +53,19 @@ It is not necessary to add a prefix to the bundle routes, but in case you want i.e. another `/login` route, it makes distinguishing between them easier. + +### CLI login + +In order to use the CLI login feature the following +environment variable must be set: + +```shell +DEFAULT_URI= +``` + +See [Symfony documentation](https://symfony.com/doc/current/routing.html#generating-urls-in-commands) +for more information. + ### Creating the Authenticator The bundle handles the extraction of credentials received from the authorizer - @@ -99,7 +112,7 @@ security: main: guard: authenticators: - - App\Security\TestAuthenticator + - App\Security\ExampleAuthenticator ``` #### Example authenticator functions @@ -125,7 +138,7 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\User\UserProviderInterface; -class TestAuthenticator extends OpenIdLoginAuthenticator +class ExampleAuthenticator extends OpenIdLoginAuthenticator { /** * @var UrlGeneratorInterface @@ -136,32 +149,31 @@ class TestAuthenticator extends OpenIdLoginAuthenticator */ private $entityManager; - public function __construct(EntityManagerInterface $entityManager, SessionInterface $session, UrlGeneratorInterface $router) + public function __construct(EntityManagerInterface $entityManager, int $leeway, SessionInterface $session, UrlGeneratorInterface $router, OpenIdConfigurationProvider $provider) { $this->router = $router; $this->entityManager = $entityManager; - parent::__construct($session); + parent::__construct($provider, $session, $leeway); } public function getUser($credentials, UserProviderInterface $userProvider) { + // Extract properties from credentials $name = $credentials['name']; $email = $credentials['upn']; - //Check if user exists already - if not create a user + // Check if user exists already - if not create a user $user = $this->entityManager->getRepository(User::class) ->findOneBy(['email'=> $email]); if (null === $user) { - // Create the new user + // Create the new user and persist it $user = new User(); + $this->entityManager->persist($user); } - // Update/set names here + // Update/set user properties $user->setName($name); $user->setEmail($email); - // persist and flush user to database - // If no change persist will recognize this - $this->entityManager->persist($user); $this->entityManager->flush(); return $user; @@ -174,11 +186,49 @@ class TestAuthenticator extends OpenIdLoginAuthenticator public function start(Request $request, AuthenticationException $authException = null) { - return new RedirectResponse($this->router->generate('itk_dev_openid_connect_login')); + return new RedirectResponse($this->router->generate('itkdev_openid_connect_login')); } } ``` +For this example we have bound `$leeway` via `.env` +and `services.yaml`: + +```text +###> itk-dev/openid-connect-bundle ### +ITK_DEV_LEEWAY=10 +###< itk-dev/openid-connect-bundle ### +``` + +```yaml +services: + _defaults: + bind: + $leeway: '%env(ITK_DEV_LEEWAY)%' +``` + +## Sign in from command line + +Rather than signing in via OpenId Connect, you can get +a sign in url from the command line by providing a username. +Make sure to configure `DEFAULT_URI`. Run + +```shell +bin/console itk-dev:openid-connect:login +``` + +Or + +```shell +bin/console itk-dev:openid-connect:login --help +``` + +for details. + +Be aware that a login token only can be used once +before it is removed, and if you used email as your user provider property +the email goes into the `username` argument. + ## Changes for Symfony 6.0 In Symfony 6.0 a new security system is @@ -207,7 +257,7 @@ docker compose exec phpfpm ./vendor/bin/phpunit ### Psalm static analysis -Where using [Psalm](https://psalm.dev/) for static analysis. To run +Where using [Psalm](https://psalm.dev/) for static analysis. To run psalm do ```shell @@ -230,7 +280,7 @@ the coding standard for the project. ```shell docker run -v ${PWD}:/app itkdev/yarn:latest install - docker run -v ${PWD}:/app itkdev/yarn:latest check-coding-standards + docker run -v ${PWD}:/app itkdev/yarn:latest coding-standards-check ``` ### Apply Coding Standards @@ -247,7 +297,7 @@ To attempt to automatically fix coding style ```shell docker run -v ${PWD}:/app itkdev/yarn:latest install - docker run -v ${PWD}:/app itkdev/yarn:latest check-coding-standards + docker run -v ${PWD}:/app itkdev/yarn:latest coding-standards-apply ``` ## CI diff --git a/composer.json b/composer.json index bc6800b..591ee08 100644 --- a/composer.json +++ b/composer.json @@ -30,8 +30,10 @@ "symfony/framework-bundle": "^5.2", "doctrine/orm": "^2.8", "symfony/security-bundle": "^5.2", - "itk-dev/openid-connect": "^1.0", - "symfony/yaml": "^5.2" + "itk-dev/openid-connect": "^2.0", + "symfony/yaml": "^5.2", + "symfony/uid": "^5.2", + "symfony/cache": "^5.2" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/psalm.xml b/psalm.xml index d58dc09..6044bcd 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,4 +13,11 @@ + + + + + + + diff --git a/src/Command/UserLoginCommand.php b/src/Command/UserLoginCommand.php new file mode 100644 index 0000000..dd6819e --- /dev/null +++ b/src/Command/UserLoginCommand.php @@ -0,0 +1,104 @@ +cliLoginHelper = $cliLoginHelper; + $this->cliLoginRedirectRoute = $cliLoginRedirectRoute; + $this->urlGenerator = $urlGenerator; + $this->userProvider = $userProvider; + + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDescription(self::$defaultDescription) + ->addArgument('username', InputArgument::REQUIRED, 'Username'); + } + + /** + * Executes the CLI login url generation. + * + * @param InputInterface $input + * @param OutputInterface $output + * + * @return int + * @throws CacheException + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = $input->getArgument('username'); + + if (!is_string($username)) { + $io->error('Username is not type string'); + return Command::FAILURE; + } + // Check if username is registered in User database + try { + $this->userProvider->loadUserByUsername($username); + } catch (UsernameNotFoundException $e) { + $io->error('User does not exist'); + return Command::FAILURE; + } + + // Create token via CliLoginHelper + $token = $this->cliLoginHelper->createToken($username); + + //Generate absolute url for login + $loginPage = $this->urlGenerator->generate($this->cliLoginRedirectRoute, [ + 'loginToken' => $token, + ], UrlGeneratorInterface::ABSOLUTE_URL); + + $io->writeln($loginPage); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index dda9f0a..d6e892c 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -2,37 +2,44 @@ namespace ItkDev\OpenIdConnectBundle\Controller; +use ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException; use ItkDev\OpenIdConnect\Security\OpenIdConfigurationProvider; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\SessionInterface; +/** + * Login Controller class. + */ class LoginController extends AbstractController { - /** - * @var array + * @var OpenIdConfigurationProvider */ - private $openIdProviderOptions; + private $provider; - public function __construct(array $openIdProviderOptions) + public function __construct(OpenIdConfigurationProvider $provider) { - $this->openIdProviderOptions = $openIdProviderOptions; + $this->provider = $provider; } /** + * Login method redirecting to authorizer. + * * @param SessionInterface $session - * @return Response + * @return RedirectResponse + * @throws ItkOpenIdConnectException */ - public function login(SessionInterface $session): Response + public function login(SessionInterface $session): RedirectResponse { - $provider = new OpenIdConfigurationProvider($this->openIdProviderOptions); + $nonce = $this->provider->generateNonce(); + $state = $this->provider->generateState(); - $authUrl = $provider->getAuthorizationUrl(); + // Save to session + $session->set('oauth2state', $state); + $session->set('oauth2nonce', $nonce); - // Set a oauth2state to avoid CSRF check it in authenticator - $session->set('oauth2state', $provider->getState()); + $authUrl = $this->provider->getAuthorizationUrl(['state' => $state, 'nonce' => $nonce]); return new RedirectResponse($authUrl); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 59622d8..7da4bd3 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -12,7 +12,7 @@ class Configuration implements ConfigurationInterface */ public function getConfigTreeBuilder(): TreeBuilder { - $treeBuilder = new TreeBuilder('itk_dev_open_id_connect'); + $treeBuilder = new TreeBuilder('itkdev_openid_connect'); // Specify which variables must be configured in itk_dev_openid_connect file // That is client_id, client_secret, discovery url and cache path @@ -20,14 +20,26 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->children() - ->arrayNode('open_id_provider_options') + ->arrayNode('cli_login_options') + ->isRequired() + ->children() + ->scalarNode('cli_redirect') + ->info('Return route for CLI login') + ->cannotBeEmpty()->end() + ->scalarNode('cache_pool') + ->info('Method for caching') + ->defaultValue('cache.app') + ->cannotBeEmpty()->end() + ->end() + ->end() + ->arrayNode('openid_provider_options') ->isRequired() ->children() ->scalarNode('configuration_url') ->info('URL to OpenId Discovery Document') ->validate() ->ifTrue( - function ($value) { + function (string $value) { return !filter_var($value, FILTER_VALIDATE_URL); } ) @@ -48,7 +60,7 @@ function ($value) { ->info('Callback URI registered at identity provider') ->validate() ->ifTrue( - function ($value) { + function (string $value) { return !filter_var($value, FILTER_VALIDATE_URL); } ) diff --git a/src/DependencyInjection/ItkDevOpenIdConnectExtension.php b/src/DependencyInjection/ItkDevOpenIdConnectExtension.php index 85eb301..7a94d70 100644 --- a/src/DependencyInjection/ItkDevOpenIdConnectExtension.php +++ b/src/DependencyInjection/ItkDevOpenIdConnectExtension.php @@ -2,20 +2,24 @@ namespace ItkDev\OpenIdConnectBundle\DependencyInjection; -use ItkDev\OpenIdConnectBundle\Controller\LoginController; +use Exception; +use ItkDev\OpenIdConnect\Security\OpenIdConfigurationProvider; +use ItkDev\OpenIdConnectBundle\Command\UserLoginCommand; +use ItkDev\OpenIdConnectBundle\Security\LoginTokenAuthenticator; +use ItkDev\OpenIdConnectBundle\Util\CliLoginHelper; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader\FileLoader; -use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; +use Symfony\Component\DependencyInjection\Reference; class ItkDevOpenIdConnectExtension extends Extension { /** * {@inheritdoc} + * @throws Exception */ - public function load(array $configs, ContainerBuilder $container) + public function load(array $configs, ContainerBuilder $container): void { $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.yaml'); @@ -23,22 +27,32 @@ public function load(array $configs, ContainerBuilder $container) $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $newConfig = [ - 'urlConfiguration' => $config['open_id_provider_options']['configuration_url'], - 'clientId' => $config['open_id_provider_options']['client_id'], - 'clientSecret' => $config['open_id_provider_options']['client_secret'], - 'cachePath' => $config['open_id_provider_options']['cache_path'], - 'redirectUri' => $config['open_id_provider_options']['callback_uri'], + $providerConfig = [ + 'openIDConnectMetadataUrl' => $config['openid_provider_options']['configuration_url'], + 'clientId' => $config['openid_provider_options']['client_id'], + 'clientSecret' => $config['openid_provider_options']['client_secret'], + 'cacheItemPool' => new Reference($config['openid_provider_options']['cache_path']), + 'redirectUri' => $config['openid_provider_options']['callback_uri'], ]; - $definition = $container->getDefinition(LoginController::class); - $definition->replaceArgument('$openIdProviderOptions', $newConfig); + $definition = $container->getDefinition(OpenIdConfigurationProvider::class); + $definition->replaceArgument('$options', $providerConfig); + $definition->replaceArgument('$collaborators', []); + + $definition = $container->getDefinition(CliLoginHelper::class); + $definition->replaceArgument('$cache', new Reference($config['cli_login_options']['cache_pool'])); + + $definition = $container->getDefinition(UserLoginCommand::class); + $definition->replaceArgument('$cliLoginRedirectRoute', $config['cli_login_options']['cli_redirect']); + + $definition = $container->getDefinition(LoginTokenAuthenticator::class); + $definition->replaceArgument('$cliLoginRedirectRoute', $config['cli_login_options']['cli_redirect']); } /** * {@inheritdoc} */ - public function getAlias() + public function getAlias(): string { return 'itkdev_openid_connect'; } diff --git a/src/Exception/CacheException.php b/src/Exception/CacheException.php new file mode 100644 index 0000000..a0d6acc --- /dev/null +++ b/src/Exception/CacheException.php @@ -0,0 +1,8 @@ +cliLoginHelper = $cliLoginHelper; + $this->cliLoginRedirectRoute = $cliLoginRedirectRoute; + $this->router = $router; + } + + public function supports(Request $request) + { + return $request->query->has('loginToken'); + } + + public function getCredentials(Request $request) + { + return $request->query->get('loginToken'); + } + + /** + * @throws UserDoesNotExistException + * @throws UsernameDoesNotExistException + * @throws CacheException + * @throws TokenNotFoundException + */ + public function getUser($credentials, UserProviderInterface $userProvider) + { + if (null === $credentials) { + // The token header was empty, authentication fails with HTTP Status + // Code 401 "Unauthorized" + return null; + } + + // Get username from CliHelperLogin + try { + $username = $this->cliLoginHelper->getUsername($credentials); + } catch (CacheException | TokenNotFoundException $e) { + throw $e; + } + + if (null === $username) { + throw new UsernameDoesNotExistException('null is not a valid Username.'); + } + + try { + $user = $userProvider->loadUserByUsername($username); + } catch (UsernameNotFoundException $e) { + throw new UserDoesNotExistException('Token correct but user not found'); + } + + return $user; + } + + public function checkCredentials($credentials, UserInterface $user) + { + // No credentials to check since loginToken login + return true; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception) + { + throw new AuthenticationException('Error occurred validating login token'); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey) + { + return new RedirectResponse($this->router->generate($this->cliLoginRedirectRoute)); + } + + public function start(Request $request, AuthenticationException $authException = null) + { + // Only way to start the CLI login flow should be via CMD and URL + throw new AuthenticationException('Authentication needed to access this URI/resource.'); + } + + public function supportsRememberMe() + { + return false; + } +} diff --git a/src/Security/OpenIdLoginAuthenticator.php b/src/Security/OpenIdLoginAuthenticator.php index 53ef97a..9229837 100644 --- a/src/Security/OpenIdLoginAuthenticator.php +++ b/src/Security/OpenIdLoginAuthenticator.php @@ -2,6 +2,9 @@ namespace ItkDev\OpenIdConnectBundle\Security; +use ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException; +use ItkDev\OpenIdConnect\Exception\ValidationException; +use ItkDev\OpenIdConnect\Security\OpenIdConfigurationProvider; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; @@ -10,6 +13,9 @@ use Symfony\Component\Security\Core\User\UserProviderInterface; use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; +/** + * Authenticator for OpenId Connect login. + */ abstract class OpenIdLoginAuthenticator extends AbstractGuardAuthenticator { /** @@ -17,10 +23,18 @@ abstract class OpenIdLoginAuthenticator extends AbstractGuardAuthenticator */ private $session; + /** + * @var OpenIdConfigurationProvider + */ + private $provider; + + private $leeway; - public function __construct(SessionInterface $session) + public function __construct(OpenIdConfigurationProvider $provider, SessionInterface $session, int $leeway = 0) { + $this->provider = $provider; $this->session = $session; + $this->leeway = $leeway; } public function supports(Request $request) @@ -29,27 +43,40 @@ public function supports(Request $request) return $request->query->has('state') && $request->query->has('id_token'); } + /** + * @throws ValidationException + */ public function getCredentials(Request $request) { - // Make sure state and oauth2sate are the same - if ($request->query->get('state') !== $this->session->get('oauth2state')) { - $this->session->remove('oauth2state'); - throw new \RuntimeException('Invalid state'); - } + // Make sure state and oauth2state are the same + $oauth2state = $this->session->get('oauth2state'); + $this->session->remove('oauth2state'); - // Retrieve id_token and decode it - // @see https://tools.ietf.org/html/rfc7519 - $idToken = $request->query->get('id_token'); - if (null === $idToken) { - throw new \RuntimeException('Id token not found'); + if ($request->query->get('state') !== $oauth2state) { + throw new ValidationException('Invalid state'); } - if (!is_string($idToken)) { - throw new \RuntimeException('Id token not type string'); + try { + $idToken = $request->query->get('id_token'); + + if (null === $idToken) { + throw new ValidationException('Id token not found.'); + } + + if (!is_string($idToken)) { + throw new ValidationException('Id token not type string'); + } + + $claims = $this->provider->validateIdToken($idToken, $this->session->get('oauth2nonce'), $this->leeway); + // Authentication successful + } catch (ItkOpenIdConnectException $exception) { + // Handle failed authentication + throw new ValidationException($exception->getMessage()); + } finally { + $this->session->remove('oauth2nonce'); } - [$jose, $payload, $signature] = array_map('base64_decode', explode('.', $idToken)); - return json_decode($payload, true); + return (array) $claims; } abstract public function getUser($credentials, UserProviderInterface $userProvider); diff --git a/src/Util/CliLoginHelper.php b/src/Util/CliLoginHelper.php new file mode 100644 index 0000000..d98c406 --- /dev/null +++ b/src/Util/CliLoginHelper.php @@ -0,0 +1,123 @@ +cache = $cache; + } + + /** + * Creates login token for CLI login. + * + * @param string $username + * @return string + * @throws CacheException + */ + public function createToken(string $username): string + { + $encodedUsername = $this->encodeKey($username); + $token = Uuid::v4()->toBase32(); + + // Add username => token to make sure no username has more than one token + try { + $revCacheItem = $this->cache->getItem($encodedUsername); + } catch (InvalidArgumentException $e) { + throw new CacheException($e->getMessage()); + } + + if ($revCacheItem->isHit()) { + return $revCacheItem->get(); + } + $revCacheItem->set($token); + $this->cache->save($revCacheItem); + + // Add token => username + try { + $cacheItem = $this->cache->getItem($token); + } catch (InvalidArgumentException $e) { + throw new CacheException($e->getMessage()); + } + + $cacheItem->set($encodedUsername); + $this->cache->save($cacheItem); + + return $token; + } + + + /** + * Gets username from login token. + * + * @param string $token + * @return string|null + * @throws TokenNotFoundException + * @throws CacheException + */ + public function getUsername(string $token): ?string + { + try { + $usernameItem = $this->cache->getItem($token); + } catch (InvalidArgumentException $e) { + throw new CacheException($e->getMessage()); + } + + if (!$usernameItem->isHit()) { + throw new TokenNotFoundException('Token does not exist'); + } + + $username = $usernameItem->get(); + + // Delete both entries from cache + try { + $this->cache->deleteItem($token); + $this->cache->deleteItem($username); + } catch (InvalidArgumentException $e) { + throw new CacheException($e->getMessage()); + } + + return $this->decodeKey($username); + } + + /** + * @param string $key + * @return string + */ + public function encodeKey(string $key): string + { + // Add namespace to key before encoding + return base64_encode(self::ITK_NAMESPACE . $key); + } + + /** + * @param string $encodedKey + * @return string + */ + public function decodeKey(string $encodedKey): string + { + $decodedKeyWithNamespace = base64_decode($encodedKey); + + // Remove namespace + $key = str_replace(self::ITK_NAMESPACE, '', $decodedKeyWithNamespace); + + return $key; + } +} diff --git a/tests/Controller/LoginControllerTest.php b/tests/Controller/LoginControllerTest.php new file mode 100644 index 0000000..757a2f5 --- /dev/null +++ b/tests/Controller/LoginControllerTest.php @@ -0,0 +1,52 @@ +createMock(OpenIdConfigurationProvider::class); + $mockProvider + ->expects($this->exactly(1)) + ->method('generateNonce') + ->willReturn('1234'); + $mockProvider + ->expects($this->exactly(1)) + ->method('generateState') + ->willReturn('abcd'); + $mockProvider + ->expects($this->exactly(1)) + ->method('getAuthorizationUrl') + ->with(['state' => 'abcd', 'nonce' => '1234']) + ->willReturn('https://test.com'); + + $this->loginController = new LoginController($mockProvider); + } + + public function testLogin(): void + { + $mockSession = $this->createMock(SessionInterface::class); + $mockSession + ->expects($this->exactly(2)) + ->method('set') + ->withConsecutive( + [$this->equalTo('oauth2state'), $this->equalTo('abcd')], + [$this->equalTo('oauth2nonce'), $this->equalTo('1234')] + ); + + $response = $this->loginController->login($mockSession); + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertSame('https://test.com', $response->getTargetUrl()); + } +} diff --git a/tests/ItkDevOpenIdConnectBundleTest.php b/tests/ItkDevOpenIdConnectBundleTest.php index afb6b0e..66210a5 100644 --- a/tests/ItkDevOpenIdConnectBundleTest.php +++ b/tests/ItkDevOpenIdConnectBundleTest.php @@ -2,7 +2,9 @@ namespace ItkDev\OpenIdConnectBundle\Tests; +use ItkDev\OpenIdConnect\Security\OpenIdConfigurationProvider; use ItkDev\OpenIdConnectBundle\Controller\LoginController; +use ItkDev\OpenIdConnectBundle\Security\OpenIdLoginAuthenticator; use PHPUnit\Framework\TestCase; /** @@ -16,20 +18,26 @@ class ItkDevOpenIdConnectBundleTest extends TestCase public function testServiceWiring() { $kernel = new ItkDevOpenIdConnectBundleTestingKernel([ - 'open_id_provider_options' => [ - 'configuration_url' => 'https://provider.com/openid-configuration', - 'client_id' => 'test_id', - 'client_secret' => 'test_secret', - 'cache_path' => 'test_path', - 'callback_uri' => 'https://app.com/callback_uri' - ] + __DIR__ . '/config/framework.yml', + __DIR__ . '/config/security.yml', + __DIR__ . '/config/itkdev_openid_connect.yml', ]); $kernel->boot(); $container = $kernel->getContainer(); + // LoginController service $this->assertTrue($container->has(LoginController::class)); $controller = $container->get(LoginController::class); $this->assertInstanceOf(LoginController::class, $controller); + + // OpenIdConfigurationProvider service + $this->assertTrue($container->has(OpenIdConfigurationProvider::class)); + + $provider = $container->get(OpenIdConfigurationProvider::class); + $this->assertInstanceOf(OpenIdConfigurationProvider::class, $provider); + + // OpenIdLoginAuthenticator service + $this->assertTrue($container->has(OpenIdLoginAuthenticator::class)); } } diff --git a/tests/ItkDevOpenIdConnectBundleTestingKernel.php b/tests/ItkDevOpenIdConnectBundleTestingKernel.php index fbeb691..3023394 100644 --- a/tests/ItkDevOpenIdConnectBundleTestingKernel.php +++ b/tests/ItkDevOpenIdConnectBundleTestingKernel.php @@ -7,24 +7,23 @@ namespace ItkDev\OpenIdConnectBundle\Tests; +use Exception; use ItkDev\OpenIdConnectBundle\ItkDevOpenIdConnectBundle; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Component\Config\Loader\LoaderInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpClient\CurlHttpClient; use Symfony\Component\HttpKernel\Kernel; -use Symfony\Contracts\HttpClient\HttpClientInterface; /** * Class ItkDevOpenIdConnectBundleTestingKernel. */ class ItkDevOpenIdConnectBundleTestingKernel extends Kernel { - private $config; + private $pathToConfigs; - public function __construct(array $config) + public function __construct(array $pathToConfigs) { - $this->config = $config; - + $this->pathToConfigs = $pathToConfigs; parent::__construct('test', true); } @@ -35,16 +34,21 @@ public function registerBundles() { return [ new ItkDevOpenIdConnectBundle(), + new SecurityBundle(), + new FrameworkBundle(), ]; } /** * {@inheritdoc} + * @throws Exception */ public function registerContainerConfiguration(LoaderInterface $loader) { - $loader->load(function (ContainerBuilder $containerBuilder) { - $containerBuilder->loadFromExtension('itkdev_openid_connect', $this->config); - }); + foreach ($this->pathToConfigs as $path) { + if (file_exists($path)) { + $loader->load($path); + } + } } } diff --git a/tests/Security/OpenIdLoginAuthenticatorTest.php b/tests/Security/OpenIdLoginAuthenticatorTest.php new file mode 100644 index 0000000..edfa437 --- /dev/null +++ b/tests/Security/OpenIdLoginAuthenticatorTest.php @@ -0,0 +1,60 @@ +createMock(OpenIdConfigurationProvider::class); + $mockSession = $this->createMock(SessionInterface::class); + + $this->authenticator = new TestAuthenticator($mockProvider, $mockSession); + } + + public function testSupports(): void + { + $mockRequest = $this->createMock(Request::class); + $mockRequest->query = new InputBag(); + + $this->assertFalse($this->authenticator->supports($mockRequest)); + + $mockRequest->query->set('state', 'abcd'); + $this->assertFalse($this->authenticator->supports($mockRequest)); + + $mockRequest->query->set('id_token', 'xyz'); + $this->assertTrue($this->authenticator->supports($mockRequest)); + } + + public function testCheckCredentials(): void + { + $stubUser = $this->createStub(UserInterface::class); + + $this->assertTrue($this->authenticator->checkCredentials(null, $stubUser)); + } + + public function testOnAuthenticationFailure(): void + { + $this->expectException(AuthenticationException::class); + + $stubRequest = $this->createStub(Request::class); + $exception = new AuthenticationException(); + + $this->authenticator->onAuthenticationFailure($stubRequest, $exception); + } + + public function testSupportsRememberMe(): void + { + $this->assertFalse($this->authenticator->supportsRememberMe()); + } +} diff --git a/tests/TestAuthenticator.php b/tests/Security/TestAuthenticator.php similarity index 94% rename from tests/TestAuthenticator.php rename to tests/Security/TestAuthenticator.php index a870c18..5d1373e 100644 --- a/tests/TestAuthenticator.php +++ b/tests/Security/TestAuthenticator.php @@ -1,6 +1,6 @@ toBase32(); + + $encodedUsername = $cliHelper->encodeKey($randomUsername); + $decodedUsername = $cliHelper->decodeKey($encodedUsername); + + $this->assertEquals($randomUsername, $decodedUsername); + } + + public function testThrowExceptionIfTokenDoesNotExist() + { + $this->expectException(ItkOpenIdConnectBundleException::class); + + $cache = new ArrayAdapter(); + + $cliHelper = new CliLoginHelper($cache); + + $username = $cliHelper->getUsername('random_gibberish_token'); + } + + public function testReuseSetTokenRatherThanRemake() + { + $cache = new ArrayAdapter(); + + $cliHelper = new CliLoginHelper($cache); + + $testUser = 'test_user'; + $token = $cliHelper->createToken($testUser); + $token2 = $cliHelper->createToken($testUser); + + $this->assertEquals($token, $token2); + } + + public function testTokenIsRemovedAfterUse() + { + $cache = new ArrayAdapter(); + + $cliHelper = new CliLoginHelper($cache); + + $testUser = 'test_user'; + $token = $cliHelper->createToken($testUser); + + $username = $cliHelper->getUsername($token); + + $this->expectException(ItkOpenIdConnectBundleException::class); + + $username = $cliHelper->getUsername($token); + } + + public function testCreateTokenAndGetUsername() + { + $cache = new ArrayAdapter(); + + $cliHelper = new CliLoginHelper($cache); + + $testUser = 'test_user'; + $token = $cliHelper->createToken($testUser); + + $username = $cliHelper->getUsername($token); + + $this->assertEquals($testUser, $username); + } +} diff --git a/tests/config/framework.yml b/tests/config/framework.yml new file mode 100644 index 0000000..1727dd7 --- /dev/null +++ b/tests/config/framework.yml @@ -0,0 +1,22 @@ +framework: + cache: + pools: + cache.array: + default_lifetime: 0 + adapters: + - cache.adapter.array + test: true + session: + storage_id: session.storage.mock_file + form: false + csrf_protection: false + validation: + enabled: false + esi: + enabled: false + workflows: + enabled: false + translator: + enabled: false + router: + resource: ~ diff --git a/tests/config/itkdev_openid_connect.yml b/tests/config/itkdev_openid_connect.yml new file mode 100644 index 0000000..af17822 --- /dev/null +++ b/tests/config/itkdev_openid_connect.yml @@ -0,0 +1,10 @@ +itkdev_openid_connect: + cli_login_options: + cli_redirect: 'cli_redirect_test' + cache_pool: 'cache.array' + openid_provider_options: + configuration_url: 'https://provider.com/openid-configuration' + client_id: 'test_id' + client_secret: 'test_secret' + cache_path: 'cache.array' + callback_uri: 'https://app.com/callback_uri' \ No newline at end of file diff --git a/tests/config/security.yml b/tests/config/security.yml new file mode 100644 index 0000000..da73cb5 --- /dev/null +++ b/tests/config/security.yml @@ -0,0 +1,13 @@ +security: + providers: + test_users: + memory: + users: + admin: { password: 'test', roles: ['ROLE_ADMIN'] } + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + main: + anonymous: true + lazy: true \ No newline at end of file