diff --git a/Docs/Documentation/Social.md b/Docs/Documentation/Social.md index c63d374..c95d8b1 100644 --- a/Docs/Documentation/Social.md +++ b/Docs/Documentation/Social.md @@ -92,4 +92,116 @@ Working with cakephp/authentication If you're using the new cakephp/authentication we recommend you to use the SocialAuthenticator and SocialMiddleware provided in this plugin. For more details of how to handle social authentication with cakephp/authentication, please check -how we implemented at CakeDC/Users plugins. \ No newline at end of file +how we implemented at CakeDC/Users plugins. + +Working with Keycloak +------------------- + +Keycloak is an open source identity and access management solution that can be integrated with this plugin. Here's how to set it up: + +### Installation + +First, install the required OAuth2 Keycloak provider package (version 5.1 or newer): + +```bash +composer require stevenmaguire/oauth2-keycloak +``` + +### Configuration + +Add the Keycloak provider configuration to your `config/users.php` file: + +```php +'OAuth' => [ + 'providers' => [ + 'keycloak' => [ + 'service' => 'CakeDC\Auth\Social\Service\OAuth2Service', + 'className' => 'Stevenmaguire\OAuth2\Client\Provider\Keycloak', + 'mapper' => 'CakeDC\Auth\Social\Mapper\Keycloak', + 'rolesMap' => [ + 'CakeDc-Admin' => 'admin', + 'CakeDc-User' => 'user', + 'CakeDc-Worker' => 'user' + ], + 'authParams' => ['scope' => ['openid', 'roles']], + 'skipSocialAccountValidation' => true, + 'options' => [ + 'redirectUri' => Router::fullBaseUrl() . '/auth/keycloak', + 'linkSocialUri' => Router::fullBaseUrl() . '/auth/link-social/keycloak', + 'callbackLinkSocialUri' => Router::fullBaseUrl() . '/auth/callback-link-social/keycloak', + 'realm' => env('KEYCLOAK_REALM', null), + 'clientId' => env('KEYCLOAK_CLIENT_ID', null), + 'clientSecret' => env('KEYCLOAK_CLIENT_SECRET', null), + 'authServerUrl' => env('KEYCLOAK_AUTH_SERVER_URL', null), + ] + ], + ], +], +``` + +### Keycloak Server Configuration + +1. **Client Scopes Setup**: + - Enable the `openid` scope + - Add a `roles` scope with Mappers configuration + - Configure the `realm_roles` mapper with "Add to userinfo" set to ON + +2. **Realm Roles**: + - Create roles that match your configuration (e.g., `CakeDc-Admin`, `CakeDc-User`, `CakeDc-Worker`) + - Assign these roles to users or groups in Keycloak + +3. **User Attributes**: + - You can add additional attributes like `website` to users if needed + +### Role Mapping + +The plugin maps Keycloak roles to application roles using the `rolesMap` configuration. When a user logs in, their Keycloak roles are checked against this map and the corresponding application role is assigned. + +### Event Listener for Role Updates + +You can create a custom event listener to update user roles during login: + +```php + 'changeRole', + ]; + } + + public function changeRole(EventInterface $event) + { + $data = $event->getData('data'); + $userEntity = $event->getData('userEntity'); + + if (isset($data['provider']) && $data['provider'] === 'keycloak' && isset($data['roles'])) { + $userEntity->set('role', $data['roles']); + return $userEntity; + } + + return null; + } +} +``` + +### Environment Variables + +For security, store your Keycloak configuration in environment variables: + +``` +KEYCLOAK_REALM=your-realm +KEYCLOAK_CLIENT_ID=your-client-id +KEYCLOAK_CLIENT_SECRET=your-client-secret +KEYCLOAK_AUTH_SERVER_URL=https://your-keycloak-server/auth +``` + +This setup allows your CakePHP application to authenticate users through Keycloak and map their roles appropriately. \ No newline at end of file diff --git a/config/auth.php b/config/auth.php index 2dafc60..9c65fce 100644 --- a/config/auth.php +++ b/config/auth.php @@ -86,11 +86,28 @@ 'callbackLinkSocialUri' => Router::fullBaseUrl() . '/callback-link-social/azure', ] ], + 'keycloak' => [ + 'service' => 'CakeDC\Auth\Social\Service\OAuth2Service', + 'className' => 'Stevenmaguire\OAuth2\Client\Provider\Keycloak', + 'mapper' => 'CakeDC\Auth\Social\Mapper\Keycloak', + 'rolesMap' => [ + 'CakeDc-Admin' => 'Admin', + 'CakeDc-User' => 'User', + 'CakeDc-Worker' => 'User' + ], + 'authParams' => ['scope' => ['openid','roles']], + 'skipSocialAccountValidation' => true, + 'options' => [ + 'redirectUri' => Router::fullBaseUrl() . '/auth/keycloak', + 'linkSocialUri' => Router::fullBaseUrl() . '/auth/link-social/keycloak', + 'callbackLinkSocialUri' => Router::fullBaseUrl() . '/auth/callback-link-social/keycloak', + ] + ], ], - 'TwoFactorProcessors' => [ + 'TwoFactorProcessors' => [ \CakeDC\Auth\Authentication\TwoFactorProcessor\OneTimePasswordProcessor::class, - \CakeDC\Auth\Authentication\TwoFactorProcessor\Webauthn2faProcessor::class, - ], + \CakeDC\Auth\Authentication\TwoFactorProcessor\Webauthn2faProcessor::class + ], 'OneTimePasswordAuthenticator' => [ 'checker' => \CakeDC\Auth\Authentication\DefaultOneTimePasswordAuthenticationChecker::class, 'verifyAction' => [ diff --git a/src/Event/SocialLoginListener.php b/src/Event/SocialLoginListener.php new file mode 100644 index 0000000..b426805 --- /dev/null +++ b/src/Event/SocialLoginListener.php @@ -0,0 +1,45 @@ + + */ + public function implementedEvents(): array + { + return [ + /** + * Event name directly used from CakeDC/Users plugin + * Original source: CakeDC\Users\Plugin::EVENT_SOCIAL_LOGIN_EXISTING_ACCOUNT + * If the constant is changed in the CakeDC/Users plugin, this string must be updated accordingly + */ + 'CakeDC.Users.Social.afterIdentify' => 'changeRole', + ]; + } + + /** + * Szerepkör módosítása Keycloak bejelentkezés esetén + * + * @param \Cake\Event\EventInterface $event Az esemény objektum + * @return \Cake\Datasource\EntityInterface|null + */ + public function changeRole(EventInterface $event) + { + $data = $event->getData('data'); + $userEntity = $event->getData('userEntity'); + if (isset($data['provider']) && $data['provider'] === 'keycloak' && isset($data['roles'])) { + $userEntity->set('role', $data['roles']); + return $userEntity; + } + + return null; + } +} \ No newline at end of file diff --git a/src/Social/MapUser.php b/src/Social/MapUser.php index 7391e4f..4ae7cf6 100644 --- a/src/Social/MapUser.php +++ b/src/Social/MapUser.php @@ -15,9 +15,25 @@ use CakeDC\Auth\Social\Service\ServiceInterface; use InvalidArgumentException; +use Cake\Event\EventManager; +use CakeDC\Auth\Event\SocialLoginListener; + class MapUser { + /** + * Constructor + * + * Initializes the MapUser class and registers the SocialLoginListener + * to handle social login events. + */ + public function __construct() + { + + $listener = new SocialLoginListener(); + EventManager::instance()->on($listener); + + } /** * Map social user user data * diff --git a/src/Social/Mapper/Keycloak.php b/src/Social/Mapper/Keycloak.php new file mode 100644 index 0000000..eb2bf21 --- /dev/null +++ b/src/Social/Mapper/Keycloak.php @@ -0,0 +1,91 @@ + 'given_name', + 'last_name' => 'family_name', + 'email' => 'email', + 'username' => 'preferred_username', + 'id' => 'sub', + 'link' => 'website', + 'roles' => 'realm_access', + 'validated' => 'email_verified' + ]; + + /** + * Map Keycloak roles to CakeDC roles + * + * @var array + */ + protected array $_rolesMap = [ + 'CakeDc-Admin' => 'admin', + 'CakeDc-User' => 'user', + 'CakeDc-Worker' => 'user' + ]; + + /** + * Constructor + */ + public function __construct() + { + $configRoleMap = Configure::read('OAuth.providers.keycloak.rolesMap'); + $configMapFields = Configure::read('OAuth.providers.keycloak.mapFields'); + if (!empty($configRoleMap) && is_array($configRoleMap)) { + $this->_rolesMap = $configRoleMap; + } + if (!empty($configMapFields) && is_array($configMapFields)) { + $this->_mapFields = $configMapFields; + } + } + + function _roles(array $data): string + { // Client Scopes > roles > Mappers > realm roles -> Add to userinfo := Enable + if (is_null($data[$this->_mapFields['roles']])) { + throw new \Exception("No roles in UserInfo token. Set realm roles 'Add to userinfo' field to ON in Client scopes or check the roles field in _mapFields"); + } + + $keycloakRoles = $data[$this->_mapFields['roles']]['roles']; + + // Ignore case when comparing roles + $mappedRoles = []; + foreach ($keycloakRoles as $keycloakRole) { + foreach (array_keys($this->_rolesMap) as $mapKey) { + if (strcasecmp($keycloakRole, $mapKey) === 0) { + $mappedRoles[] = $mapKey; + break; + } + } + } + + if (empty($mappedRoles)) { + throw new \Exception("No mappable role found in Keycloak. Available roles in map: " . implode(', ', array_keys($this->_rolesMap)) . ' / '. implode(', ', ($keycloakRoles))); + } + + $keycloakRole = array_pop($mappedRoles); + $role = $this->_rolesMap[$keycloakRole]; + // Set the cakedc default user role from keycloak roles + Configure::write('Users.Registration.defaultRole', $role); + return $role; + } +}