Merge branch 'keycloak' into develop-unstable

pull/72/head
iglocska 2021-10-01 13:53:14 +02:00
commit 99a89977c8
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
13 changed files with 413 additions and 5 deletions

View File

@ -6,6 +6,7 @@
"license": "MIT",
"require": {
"php": ">=7.2",
"admad/cakephp-social-auth": "^1.1",
"cakephp/authentication": "^2.0",
"cakephp/authorization": "^2.0",
"cakephp/cakephp": "^4.0",

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Migrations\AbstractMigration;
class RolesPermOrgAdmin extends AbstractMigration
{
public function change()
{
$table = $this->table('roles')
->addColumn('perm_org_admin', 'boolean', [
'default' => 0,
'null' => false,
])
->update();
}
}

View File

@ -31,7 +31,8 @@ use Authentication\Middleware\AuthenticationMiddleware;
use Psr\Http\Message\ServerRequestInterface;
use Tags\Plugin as TagsPlugin;
use App\Event\SocialAuthListener;
use Cake\Event\EventManager;
/**
* Application setup class.
*
@ -47,6 +48,8 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
*/
public function bootstrap(): void
{
$this->addPlugin('ADmad/SocialAuth');
// Call parent to load bootstrap from files.
parent::bootstrap();
@ -63,6 +66,7 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
}
$this->addPlugin('Authentication');
$this->addPlugin('Tags', ['routes' => true]);
EventManager::instance()->on(new SocialAuthListener());
// Load more plugins here
}
@ -90,8 +94,32 @@ class Application extends BaseApplication implements AuthenticationServiceProvid
// creating the middleware instance specify the cache config name by
// using it's second constructor argument:
// `new RoutingMiddleware($this, '_cake_routes_')`
->add(new RoutingMiddleware($this))
->add(new AuthenticationMiddleware($this))
->add(new RoutingMiddleware($this));
if (!empty(Configure::read('keycloak'))) {
$middlewareQueue->add(new \ADmad\SocialAuth\Middleware\SocialAuthMiddleware([
'requestMethod' => 'POST',
'loginUrl' => '/users/login',
'loginRedirect' => '/',
'userEntity' => false,
'userModel' => 'Users',
'socialProfileModel' => 'ADmad/SocialAuth.SocialProfiles',
'finder' => 'all',
'fields' => [
'password' => 'password',
],
'sessionKey' => 'Auth',
'getUserCallback' => 'getUser',
'serviceConfig' => [
'provider' => [
'keycloak' => Configure::read('keycloak.provider')
],
],
'collectionFactory' => null,
'logErrors' => true,
]));
}
$middlewareQueue->add(new AuthenticationMiddleware($this))
->add(new BodyParserMiddleware());
return $middlewareQueue;
}

View File

@ -149,8 +149,14 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if (isset($params['beforeSave'])) {
$data = $params['beforeSave']($data);
}
$savedData = $this->Table->save($data);
if ($savedData !== false) {
if (isset($params['afterSave'])) {
$params['afterSave']($data);
}
$message = __('{0} added.', $this->ObjectAlias);
if (!empty($input['metaFields'])) {
$this->saveMetaFields($data->id, $input);
@ -270,8 +276,14 @@ class CRUDComponent extends Component
$patchEntityParams['fields'] = $params['fields'];
}
$data = $this->Table->patchEntity($data, $input, $patchEntityParams);
if (isset($params['beforeSave'])) {
$data = $params['beforeSave']($data);
}
$savedData = $this->Table->save($data);
if ($savedData !== false) {
if (isset($params['afterSave'])) {
$params['afterSave']($data);
}
$message = __('{0} `{1}` updated.', $this->ObjectAlias, $savedData->{$this->Table->getDisplayField()});
if (!empty($input['metaFields'])) {
$this->MetaFields->deleteAll(['scope' => $this->Table->metaFields, 'parent_id' => $savedData->id]);

View File

@ -12,7 +12,7 @@ use Cake\Http\Exception\ForbiddenException;
class RolesController extends AppController
{
public $filterFields = ['name', 'uuid', 'perm_admin', 'Users.id'];
public $filterFields = ['name', 'uuid', 'perm_admin', 'Users.id', 'perm_org_admin'];
public $quickFilterFields = ['name'];
public $containFields = [];

View File

@ -29,7 +29,12 @@ class UsersController extends AppController
public function add()
{
$this->CRUD->add();
$this->CRUD->add([
'beforeSave' => function($data) {
$this->Users->enrollUserRouter($data);
return $data;
}
]);
$responsePayload = $this->CRUD->getResponsePayload();
if (!empty($responsePayload)) {
return $responsePayload;

View File

@ -0,0 +1,89 @@
<?php
namespace App\Event;
use ADmad\SocialAuth\Middleware\SocialAuthMiddleware;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\Event\EventListenerInterface;
use Cake\Http\ServerRequest;
use Cake\I18n\FrozenTime;
use Cake\ORM\Locator\LocatorAwareTrait;
class SocialAuthListener implements EventListenerInterface
{
use LocatorAwareTrait;
public function implementedEvents(): array
{
return [
SocialAuthMiddleware::EVENT_AFTER_IDENTIFY => 'afterIdentify',
SocialAuthMiddleware::EVENT_BEFORE_REDIRECT => 'beforeRedirect',
// Uncomment below if you want to use the event listener to return
// an entity for a new user instead of directly using `createUser()` table method.
// SocialAuthMiddleware::EVENT_CREATE_USER => 'createUser',
];
}
public function afterIdentify(EventInterface $event, EntityInterface $user): EntityInterface
{
// Update last login time
// $user->set('last_login', new FrozenTime());
// You can access the profile using $user->social_profile
$this->getTableLocator()->get('Users')->saveOrFail($user);
return $user;
}
/**
* @param \Cake\Event\EventInterface $event
* @param string|array $url
* @param string $status
* @param \Cake\Http\ServerRequest $request
* @return void
*/
public function beforeRedirect(EventInterface $event, $url, string $status, ServerRequest $request): void
{
$messages = (array)$request->getSession()->read('Flash.flash');
// Set flash message
switch ($status) {
case SocialAuthMiddleware::AUTH_STATUS_SUCCESS:
$messages[] = [
'message' => __('You are now logged in'),
'key' => 'flash',
'element' => 'flash/success',
'params' => [],
];
break;
// Auth through provider failed. Details will be logged in
// `error.log` if `logErrors` option is set to `true`.
case SocialAuthMiddleware::AUTH_STATUS_PROVIDER_FAILURE:
// Table finder failed to return user record. An e.g. of this is a
// user has been authenticated through provider but your finder has
// a condition to not return an inactivated user.
case SocialAuthMiddleware::AUTH_STATUS_FINDER_FAILURE:
$messages[] = [
'message' => __('Authentication failed'),
'key' => 'flash',
'element' => 'flash/error',
'params' => [],
];
break;
}
$request->getSession()->write('Flash.flash', $messages);
// You can return a modified redirect URL if needed.
}
public function createUser(EventInterface $event, EntityInterface $profile, Session $session): EntityInterface
{
// Create and save entity for new user as shown in "createUser()" method above
return $user;
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace App\Model\Behavior;
use ArrayObject;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\Utility\Text;
use \Cake\Http\Session;
use Cake\Core\Configure;
use Cake\Http\Client;
use Cake\Http\Client\FormData;
class AuthKeycloakBehavior extends Behavior
{
public function getUser(EntityInterface $profile, Session $session)
{
$userId = $session->read('Auth.User.id');
if ($userId) {
return $this->_table->get($userId);
}
$raw_profile_payload = $profile->access_token->getJwt()->getPayload();
$user = $this->extractProfileData($raw_profile_payload);
if (!$user) {
throw new \RuntimeException('Unable to save new user');
}
return $user;
}
private function extractProfileData($profile_payload)
{
$mapping = Configure::read('keycloak.mapping');
$fields = [
'org_uuid' => 'org_uuid',
'role_name' => 'role_name',
'username' => 'preferred_username',
'email' => 'email',
'first_name' => 'given_name',
'last_name' => 'family_name'
];
foreach ($fields as $field => $default) {
if (!empty($mapping[$field])) {
$fields[$field] = $mapping[$field];
}
}
$user = [
'individual' => [
'email' => $profile_payload[$fields['email']],
'first_name' => $profile_payload[$fields['first_name']],
'last_name' => $profile_payload[$fields['last_name']]
],
'user' => [
'username' => $profile_payload[$fields['username']],
],
'organisation' => [
'uuid' => $profile_payload[$fields['org_uuid']],
],
'role' => [
'name' => $profile_payload[$fields['role_name']],
]
];
$user['user']['individual_id'] = $this->_table->captureIndividual($user);
$user['user']['role_id'] = $this->_table->captureRole($user);
$existingUser = $this->_table->find()->where(['username' => $user['user']['username']])->first();
if (empty($existingUser)) {
$user['user']['password'] = Security::randomString(16);
$existingUser = $this->_table->newEntity($user['user']);
if (!$this->save($existingUser)) {
return false;
}
} else {
$dirty = false;
if ($user['user']['individual_id'] != $existingUser['individual_id']) {
$existingUser['individual_id'] = $user['user']['individual_id'];
$dirty = true;
}
if ($user['user']['role_id'] != $existingUser['role_id']) {
$existingUser['role_id'] = $user['user']['role_id'];
$dirty = true;
}
$existingUser;
if ($dirty) {
if (!$this->_table->save($existingUser)) {
return false;
}
}
}
return $existingUser;
}
public function enrollUser($data): bool
{
$individual = $this->_table->Individuals->find()->where(
['id' => $data['individual_id']]
)->contain(['Organisations'])->first();
$roleConditions = [
'id' => $data['role_id']
];
if (!empty(Configure::read('keycloak.user_management.actions'))) {
$roleConditions['name'] = Configure::read('keycloak.default_role_name');
}
$role = $this->_table->Roles->find()->where($roleConditions)->first();
$orgs = [];
foreach ($individual['organisations'] as $org) {
$orgs[] = $org['uuid'];
}
$token = $this->getAdminAccessToken();
$keyCloakUser = [
'firstName' => $individual['first_name'],
'lastName' => $individual['last_name'],
'username' => $data['username'],
'email' => $individual['email'],
'attributes' => [
'role_name' => empty($role['name']) ? Configure::read('keycloak.default_role_name') : $role['name'],
'org_uuid' => empty($orgs[0]) ? '' : $orgs[0]
]
];
$keycloakConfig = Configure::read('keycloak');
$http = new Client();
$url = sprintf(
'%s/admin/realms/%s/users',
$keycloakConfig['provider']['baseUrl'],
$keycloakConfig['provider']['realm']
);
$response = $http->post(
$url,
json_encode($keyCloakUser),
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $token
]
]
);
return true;
}
private function getAdminAccessToken()
{
$keycloakConfig = Configure::read('keycloak');
$http = new Client();
$tokenUrl = sprintf(
'%s/realms/%s/protocol/openid-connect/token',
$keycloakConfig['provider']['baseUrl'],
$keycloakConfig['provider']['realm']
);
$response = $http->post(
$tokenUrl,
sprintf(
'grant_type=client_credentials&client_id=%s&client_secret=%s',
urlencode(Configure::read('keycloak.provider.applicationId')),
urlencode(Configure::read('keycloak.provider.applicationSecret'))
),
[
'headers' => [
'Content-Type' => 'application/x-www-form-urlencoded'
]
]
);
$parsedResponse = json_decode($response->getStringBody(), true);
return $parsedResponse['access_token'];
}
}

View File

@ -7,6 +7,11 @@ use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\ORM\RulesChecker;
use Cake\ORM\TableRegistry;
use \Cake\Datasource\EntityInterface;
use \Cake\Http\Session;
use Cake\Http\Client;
use Cake\Utility\Security;
use Cake\Core\Configure;
class UsersTable extends AppTable
{
@ -14,6 +19,7 @@ class UsersTable extends AppTable
{
parent::initialize($config);
$this->addBehavior('UUID');
$this->initAuthBehaviors();
$this->belongsTo(
'Individuals',
[
@ -31,6 +37,13 @@ class UsersTable extends AppTable
$this->setDisplayField('username');
}
private function initAuthBehaviors()
{
if (!empty(Configure::read('keycloak'))) {
$this->addBehavior('AuthKeycloak');
}
}
public function validationDefault(Validator $validator): Validator
{
$validator
@ -93,4 +106,51 @@ class UsersTable extends AppTable
}
return true;
}
public function captureIndividual($user): int
{
$individual = $this->Individuals->find()->where(['email' => $user['individual']['email']])->first();
if (empty($individual)) {
$individual = $this->Individuals->newEntity($user['individual']);
if (!$this->Individuals->save($individual)) {
throw new BadRequestException(__('Could not save the associated individual'));
}
}
return $individual->id;
}
public function captureOrganisation($user): int
{
$organisation = $this->Organisations->find()->where(['uuid' => $user['organisation']['uuid']])->first();
if (empty($organisation)) {
$user['organisation']['name'] = $user['organisation']['uuid'];
$organisation = $this->Organisations->newEntity($user['organisation']);
if (!$this->Organisations->save($organisation)) {
throw new BadRequestException(__('Could not save the associated organisation'));
}
}
return $organisation->id;
}
public function captureRole($user): int
{
$role = $this->Roles->find()->where(['name' => $user['role']['name']])->first();
if (empty($role)) {
if (!empty(Configure::read('keycloak.default_role_name'))) {
$default_role_name = Configure::read('keycloak.default_role_name');
$role = $this->Roles->find()->where(['name' => $default_role_name])->first();
}
if (empty($role)) {
throw new NotFoundException(__('Invalid role'));
}
}
return $role->id;
}
public function enrollUserRouter($data): void
{
if (!empty(Configure::read('keycloak'))) {
$this->enrollUser($data);
}
}
}

View File

@ -12,6 +12,11 @@
'type' => 'checkbox',
'label' => 'Full admin privilege'
],
[
'field' => 'perm_org_admin',
'type' => 'checkbox',
'label' => 'Organisation admin privilege'
],
[
'field' => 'perm_sync',
'type' => 'checkbox',

View File

@ -47,6 +47,12 @@ echo $this->element('genericElements/IndexTable/index_table', [
'data_path' => 'perm_admin',
'element' => 'boolean'
],
[
'name' => __('Org Admin'),
'sort' => 'perm_org_admin',
'data_path' => 'perm_org_admin',
'element' => 'boolean'
],
[
'name' => __('Sync'),
'sort' => 'perm_sync',

View File

@ -17,6 +17,11 @@ echo $this->element(
'path' => 'perm_admin',
'type' => 'boolean'
],
[
'key' => __('Organisation admin permission'),
'path' => 'perm_org_admin',
'type' => 'boolean'
],
[
'key' => __('Sync permission'),
'path' => 'perm_sync',

View File

@ -13,5 +13,16 @@
echo $this->Form->control(__('Submit'), ['type' => 'submit', 'class' => 'btn btn-primary']);
echo $this->Form->end();
echo '</div>';
echo $this->Form->postLink(
'Login with Keycloak',
[
'prefix' => false,
'plugin' => 'ADmad/SocialAuth',
'controller' => 'Auth',
'action' => 'login',
'provider' => 'keycloak',
'?' => ['redirect' => $this->request->getQuery('redirect')]
]
);
?>
</div>