Skip to content
114 changes: 113 additions & 1 deletion Docs/Documentation/Social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
<?php
namespace App\Event;

use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;
use CakeDC\Users\Plugin;

class SocialLoginListener implements EventListenerInterface
{
public function implementedEvents(): array
{
return [
Plugin::EVENT_SOCIAL_LOGIN_EXISTING_ACCOUNT => '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.
23 changes: 20 additions & 3 deletions config/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
45 changes: 45 additions & 0 deletions src/Event/SocialLoginListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace CakeDC\Auth\Event;

use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;

class SocialLoginListener implements EventListenerInterface
{
/**
* Implementálja az EventListenerInterface-t
*
* @return array<string, mixed>
*/
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;
}
}
16 changes: 16 additions & 0 deletions src/Social/MapUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
91 changes: 91 additions & 0 deletions src/Social/Mapper/Keycloak.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);

/**
* Copyright 2010 - 2019, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2010 - 2019, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/


namespace CakeDC\Auth\Social\Mapper;
use Cake\Core\Configure;

class Keycloak extends AbstractMapper
{
/**
* Map for provider fields
*
* @var array
*/
protected array $_mapFields = [
'first_name' => '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;
}
}