first commit

This commit is contained in:
Abdellah ELMAKHROUBI
2020-11-19 15:26:54 +01:00
commit b303c94643
11 changed files with 908 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/.idea
/vendor/
composer.lock

View File

@@ -0,0 +1,19 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle;
use ABEL\Bundle\keycloakBearerOnlyAdapterBundle\DependencyInjection\ABELkeycloakBearerOnlyAdapterExtension;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class ABELkeycloakBearerOnlyAdapterBundle extends Bundle
{
public function getContainerExtension()
{
if (null === $this->extension) {
$this->extension = new ABELkeycloakBearerOnlyAdapterExtension();
}
return $this->extension;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class ABELkeycloakBearerOnlyAdapterExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
$loader->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';
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder("abel_keycloak_bearer_only_adapter");
$treeBuilder->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;
}
}

21
LICENSE Normal file
View File

@@ -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.

79
README.md Normal file
View File

@@ -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 <container id> | 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 }
```

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services
https://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<!-- Default configuration for services in *this* file -->
<defaults autowire="true" autoconfigure="true"/>
<!-- makes classes available to be used as services -->
<!-- this creates a service per class whose id is the fully-qualified class name -->
<prototype namespace="ABEL\Bundle\keycloakBearerOnlyAdapterBundle\" resource="../../*" exclude="../../{Entity,Migrations,Tests}"/>
<service id="abel_keycloak_bearer_only_adapter.keycloak_bearer_user_provider" class="ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\User\KeycloakBearerUserProvider">
<argument/>
<argument/>
<argument/>
<argument/>
</service>
<service id="ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\User\KeycloakBearerUserProvider" alias="abel_keycloak_bearer_only_adapter.keycloak_bearer_user_provider" />
</services>
</container>

View File

@@ -0,0 +1,215 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\Authenticator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
class KeycloakBearerAuthenticator extends AbstractGuardAuthenticator
{
/**
* Returns a response that directs the user to authenticate.
*
* This is called when an anonymous request accesses a resource that
* requires authentication. The job of this method is to return some
* response that "helps" the user start into the authentication process.
*
* Examples:
*
* - For a form login, you might redirect to the login page
*
* return new RedirectResponse('/login');
*
* - For an API token authentication system, you return a 401 response
*
* return new Response('Auth header required', 401);
*
* @param Request $request The request that resulted in an AuthenticationException
* @param AuthenticationException|null $authException The exception that started the authentication process
*
* @return JsonResponse
*/
public function start(Request $request, AuthenticationException $authException = null)
{
$data = [
// you might translate this message
'message' => '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));
}
}

View File

@@ -0,0 +1,301 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\User;
use Symfony\Component\Security\Core\User\UserInterface;
class KeycloakBearerUser implements UserInterface, \Serializable
{
/**
* @var string
*/
private $sub;
/**
* @var string
*/
private $name;
/**
* @var string
*/
private $email;
/**
* @var string
*/
private $given_name;
/**
* @var string
*/
private $family_name;
/**
* @var string
*/
private $preferred_username;
/**
* @var array
*/
private $roles;
/**
* @var string
*/
private $accessToken;
/**
* KeycloakBearerUser constructor.
* @param string $sub
* @param string $name
* @param string $email
* @param string $given_name
* @param string $family_name
* @param string $preferred_username
* @param array $roles
* @param string $accessToken
*/
public function __construct(
string $sub,
string $name,
string $email,
string $given_name,
string $family_name,
string $preferred_username,
array $roles,
string $accessToken
)
{
$this->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 <p>
* The string representation of the object.
* </p>
* @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]);
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace ABEL\Bundle\keycloakBearerOnlyAdapterBundle\Security\User;
use GuzzleHttp\Client;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
class KeycloakBearerUserProvider implements UserProviderInterface
{
/**
* @var string
*/
private $issuer;
/**
* @var string
*/
private $realm;
/**
* @var string
*/
private $client_id;
/**
* @var string
*/
private $client_secret;
/**
* KeycloakBearerUserProvider constructor.
* @param string $issuer
* @param string $realm
* @param string $client_id
* @param string $client_secret
*/
public function __construct(string $issuer, string $realm, string $client_id, string $client_secret)
{
$this->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;
}
}

35
composer.json Normal file
View File

@@ -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"
}
}
}