From 60d85b21dca16e3a74cbd32d2630c6b75a13ab98 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Thu, 2 Aug 2018 16:39:21 +0200 Subject: [PATCH 1/3] POC introspection endpoint RFC 7662 --- Controller/IntrospectionController.php | 128 ++++++++++++++++++ .../FOSOAuthServerExtension.php | 7 + Resources/config/introspection.xml | 15 ++ Resources/config/routing/introspection.xml | 12 ++ 4 files changed, 162 insertions(+) create mode 100644 Controller/IntrospectionController.php create mode 100644 Resources/config/introspection.xml create mode 100644 Resources/config/routing/introspection.xml diff --git a/Controller/IntrospectionController.php b/Controller/IntrospectionController.php new file mode 100644 index 00000000..2c26f681 --- /dev/null +++ b/Controller/IntrospectionController.php @@ -0,0 +1,128 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Controller; + +use FOS\OAuthServerBundle\Model\AccessTokenInterface; +use FOS\OAuthServerBundle\Model\RefreshTokenInterface; +use FOS\OAuthServerBundle\Model\TokenInterface; +use FOS\OAuthServerBundle\Model\TokenManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; + +class IntrospectionController +{ + /** + * @var TokenStorageInterface + */ + private $tokenStorage; + + /** + * @var TokenManagerInterface + */ + private $accessTokenManager; + + /** + * @var TokenManagerInterface + */ + private $refreshTokenManager; + + public function __construct( + TokenStorageInterface $tokenStorage, + TokenManagerInterface $accessTokenManager, + TokenManagerInterface $refreshTokenManager + ) { + $this->tokenStorage = $tokenStorage; + $this->accessTokenManager = $accessTokenManager; + $this->refreshTokenManager = $refreshTokenManager; + } + + public function introspectAction(Request $request): JsonResponse + { + // $clientToken = $this->tokenStorage->getToken(); → use in security + + // TODO security for this endpoint. Probably in the README documentation + + $token = $this->getToken($request); + + $isActive = $token && !$token->hasExpired(); + + if (!$isActive) { + return new JsonResponse([ + 'active' => false, + ]); + } + + return new JsonResponse([ + 'active' => true, + 'scope' => $token->getScope(), + 'client_id' => $token->getClientId(), + 'username' => $this->getUsername($token), + 'token_type' => $this->getTokenType($token), + 'exp' => $token->getExpiresAt(), + ]); + } + + /** + * @return TokenInterface|null + */ + private function getToken(Request $request) + { + $tokenTypeHint = $request->request->get('token_type_hint'); // TODO move in a form type ? can be `access_token`, `refresh_token` See https://tools.ietf.org/html/rfc7009#section-4.1.2 + $tokenString = $request->request->get('token'); // TODO move in a form type ? + + $tokenManagerList = []; + if (!$tokenTypeHint || 'access_token' === $tokenTypeHint) { + $tokenManagerList[] = $this->accessTokenManager; + } + if (!$tokenTypeHint || 'refresh_token' === $tokenTypeHint) { + $tokenManagerList[] = $this->refreshTokenManager; + } + + foreach ($tokenManagerList as $tokenManager) { + $token = $tokenManager->findTokenByToken($tokenString); + + if ($token) { + return $token; + } + } + } + + /** + * @return string|null + */ + private function getTokenType(TokenInterface $token) + { + if ($token instanceof AccessTokenInterface) { + return 'access_token'; + } elseif ($token instanceof RefreshTokenInterface) { + return 'refresh_token'; + } + + return null; + } + + /** + * @return string|null + */ + private function getUsername(TokenInterface $token) + { + $user = $token->getUser(); + if (!$user) { + return null; + } + + return $user->getUserName(); + } +} diff --git a/DependencyInjection/FOSOAuthServerExtension.php b/DependencyInjection/FOSOAuthServerExtension.php index 92773af5..bd79b267 100644 --- a/DependencyInjection/FOSOAuthServerExtension.php +++ b/DependencyInjection/FOSOAuthServerExtension.php @@ -99,6 +99,8 @@ public function load(array $configs, ContainerBuilder $container) $authorizeFormDefinition = $container->getDefinition('fos_oauth_server.authorize.form'); $authorizeFormDefinition->setFactory([new Reference('form.factory'), 'createNamed']); } + + $this->loadIntrospection($loader); } /** @@ -140,6 +142,11 @@ protected function remapParametersNamespaces(array $config, ContainerBuilder $co } } + protected function loadIntrospection(XmlFileLoader $loader) + { + $loader->load('introspection.xml'); + } + protected function loadAuthorize(array $config, ContainerBuilder $container, XmlFileLoader $loader) { $loader->load('authorize.xml'); diff --git a/Resources/config/introspection.xml b/Resources/config/introspection.xml new file mode 100644 index 00000000..7bc972b1 --- /dev/null +++ b/Resources/config/introspection.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/Resources/config/routing/introspection.xml b/Resources/config/routing/introspection.xml new file mode 100644 index 00000000..a36889ff --- /dev/null +++ b/Resources/config/routing/introspection.xml @@ -0,0 +1,12 @@ + + + + + + fos_oauth_server.controller.introspection:introspectAction + + + + From 11594358078775245f94829b5ed596d60f4be8c7 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Tue, 21 Aug 2018 17:25:27 +0200 Subject: [PATCH 2/3] add list of allow client for introspection endpoint --- Controller/IntrospectionController.php | 60 +++++++++++++++++-- DependencyInjection/Configuration.php | 36 +++++++++++ .../FOSOAuthServerExtension.php | 7 ++- Form/Model/Introspect.php | 30 ++++++++++ Form/Type/IntrospectionFormType.php | 54 +++++++++++++++++ Resources/config/introspection.xml | 2 + 6 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 Form/Model/Introspect.php create mode 100644 Form/Type/IntrospectionFormType.php diff --git a/Controller/IntrospectionController.php b/Controller/IntrospectionController.php index 2c26f681..3e6a1635 100644 --- a/Controller/IntrospectionController.php +++ b/Controller/IntrospectionController.php @@ -13,13 +13,19 @@ namespace FOS\OAuthServerBundle\Controller; +use FOS\OAuthServerBundle\Form\Model\Introspect; +use FOS\OAuthServerBundle\Form\Type\IntrospectionFormType; use FOS\OAuthServerBundle\Model\AccessTokenInterface; use FOS\OAuthServerBundle\Model\RefreshTokenInterface; use FOS\OAuthServerBundle\Model\TokenInterface; use FOS\OAuthServerBundle\Model\TokenManagerInterface; +use FOS\OAuthServerBundle\Security\Authentication\Token\OAuthToken; +use Symfony\Component\Form\FormFactory; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; class IntrospectionController { @@ -38,21 +44,47 @@ class IntrospectionController */ private $refreshTokenManager; + /** + * @var FormFactory + */ + private $formFactory; + + /** + * @var array + */ + private $allowedIntrospectionClients; + public function __construct( TokenStorageInterface $tokenStorage, TokenManagerInterface $accessTokenManager, - TokenManagerInterface $refreshTokenManager + TokenManagerInterface $refreshTokenManager, + FormFactory $formFactory, + array $allowedIntrospectionClients ) { $this->tokenStorage = $tokenStorage; $this->accessTokenManager = $accessTokenManager; $this->refreshTokenManager = $refreshTokenManager; + $this->formFactory = $formFactory; + $this->allowedIntrospectionClients = $allowedIntrospectionClients; } public function introspectAction(Request $request): JsonResponse { - // $clientToken = $this->tokenStorage->getToken(); → use in security + $clientToken = $this->tokenStorage->getToken(); // → use in security + + if (!$clientToken instanceof OAuthToken) { + throw new AccessDeniedException('The introspect endpoint must be behind a secure firewall.'); + } - // TODO security for this endpoint. Probably in the README documentation + $callerToken = $this->accessTokenManager->findTokenByToken($clientToken->getToken()); + + if (!$callerToken) { + throw new AccessDeniedException('The access token must have a valid token.'); + } + + if (!in_array($callerToken->getClientId(), $this->allowedIntrospectionClients)) { + throw new AccessDeniedException('This access token is not autorised to do introspection.'); + } $token = $this->getToken($request); @@ -79,8 +111,9 @@ public function introspectAction(Request $request): JsonResponse */ private function getToken(Request $request) { - $tokenTypeHint = $request->request->get('token_type_hint'); // TODO move in a form type ? can be `access_token`, `refresh_token` See https://tools.ietf.org/html/rfc7009#section-4.1.2 - $tokenString = $request->request->get('token'); // TODO move in a form type ? + $formData = $this->processIntrospectionForm($request); + $tokenString = $formData->token; + $tokenTypeHint = $formData->token_type_hint; $tokenManagerList = []; if (!$tokenTypeHint || 'access_token' === $tokenTypeHint) { @@ -125,4 +158,21 @@ private function getUsername(TokenInterface $token) return $user->getUserName(); } + + private function processIntrospectionForm(Request $request): Introspect + { + $formData = new Introspect(); + $form = $this->formFactory->create(IntrospectionFormType::class, $formData); + $form->handleRequest($request); + + if (!$form->isSubmitted() || !$form->isValid()) { + $errors = $form->getErrors(); + if (count($errors) > 0) { + throw new BadRequestHttpException((string) $errors); + } else { + throw new BadRequestHttpException('Introspection endpoint needs to have at least a "token" form parameter'); + } + } + return $form->getData(); + } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index fb13f78c..8c06c179 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -79,6 +79,8 @@ public function getConfigTreeBuilder() $this->addAuthorizeSection($rootNode); $this->addServiceSection($rootNode); + $this->addTemplateSection($rootNode); + $this->addIntrospectionSection($rootNode); return $treeBuilder; } @@ -134,4 +136,38 @@ private function addServiceSection(ArrayNodeDefinition $node) ->end() ; } + + private function addTemplateSection(ArrayNodeDefinition $node) + { + $node + ->children() + ->arrayNode('template') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('engine')->defaultValue('twig')->end() + ->end() + ->end() + ->end() + ; + } + + private function addIntrospectionSection(ArrayNodeDefinition $node) + { + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('introspection') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('allowed_clients') + ->useAttributeAsKey('key') + ->treatNullLike([]) + ->prototype('variable')->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ; + } } diff --git a/DependencyInjection/FOSOAuthServerExtension.php b/DependencyInjection/FOSOAuthServerExtension.php index bd79b267..e915fb4f 100644 --- a/DependencyInjection/FOSOAuthServerExtension.php +++ b/DependencyInjection/FOSOAuthServerExtension.php @@ -100,7 +100,7 @@ public function load(array $configs, ContainerBuilder $container) $authorizeFormDefinition->setFactory([new Reference('form.factory'), 'createNamed']); } - $this->loadIntrospection($loader); + $this->loadIntrospection($config, $container, $loader); } /** @@ -142,9 +142,12 @@ protected function remapParametersNamespaces(array $config, ContainerBuilder $co } } - protected function loadIntrospection(XmlFileLoader $loader) + protected function loadIntrospection(array $config, ContainerBuilder $container, XmlFileLoader $loader) { $loader->load('introspection.xml'); + + $allowedClients = $config['introspection']['allowed_clients']; + $container->setParameter('fos_oauth_server.introspection.allowed_clients', $allowedClients); } protected function loadAuthorize(array $config, ContainerBuilder $container, XmlFileLoader $loader) diff --git a/Form/Model/Introspect.php b/Form/Model/Introspect.php new file mode 100644 index 00000000..b419ed6a --- /dev/null +++ b/Form/Model/Introspect.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Form\Model; + +use Symfony\Component\Validator\Constraints as Assert; + +class Introspect +{ + /** + * @var string + * @Assert\NotBlank() + */ + public $token; + + /** + * @var string + */ + public $token_type_hint; +} diff --git a/Form/Type/IntrospectionFormType.php b/Form/Type/IntrospectionFormType.php new file mode 100644 index 00000000..b169cf5a --- /dev/null +++ b/Form/Type/IntrospectionFormType.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\OAuthServerBundle\Form\Type; + +use FOS\OAuthServerBundle\Form\Model\Introspect; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class IntrospectionFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder->add('token', HiddenType::class); + $builder->add('token_type_hint', ChoiceType::class, [ // can be `access_token`, `refresh_token` See https://tools.ietf.org/html/rfc7009#section-4.1.2 + 'choices' => [ + 'access_token' => 'access_token', + 'refresh_token' => 'refresh_token', + ] + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Introspect::class, + 'csrf_protection' => false, + ]); + } + + /** + * @return string + */ + public function getBlockPrefix() + { + return ''; + } +} diff --git a/Resources/config/introspection.xml b/Resources/config/introspection.xml index 7bc972b1..da9d1cd6 100644 --- a/Resources/config/introspection.xml +++ b/Resources/config/introspection.xml @@ -9,6 +9,8 @@ + + %fos_oauth_server.introspection.allowed_clients% From 9788ea6b9fb60ce104a7f2d1218e2fd11c07f040 Mon Sep 17 00:00:00 2001 From: Julien Deniau Date: Wed, 22 Aug 2018 09:00:42 +0200 Subject: [PATCH 3/3] add documentation --- Controller/IntrospectionController.php | 38 ++++++++----- Resources/doc/index.md | 2 + Resources/doc/introspection_endpoint.md | 76 +++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 Resources/doc/introspection_endpoint.md diff --git a/Controller/IntrospectionController.php b/Controller/IntrospectionController.php index 3e6a1635..d262d1d4 100644 --- a/Controller/IntrospectionController.php +++ b/Controller/IntrospectionController.php @@ -70,21 +70,7 @@ public function __construct( public function introspectAction(Request $request): JsonResponse { - $clientToken = $this->tokenStorage->getToken(); // → use in security - - if (!$clientToken instanceof OAuthToken) { - throw new AccessDeniedException('The introspect endpoint must be behind a secure firewall.'); - } - - $callerToken = $this->accessTokenManager->findTokenByToken($clientToken->getToken()); - - if (!$callerToken) { - throw new AccessDeniedException('The access token must have a valid token.'); - } - - if (!in_array($callerToken->getClientId(), $this->allowedIntrospectionClients)) { - throw new AccessDeniedException('This access token is not autorised to do introspection.'); - } + $this->denyAccessIfNotAuthorizedClient(); $token = $this->getToken($request); @@ -106,6 +92,28 @@ public function introspectAction(Request $request): JsonResponse ]); } + /** + * Check that the caller has a token generated by an allowed client + */ + private function denyAccessIfNotAuthorizedClient(): void + { + $clientToken = $this->tokenStorage->getToken(); + + if (!$clientToken instanceof OAuthToken) { + throw new AccessDeniedException('The introspect endpoint must be behind a secure firewall.'); + } + + $callerToken = $this->accessTokenManager->findTokenByToken($clientToken->getToken()); + + if (!$callerToken) { + throw new AccessDeniedException('The access token must have a valid token.'); + } + + if (!in_array($callerToken->getClientId(), $this->allowedIntrospectionClients)) { + throw new AccessDeniedException('This access token is not autorised to do introspection.'); + } + } + /** * @return TokenInterface|null */ diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 65ac21c4..304a87a8 100644 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -624,3 +624,5 @@ The `authorize` endpoint is at `/oauth/v2/auth` by default (see `Resources/confi [Adding Grant Extensions](adding_grant_extensions.md) [Custom DB Driver](custom_db_driver.md) + +[Introspection endpoint](introspection_endpoint.md) diff --git a/Resources/doc/introspection_endpoint.md b/Resources/doc/introspection_endpoint.md new file mode 100644 index 00000000..8cad3a80 --- /dev/null +++ b/Resources/doc/introspection_endpoint.md @@ -0,0 +1,76 @@ +Introspection endpoint +========================================= + +The OAuth 2.0 Token Introspection extension defines a protocol that returns information about an access token, intended to be used by resource servers or other internal servers. + +For more information, see [this explaination](https://www.oauth.com/oauth2-servers/token-introspection-endpoint/) or [the RFC 7662](https://tools.ietf.org/html/rfc7662). + +## Configuration + +Import the routing.yml configuration file in `app/config/routing.yml`: + +```yaml +# app/config/routing.yml + +fos_oauth_server_introspection: + resource: "@FOSOAuthServerBundle/Resources/config/routing/introspection.xml" +``` + +Add FOSOAuthServerBundle settings in `app/config/config.yml`: + +```yaml +fos_oauth_server: + introspection: + allowed_clients: + - 1_wUS0gjHdHyC2qeBL3u7RuIrIXClt6irL # an oauth client used only for token introspection. +``` + +The allowed clients MUST be clients as defined [here](index.md#creating-a-client) and SHOULD be used only for token introspection (otherwise a endpoint client might call the introspection endpoint with its valid token). + + +The introspection endpoint must be behind a firewall defined like this: + +```yaml +# app/config/security.yml +security: + firewalls: + oauth_introspect: + host: "%domain.oauth2%" + pattern: ^/oauth/v2/introspect + fos_oauth: true + stateless: true + anonymous: false +``` + +### Usage + +Then you can call the introspection endpoint like this: + +``` +POST /token_info +Host: authorization-server.com +Authorization: Bearer KvIu5v90GqgDctofFXP8npjC5DzMUkci + +token=SON4N82oVuRFykExk0iGTghihgOcI6bm +``` + +The JSON response will look like this if the token is inactive: + +```json +{ + "active": false +} +``` + +If the token is active, the response will look like this: + +```json +{ + "active": true, + "scope": "scope1 scope2", + "client_id": "2_HC1KF0UrawHx05AxgNEeKJF10giBUOHZ", + "username": "foobar", + "token_type": "access_token", + "exp": 1534921182 +} +```