commit b303c94643d7c3cf54caebff64c268d94d8121bb Author: Abdellah ELMAKHROUBI Date: Thu Nov 19 15:26:54 2020 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0472cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea +/vendor/ +composer.lock + diff --git a/ABELkeycloakBearerOnlyAdapterBundle.php b/ABELkeycloakBearerOnlyAdapterBundle.php new file mode 100644 index 0000000..61236cc --- /dev/null +++ b/ABELkeycloakBearerOnlyAdapterBundle.php @@ -0,0 +1,19 @@ +extension) { + $this->extension = new ABELkeycloakBearerOnlyAdapterExtension(); + } + return $this->extension; + } +} diff --git a/DependencyInjection/ABELkeycloakBearerOnlyAdapterExtension.php b/DependencyInjection/ABELkeycloakBearerOnlyAdapterExtension.php new file mode 100644 index 0000000..df65028 --- /dev/null +++ b/DependencyInjection/ABELkeycloakBearerOnlyAdapterExtension.php @@ -0,0 +1,33 @@ +load('services.xml'); + + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + $definition = $container->getDefinition('abel_keycloak_bearer_only_adapter.keycloak_bearer_user_provider'); + $definition->replaceArgument(0, $config['issuer']); + $definition->replaceArgument(1, $config['realm']); + $definition->replaceArgument(2, $config['client_id']); + $definition->replaceArgument(3, $config['client_secret']); + } + + public function getAlias() + { + return 'abel_keycloak_bearer_only_adapter'; + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php new file mode 100644 index 0000000..4e799b7 --- /dev/null +++ b/DependencyInjection/Configuration.php @@ -0,0 +1,38 @@ +getRootNode() + ->children() + ->scalarNode("issuer") + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode("realm") + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode("client_id") + ->isRequired() + ->cannotBeEmpty() + ->end() + ->scalarNode("client_secret") + ->isRequired() + ->cannotBeEmpty() + ->end(); + + return $treeBuilder; + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5262420 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-present Abdellah Elmakhroubi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..88c6b5c --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +ABELkeycloakBearerOnlyAdapterBundle +=================================== + +This Symfony bundle is an adapter that allows securing API using keycloak Bearer Only clients. + +## Installation + +With composer: + +``` +$ composer require abel/keycloak-bearer-only-adapter-bundle +``` + +## Configuration + +If you want to set up keycloak locally you can download it [here](https://www.keycloak.org/downloads) and follow instructions from [the official documentation](https://www.keycloak.org/docs/latest/server_installation/index.html). + +### Bundle configuration + +Having a running keycloak locally or in Docker and already configured a client with **Access Type = bearer-only** +here is the configuration to use: + +```yaml +# config/packages/abel_keycloak_bearer_only_adapter.yaml +abel_keycloak_bearer_only_adapter: + issuer: '%env(OAUTH_KEYCLOAK_ISSUER)%' # your accessible keycloak url + realm: '%env(OAUTH_KEYCLOAK_REALM)%' # your keycloak realm name + client_id: '%env(OAUTH_KEYCLOAK_CLIENT_ID)%' # your keycloak client id + client_secret: '%env(OAUTH_KEYCLOAK_CLIENT_SECRET)%' # your keycloak client secret +``` +The best practice is to load your configuration from **.env** file. + +``` +# .env +... +###> Keycloak ### +KEYCLOAK_ISSUER=http://keycloak.local:8080 +KEYCLOAK_REALM=my_realm +KEYCLOAK_CLIENT_ID=my_bearer_client +KEYCLOAK_CLIENT_SECRET=my_bearer_client_secret +###< Keycloak ### +... +``` + +In case of using Keycloak with Docker locally replace **issuer** value with your keycloak container reference in the network + +For example, you can use the container IPAdresse, that you can get using this command: + +``` +$ docker inspect | grep "IPAddress" +``` +### Symfony security configuration + +To secure your API with Keycloak you must change the default security configuration in symfony. + +Here is a simple configuration that restrict access to ```/api/*``` routes only to user with role **ROLE_API** : + +```yaml +# config/packages/security.yaml +security: + providers: + keycloak_bearer_user_provider: + id: ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\User\KeycloakBearerUserProvider + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + api: + pattern: ^/api/ + guard: + provider: keycloak_bearer_user_provider + authenticators: + - ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\Authenticator\KeycloakBearerAuthenticator + stateless: true + main: + anonymous: ~ + access_control: + - { path: ^/api/, roles: ROLE_API } +``` diff --git a/Resources/config/services.xml b/Resources/config/services.xml new file mode 100644 index 0000000..ce130b6 --- /dev/null +++ b/Resources/config/services.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Security/Authenticator/KeycloakBearerAuthenticator.php b/Security/Authenticator/KeycloakBearerAuthenticator.php new file mode 100644 index 0000000..12b5329 --- /dev/null +++ b/Security/Authenticator/KeycloakBearerAuthenticator.php @@ -0,0 +1,215 @@ + 'Auth header required' + ]; + + return new JsonResponse($data, Response::HTTP_UNAUTHORIZED); + } + + /** + * Does the authenticator support the given Request? + * + * If this returns false, the authenticator will be skipped. + * + * @param Request $request + * + * @return bool + */ + public function supports(Request $request) + { + return !empty($request->headers->get('Authorization')); + } + + /** + * Get the authentication credentials from the request and return them + * as any type (e.g. an associate array). + * + * Whatever value you return here will be passed to getUser() and checkCredentials() + * + * For example, for a form login, you might: + * + * return [ + * 'username' => $request->request->get('_username'), + * 'password' => $request->request->get('_password'), + * ]; + * + * Or for an API token that's on a header, you might use: + * + * return ['api_key' => $request->headers->get('X-API-TOKEN')]; + * + * @param Request $request + * + * @return mixed Any non-null value + * + * @throws \UnexpectedValueException If null is returned + */ + public function getCredentials(Request $request) + { + return [ + 'token' => $request->headers->get('Authorization'), + ]; + } + + /** + * Return a UserInterface object based on the credentials. + * + * The *credentials* are the return value from getCredentials() + * + * You may throw an AuthenticationException if you wish. If you return + * null, then a UsernameNotFoundException is thrown for you. + * + * @param mixed $credentials + * @param UserProviderInterface $userProvider + * + * @throws AuthenticationException + * + * @return UserInterface|null + */ + public function getUser($credentials, UserProviderInterface $userProvider) + { + $token = $credentials['token']; + + if (!$token) { + throw new BadCredentialsException('Token is not present in the request headers'); + } + + try { + $user = $userProvider->loadUserByUsername($this->formatToken($token)); + } catch (\Exception $e) { + throw new BadCredentialsException(sprintf('Error when introspecting the token: %s', $e->getMessage())); + } + + return $user; + + } + + /** + * Returns true if the credentials are valid. + * + * If any value other than true is returned, authentication will + * fail. You may also throw an AuthenticationException if you wish + * to cause authentication to fail. + * + * The *credentials* are the return value from getCredentials() + * + * @param mixed $credentials + * @param UserInterface $user + * + * @return bool + * + * @throws AuthenticationException + */ + public function checkCredentials($credentials, UserInterface $user) + { + return true; + } + + /** + * Called when authentication executed, but failed (e.g. wrong username password). + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the login page or a 403 response. + * + * If you return null, the request will continue, but the user will + * not be authenticated. This is probably not what you want to do. + * + * @param Request $request + * @param AuthenticationException $exception + * + * @return Response|null + */ + public function onAuthenticationFailure(Request $request, AuthenticationException $exception) + { + return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_FORBIDDEN); + } + + /** + * Called when authentication executed and was successful! + * + * This should return the Response sent back to the user, like a + * RedirectResponse to the last page they visited. + * + * If you return null, the current request will continue, and the user + * will be authenticated. This makes sense, for example, with an API. + * + * @param Request $request + * @param TokenInterface $token + * @param string $providerKey The provider (i.e. firewall) key + * + * @return Response|null + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey) + { + return null; + } + + /** + * Does this method support remember me cookies? + * + * Remember me cookie will be set if *all* of the following are met: + * A) This method returns true + * B) The remember_me key under your firewall is configured + * C) The "remember me" functionality is activated. This is usually + * done by having a _remember_me checkbox in your form, but + * can be configured by the "always_remember_me" and "remember_me_parameter" + * parameters under the "remember_me" firewall key + * D) The onAuthenticationSuccess method returns a Response object + * + * @return bool + */ + public function supportsRememberMe() + { + return false; + } + + /** + * @param string $token + * @return string + */ + protected function formatToken(string $token): string + { + return trim(preg_replace('/^(?:\s+)?Bearer\s/', '', $token)); + } +} \ No newline at end of file diff --git a/Security/User/KeycloakBearerUser.php b/Security/User/KeycloakBearerUser.php new file mode 100644 index 0000000..e8089ed --- /dev/null +++ b/Security/User/KeycloakBearerUser.php @@ -0,0 +1,301 @@ +sub = $sub; + $this->name = $name; + $this->email = $email; + $this->given_name = $given_name; + $this->family_name = $family_name; + $this->preferred_username = $preferred_username; + $this->roles = $roles; + $this->accessToken = $accessToken; + } + + /** + * @return string + */ + public function getSub(): string + { + return $this->sub; + } + + /** + * @param string $sub + */ + public function setSub(string $sub): void + { + $this->sub = $sub; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + */ + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return string + */ + public function getEmail(): string + { + return $this->email; + } + + /** + * @param string $email + */ + public function setEmail(string $email): void + { + $this->email = $email; + } + + /** + * @return string + */ + public function getGivenName(): string + { + return $this->given_name; + } + + /** + * @param string $given_name + */ + public function setGivenName(string $given_name): void + { + $this->given_name = $given_name; + } + + /** + * @return string + */ + public function getFamilyName(): string + { + return $this->family_name; + } + + /** + * @param string $family_name + */ + public function setFamilyName(string $family_name): void + { + $this->family_name = $family_name; + } + + /** + * @param string $preferred_username + */ + public function setPreferredUsername(string $preferred_username): void + { + $this->preferred_username = $preferred_username; + } + + /** + * @return string + */ + public function getAccessToken(): string + { + return $this->accessToken; + } + + /** + * @param string $accessToken + */ + public function setAccessToken(string $accessToken): void + { + $this->accessToken = $accessToken; + } + + + /** + * Returns the roles granted to the user. + * + * public function getRoles() + * { + * return ['ROLE_USER']; + * } + * + * Alternatively, the roles might be stored on a ``roles`` property, + * and populated in any number of different ways when the user object + * is created. + * + * @return array (Role|string)[] The user roles + */ + public function getRoles() + { + return $this->roles; + } + + /** + * Returns the password used to authenticate the user. + * + * This should be the encoded password. On authentication, a plain-text + * password will be salted, encoded, and then compared to this value. + * + * @return string The password + */ + public function getPassword() + { + // TODO: Implement getPassword() method. + return $this->sub; + } + + /** + * Returns the salt that was originally used to encode the password. + * + * This can return null if the password was not encoded using a salt. + * + * @return string|null The salt + */ + public function getSalt() + { + // TODO: Implement getSalt() method. + return null; + } + + /** + * Returns the username used to authenticate the user. + * + * @return string The username + */ + public function getUsername() + { + // TODO: Implement getUsername() method. + return $this->preferred_username; + } + + /** + * Removes sensitive data from the user. + * + * This is important if, at any given point, sensitive information like + * the plain-text password is stored on this object. + */ + public function eraseCredentials() + { + // TODO: Implement eraseCredentials() method. + } + + /** + * String representation of object + * @link http://php.net/manual/en/serializable.serialize.php + * @return string the string representation of the object or null + * @since 5.1.0 + */ + public function serialize() + { + return serialize(array( + $this->sub, + $this->name, + $this->email, + $this->given_name, + $this->family_name, + $this->preferred_username, + $this->roles, + $this->accessToken + )); + } + + /** + * Constructs the object + * @link http://php.net/manual/en/serializable.unserialize.php + * @param string $serialized

+ * The string representation of the object. + *

+ * @return void + * @since 5.1.0 + */ + public function unserialize($serialized) + { + list ( + $this->sub, + $this->name, + $this->email, + $this->given_name, + $this->family_name, + $this->preferred_username, + $this->roles, + $this->accessToken + ) = unserialize($serialized, ['allowed_classes' => false]); + } +} \ No newline at end of file diff --git a/Security/User/KeycloakBearerUserProvider.php b/Security/User/KeycloakBearerUserProvider.php new file mode 100644 index 0000000..9403093 --- /dev/null +++ b/Security/User/KeycloakBearerUserProvider.php @@ -0,0 +1,138 @@ +issuer = $issuer; + $this->realm = $realm; + $this->client_id = $client_id; + $this->client_secret = $client_secret; + } + + /** + * Loads the user for the given username. + * + * This method must throw UsernameNotFoundException if the user is not + * found. + * + * @param string $accessToken The username + * + * @return UserInterface + * + * @throws UsernameNotFoundException if the user is not found + */ + public function loadUserByUsername($accessToken) + { + $client = new Client([ + 'base_uri' => $this->issuer, + ]); + + $response = $client->post('/auth/realms/'.$this->realm.'/protocol/openid-connect/token/introspect', [ + 'auth' => [$this->client_id, $this->client_secret], + 'form_params' => [ + 'token' => $accessToken, + ], + 'proxy' => [ + 'http' => '', // Use this proxy with "http" + 'https' => '', // Use this proxy with "https", + ], + 'http_errors' => false + ]); + + $jwt = json_decode($response->getBody(), true); + + if (!$jwt['active']) { + throw new \UnexpectedValueException('The token does not exist or is not valid anymore'); + } + + if (!isset($jwt['resource_access'][$this->client_id])) { + throw new \UnexpectedValueException('The token does not have the necessary permissions!'); + } + + return new KeycloakBearerUser( + $jwt['sub'], + $jwt['name'], + $jwt['email'], + $jwt['given_name'], + $jwt['family_name'], + $jwt['preferred_username'], + $jwt['resource_access'][$this->client_id]['roles'], + $accessToken + ); + } + + /** + * Refreshes the user. + * + * It is up to the implementation to decide if the user data should be + * totally reloaded (e.g. from the database), or if the UserInterface + * object can just be merged into some internal array of users / identity + * map. + * + * @return UserInterface + * + * @throws UnsupportedUserException if the user is not supported + * @throws UsernameNotFoundException if the user is not found + */ + public function refreshUser(UserInterface $user) + { + if (!$user instanceof KeycloakBearerUser) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user))); + } + + $user = $this->loadUserByUsername($user->getAccessToken()); + + if (!$user) { + throw new UsernameNotFoundException(); + } + + return $user; + } + + /** + * Whether this provider supports the given user class. + * + * @param string $class + * + * @return bool + */ + public function supportsClass($class) + { + return KeycloakBearerUser::class === $class; + } +} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..22122db --- /dev/null +++ b/composer.json @@ -0,0 +1,35 @@ +{ + "name": "abel/keycloak-bearer-only-adapter-bundle", + "description": "Keycloak security adapter for bearer only clients", + "license": "MIT", + "type": "symfony-bundle", + "authors": [ + { + "name": "Abdellah Elmakhroubi", + "email": "abdellah.elmakhroubi@gmail.com" + } + ], + "minimum-stability": "stable", + "require": { + "php": "^7.1", + "symfony/config": "^4.0", + "symfony/dependency-injection": "^4.0", + "symfony/http-kernel": "^4.0", + "symfony/security-bundle": "^4.0", + "guzzlehttp/guzzle": "^6.3", + "ext-json": "*" + }, + "autoload": { + "psr-4": { + "ABEL\\Bundle\\keycloakBearerOnlyAdapterBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "autoload-dev": { + "psr-4": { + "ABEL\\Bundle\\keycloakBearerOnlyAdapterBundle\\Tests\\": "Tests" + } + } +}