From a1be5dfe7d5536973f2c6c6ac5b971ee0c2946c6 Mon Sep 17 00:00:00 2001 From: NACorp Date: Sat, 7 Oct 2023 14:46:43 +0200 Subject: [PATCH] [Security] add CAS 2.0 AccessToken handler --- .../AccessToken/CasTokenHandlerFactory.php | 62 +++++++++ .../Bundle/SecurityBundle/SecurityBundle.php | 2 + .../Factory/AccessTokenFactoryTest.php | 23 ++++ .../Tests/Functional/AccessTokenTest.php | 25 ++++ .../Functional/app/AccessToken/config_cas.yml | 41 ++++++ .../Http/AccessToken/Cas/Cas2Handler.php | 85 ++++++++++++ .../Component/Security/Http/CHANGELOG.md | 1 + .../Tests/AccessToken/Cas/Cas2HandlerTest.php | 123 ++++++++++++++++++ .../Component/Security/Http/composer.json | 1 + 9 files changed, 363 insertions(+) create mode 100644 src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/CasTokenHandlerFactory.php create mode 100644 src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_cas.yml create mode 100644 src/Symfony/Component/Security/Http/AccessToken/Cas/Cas2Handler.php create mode 100644 src/Symfony/Component/Security/Http/Tests/AccessToken/Cas/Cas2HandlerTest.php diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/CasTokenHandlerFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/CasTokenHandlerFactory.php new file mode 100644 index 0000000000000..a0c2ca047bc40 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/AccessToken/CasTokenHandlerFactory.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken; + +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\DependencyInjection\ChildDefinition; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Http\AccessToken\Cas\Cas2Handler; + +class CasTokenHandlerFactory implements TokenHandlerFactoryInterface +{ + public function create(ContainerBuilder $container, string $id, array|string $config): void + { + $container->setDefinition($id, new ChildDefinition('security.access_token_handler.cas')); + + $container + ->register('security.access_token_handler.cas', Cas2Handler::class) + ->setArguments([ + new Reference('request_stack'), + $config['validation_url'], + $config['prefix'], + $config['http_client'] ? new Reference($config['http_client']) : null, + ]); + } + + public function getKey(): string + { + return 'cas'; + } + + public function addConfiguration(NodeBuilder $node): void + { + $node + ->arrayNode($this->getKey()) + ->fixXmlConfig($this->getKey()) + ->children() + ->scalarNode('validation_url') + ->info('CAS server validation URL') + ->isRequired() + ->end() + ->scalarNode('prefix') + ->info('CAS prefix') + ->defaultValue('cas') + ->end() + ->scalarNode('http_client') + ->info('HTTP Client service') + ->defaultNull() + ->end() + ->end() + ->end(); + } +} diff --git a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php index b2e81a7f4b92b..0527adb9f66be 100644 --- a/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php +++ b/src/Symfony/Bundle/SecurityBundle/SecurityBundle.php @@ -23,6 +23,7 @@ use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\RegisterTokenUsageTrackingPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\ReplaceDecoratedRememberMeHandlerPass; use Symfony\Bundle\SecurityBundle\DependencyInjection\Compiler\SortFirewallListenersPass; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; @@ -78,6 +79,7 @@ public function build(ContainerBuilder $container): void new ServiceTokenHandlerFactory(), new OidcUserInfoTokenHandlerFactory(), new OidcTokenHandlerFactory(), + new CasTokenHandlerFactory(), ])); $extension->addUserProviderFactory(new InMemoryFactory()); diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php index e1f55817eee68..f3e12e8190ced 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/DependencyInjection/Security/Factory/AccessTokenFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\Bundle\SecurityBundle\Tests\DependencyInjection\Security\Factory; use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\CasTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\OidcUserInfoTokenHandlerFactory; use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\AccessToken\ServiceTokenHandlerFactory; @@ -76,6 +77,27 @@ public function testIdTokenHandlerConfiguration() $this->assertTrue($container->hasDefinition('security.access_token_handler.firewall1')); } + public function testCasTokenHandlerConfiguration() + { + $container = new ContainerBuilder(); + $config = [ + 'token_handler' => ['cas' => ['validation_url' => 'https://www.example.com/cas/validate']], + ]; + + $factory = new AccessTokenFactory($this->createTokenHandlerFactories()); + $finalizedConfig = $this->processConfig($config, $factory); + + $factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider'); + + $this->assertTrue($container->hasDefinition('security.access_token_handler.cas')); + + $arguments = $container->getDefinition('security.access_token_handler.cas')->getArguments(); + $this->assertSame((string) $arguments[0], 'request_stack'); + $this->assertSame($arguments[1], 'https://www.example.com/cas/validate'); + $this->assertSame($arguments[2], 'cas'); + $this->assertNull($arguments[3]); + } + public function testOidcUserInfoTokenHandlerConfigurationWithExistingClient() { $container = new ContainerBuilder(); @@ -218,6 +240,7 @@ private function createTokenHandlerFactories(): array new ServiceTokenHandlerFactory(), new OidcUserInfoTokenHandlerFactory(), new OidcTokenHandlerFactory(), + new CasTokenHandlerFactory(), ]; } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php index 6cc2b1f0fb150..00c11bf40a211 100644 --- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/AccessTokenTest.php @@ -17,6 +17,8 @@ use Jose\Component\Signature\JWSBuilder; use Jose\Component\Signature\Serializer\CompactSerializer; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; use Symfony\Component\HttpFoundation\Response; class AccessTokenTest extends AbstractWebTestCase @@ -383,4 +385,27 @@ public function testOidcSuccess() $this->assertSame(200, $response->getStatusCode()); $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); } + + public function testCasSuccess() + { + $casResponse = new MockResponse(<< + + dunglas + PGTIOU-84678-8a9d + + + BODY + ); + + $client = $this->createClient(['test_case' => 'AccessToken', 'root_config' => 'config_cas.yml']); + $client->getContainer()->set('Symfony\Contracts\HttpClient\HttpClientInterface', new MockHttpClient($casResponse)); + + $client->request('GET', '/foo?ticket=PGTIOU-84678-8a9d', [], [], []); + $response = $client->getResponse(); + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['message' => 'Welcome @dunglas!'], json_decode($response->getContent(), true)); + } } diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_cas.yml b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_cas.yml new file mode 100644 index 0000000000000..2cd2abc566c05 --- /dev/null +++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/app/AccessToken/config_cas.yml @@ -0,0 +1,41 @@ +imports: + - { resource: ./../config/framework.yml } + +framework: + http_method_override: false + serializer: ~ + +security: + password_hashers: + Symfony\Component\Security\Core\User\InMemoryUser: plaintext + + providers: + in_memory: + memory: + users: + dunglas: { password: foo, roles: [ROLE_USER] } + + firewalls: + main: + pattern: ^/ + access_token: + token_handler: + cas: + validation_url: 'https://www.example.com/cas/serviceValidate' + http_client: 'Symfony\Contracts\HttpClient\HttpClientInterface' + token_extractors: + - security.access_token_extractor.cas + + access_control: + - { path: ^/foo, roles: ROLE_USER } + +services: + _defaults: + public: true + + security.access_token_extractor.cas: + class: Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor + arguments: + - 'ticket' + + Symfony\Contracts\HttpClient\HttpClientInterface: ~ diff --git a/src/Symfony/Component/Security/Http/AccessToken/Cas/Cas2Handler.php b/src/Symfony/Component/Security/Http/AccessToken/Cas/Cas2Handler.php new file mode 100644 index 0000000000000..d7e6df9bcf127 --- /dev/null +++ b/src/Symfony/Component/Security/Http/AccessToken/Cas/Cas2Handler.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\AccessToken\Cas; + +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @see https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-V2-Specification.html + * + * @author Nicolas Attard + */ +final class Cas2Handler implements AccessTokenHandlerInterface +{ + public function __construct( + private readonly RequestStack $requestStack, + private readonly string $validationUrl, + private readonly string $prefix = 'cas', + private ?HttpClientInterface $client = null, + ) { + if (null === $client) { + if (!class_exists(HttpClient::class)) { + throw new \LogicException(sprintf('You cannot use "%s" as the HttpClient component is not installed. Try running "composer require symfony/http-client".', __CLASS__)); + } + + $this->client = HttpClient::create(); + } + } + + /** + * @throws AuthenticationException + */ + public function getUserBadgeFrom(string $accessToken): UserBadge + { + $response = $this->client->request('GET', $this->getValidationUrl($accessToken)); + + $xml = new \SimpleXMLElement($response->getContent(), 0, false, $this->prefix, true); + + if (isset($xml->authenticationSuccess)) { + return new UserBadge((string) $xml->authenticationSuccess->user); + } + + if (isset($xml->authenticationFailure)) { + throw new AuthenticationException('CAS Authentication Failure: '.trim((string) $xml->authenticationFailure)); + } + + throw new AuthenticationException('Invalid CAS response.'); + } + + private function getValidationUrl(string $accessToken): string + { + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new \LogicException('Request should exist so it can be processed for error.'); + } + + $query = $request->query->all(); + + if (!isset($query['ticket'])) { + throw new AuthenticationException('No ticket found in request.'); + } + unset($query['ticket']); + $queryString = empty($query) ? '' : '?'.http_build_query($query); + + return sprintf('%s?ticket=%s&service=%s', + $this->validationUrl, + urlencode($accessToken), + urlencode($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$queryString) + ); + } +} diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 58f227f37383d..24cd13213ddfc 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -18,6 +18,7 @@ CHANGELOG * `UserValueResolver` no longer implements `ArgumentValueResolverInterface` * Deprecate calling the constructor of `DefaultLoginRateLimiter` with an empty secret + * Add CAS 2.0 access token handler 6.3 --- diff --git a/src/Symfony/Component/Security/Http/Tests/AccessToken/Cas/Cas2HandlerTest.php b/src/Symfony/Component/Security/Http/Tests/AccessToken/Cas/Cas2HandlerTest.php new file mode 100644 index 0000000000000..728b7ca529a79 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/AccessToken/Cas/Cas2HandlerTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\AccessToken\Cas; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\Security\Core\Exception\AuthenticationException; +use Symfony\Component\Security\Http\AccessToken\Cas\Cas2Handler; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; + +final class Cas2HandlerTest extends TestCase +{ + public function testWithValidTicket() + { + $response = new MockResponse(<< + + lobster + PGTIOU-84678-8a9d + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'PGTIOU-84678-8a9d'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $userbadge = $cas2Handler->getUserBadgeFrom('PGTIOU-84678-8a9d'); + $this->assertEquals(new UserBadge('lobster'), $userbadge); + } + + public function testWithInvalidTicket() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('CAS Authentication Failure: Ticket ST-1856339 not recognized'); + + $response = new MockResponse(<< + + Ticket ST-1856339 not recognized + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'ST-1856339'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithInvalidCasResponse() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid CAS response.'); + + $response = new MockResponse(<< + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'ST-1856339'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithoutTicket() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No ticket found in request.'); + + $httpClient = new MockHttpClient(); + $requestStack = new RequestStack(); + $requestStack->push(new Request()); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', client: $httpClient); + $cas2Handler->getUserBadgeFrom('should-not-work'); + } + + public function testWithInvalidPrefix() + { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid CAS response.'); + + $response = new MockResponse(<< + + lobster + PGTIOU-84678-8a9d + + + BODY + ); + + $httpClient = new MockHttpClient([$response]); + $requestStack = new RequestStack(); + $requestStack->push(new Request(['ticket' => 'PGTIOU-84678-8a9d'])); + + $cas2Handler = new Cas2Handler(requestStack: $requestStack, validationUrl: 'https://www.example.com/cas', prefix: 'invalid-one', client: $httpClient); + $username = $cas2Handler->getUserBadgeFrom('PGTIOU-84678-8a9d'); + $this->assertEquals('lobster', $username); + } +} diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 3f96dc20c137b..4bf955b99358c 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -28,6 +28,7 @@ "symfony/cache": "^6.4|^7.0", "symfony/clock": "^6.4|^7.0", "symfony/expression-language": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", "symfony/http-client-contracts": "^3.0", "symfony/rate-limiter": "^6.4|^7.0", "symfony/routing": "^6.4|^7.0",