diff --git a/composer.json b/composer.json index c233ebd..42a49b2 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/Migrations/20211001000000_RolesPermOrgAdmin.php b/config/Migrations/20211001000000_RolesPermOrgAdmin.php new file mode 100644 index 0000000..28cfae8 --- /dev/null +++ b/config/Migrations/20211001000000_RolesPermOrgAdmin.php @@ -0,0 +1,18 @@ +table('roles') + ->addColumn('perm_org_admin', 'boolean', [ + 'default' => 0, + 'null' => false, + ]) + ->update(); + } +} diff --git a/src/Application.php b/src/Application.php index 2a8a88b..a63a6ce 100644 --- a/src/Application.php +++ b/src/Application.php @@ -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; } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index ac30776..827ac1c 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -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]); diff --git a/src/Controller/RolesController.php b/src/Controller/RolesController.php index 9421a8c..357eeeb 100644 --- a/src/Controller/RolesController.php +++ b/src/Controller/RolesController.php @@ -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 = []; diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index e2d15ef..f3059b2 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -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; diff --git a/src/Event/SocialAuthListener.php b/src/Event/SocialAuthListener.php new file mode 100644 index 0000000..2bfba76 --- /dev/null +++ b/src/Event/SocialAuthListener.php @@ -0,0 +1,89 @@ + '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; + } +} diff --git a/src/Model/Behavior/AuthKeycloakBehavior.php b/src/Model/Behavior/AuthKeycloakBehavior.php new file mode 100644 index 0000000..519794c --- /dev/null +++ b/src/Model/Behavior/AuthKeycloakBehavior.php @@ -0,0 +1,168 @@ +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']; + } +} diff --git a/src/Model/Table/UsersTable.php b/src/Model/Table/UsersTable.php index 09a142e..585295d 100644 --- a/src/Model/Table/UsersTable.php +++ b/src/Model/Table/UsersTable.php @@ -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); + } + } } diff --git a/templates/Roles/add.php b/templates/Roles/add.php index 3d874b5..c709865 100644 --- a/templates/Roles/add.php +++ b/templates/Roles/add.php @@ -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', diff --git a/templates/Roles/index.php b/templates/Roles/index.php index e5685c3..2fc4c03 100644 --- a/templates/Roles/index.php +++ b/templates/Roles/index.php @@ -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', diff --git a/templates/Roles/view.php b/templates/Roles/view.php index 4624868..eac0175 100644 --- a/templates/Roles/view.php +++ b/templates/Roles/view.php @@ -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', diff --git a/templates/Users/login.php b/templates/Users/login.php index ca50832..fdcbe3a 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -13,5 +13,16 @@ echo $this->Form->control(__('Submit'), ['type' => 'submit', 'class' => 'btn btn-primary']); echo $this->Form->end(); echo ''; + echo $this->Form->postLink( + 'Login with Keycloak', + [ + 'prefix' => false, + 'plugin' => 'ADmad/SocialAuth', + 'controller' => 'Auth', + 'action' => 'login', + 'provider' => 'keycloak', + '?' => ['redirect' => $this->request->getQuery('redirect')] + ] + ); ?>