From 828946a97fdd5952d84762ab94b00b43e3d0fd8e Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 24 Feb 2022 13:45:10 +0100 Subject: [PATCH 01/10] new: [users] several changes - make usernames immutable - restrict user creation to aligned individuals (org admin only) - optionally create individual while creating a user --- src/Controller/UsersController.php | 35 +++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 272b1ba..ab21c42 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -44,15 +44,23 @@ class UsersController extends AppController { $currentUser = $this->ACL->getUser(); $validRoles = []; + $individuals_params = [ + 'sort' => ['email' => 'asc'] + ]; if (!$currentUser['role']['perm_admin']) { $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0])->all()->toArray(); + $individual_ids = $this->Users->Individuals->find('aligned', ['organisation_id' => $currentUser['organisation_id']])->all()->extract('id')->toArray(); + if (empty($individual_ids)) { + $individual_ids = [-1]; + } + $individuals_params['conditions'] = ['id IN' => $individual_ids]; } else { $validRoles = $this->Users->Roles->find('list')->order(['name' => 'asc'])->all()->toArray(); } $defaultRole = $this->Users->Roles->find()->select(['id'])->first()->toArray(); - + $individuals = $this->Users->Individuals->find('list', $individuals_params)->toArray(); $this->CRUD->add([ - 'beforeSave' => function($data) use ($currentUser, $validRoles, $defaultRole) { + 'beforeSave' => function($data) use ($currentUser, $validRoles, $defaultRole, $individual_ids) { if (!isset($data['role_id']) && !empty($defaultRole)) { $data['role_id'] = $defaultRole['id']; } @@ -62,6 +70,21 @@ class UsersController extends AppController throw new MethodNotAllowedException(__('You do not have permission to assign that role.')); } } + if ((!isset($data['individual_id']) || $data['individual_id'] === 'new') && !empty($data['individual'])) { + $existingOrg = $this->Users->Organisations->find('all')->where(['id' => $data['organisation_id']])->select(['uuid'])->first(); + if (empty($existingOrg)) { + throw new MethodNotAllowedException(__('No valid organisation found. Either encode the organisation separately or select a valid one.')); + } + $data['individual']['alignments'][] = ['type' => 'Member', 'organisation' => ['uuid' => $existingOrg['uuid']]]; + $data['individual_id'] = $this->Users->Individuals->captureIndividual($data['individual']); + } else if (!$currentUser['role']['perm_admin'] && isset($data['individual_id'])) { + if (!in_array($data['individual_id'], $individual_ids)) { + throw new MethodNotAllowedException(__('The selected individual is not aligned with your organisation. Creating a user for them is not permitted.')); + } + } + if (empty($data['individual_id'])) { + throw new MethodNotAllowedException(__('No valid individual found. Either supply it in the request or set the individual_id to a valid value.')); + } $this->Users->enrollUserRouter($data); return $data; } @@ -84,9 +107,7 @@ class UsersController extends AppController } $dropdownData = [ 'role' => $validRoles, - 'individual' => $this->Users->Individuals->find('list', [ - 'sort' => ['email' => 'asc'] - ]), + 'individual' => $individuals, 'organisation' => $this->Users->Organisations->find('list', [ 'sort' => ['name' => 'asc'], 'conditions' => $org_conditions @@ -136,7 +157,7 @@ class UsersController extends AppController $params = [ 'get' => [ 'fields' => [ - 'id', 'individual_id', 'role_id', 'username', 'disabled' + 'id', 'individual_id', 'role_id', 'disabled', 'username' ] ], 'removeEmpty' => [ @@ -148,12 +169,10 @@ class UsersController extends AppController ]; if (!empty($this->ACL->getUser()['role']['perm_admin'])) { $params['fields'][] = 'individual_id'; - $params['fields'][] = 'username'; $params['fields'][] = 'role_id'; $params['fields'][] = 'organisation_id'; $params['fields'][] = 'disabled'; } else if (!empty($this->ACL->getUser()['role']['perm_org_admin'])) { - $params['fields'][] = 'username'; $params['fields'][] = 'role_id'; $params['fields'][] = 'disabled'; if (!$currentUser['role']['perm_admin']) { From 8fdb8668c84611e15dd2cbef4abd3270938f1338 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 24 Feb 2022 13:46:35 +0100 Subject: [PATCH 02/10] fix: [alignments] saving of the alignment was omitted before --- src/Model/Table/AlignmentsTable.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Model/Table/AlignmentsTable.php b/src/Model/Table/AlignmentsTable.php index c05ad90..3cbf5c5 100644 --- a/src/Model/Table/AlignmentsTable.php +++ b/src/Model/Table/AlignmentsTable.php @@ -42,6 +42,7 @@ class AlignmentsTable extends AppTable 'type' => $type ]; $this->patchEntity($alignment, $data); + $this->save($alignment); } } } From 3790244ce417f8ec1b6029ad512664b21f5f7db5 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 24 Feb 2022 13:47:08 +0100 Subject: [PATCH 03/10] new: [individuals] new finder method to find by alignment --- src/Model/Table/IndividualsTable.php | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Model/Table/IndividualsTable.php b/src/Model/Table/IndividualsTable.php index 16a3da2..de5b8e8 100644 --- a/src/Model/Table/IndividualsTable.php +++ b/src/Model/Table/IndividualsTable.php @@ -5,6 +5,8 @@ namespace App\Model\Table; use App\Model\Table\AppTable; use Cake\ORM\Table; use Cake\Validation\Validator; +use Cake\ORM\Query; + class IndividualsTable extends AppTable { @@ -55,7 +57,9 @@ class IndividualsTable extends AppTable 'uuid' => $individual['uuid'] ])->first(); } else { - return null; + $existingIndividual = $this->find()->where([ + 'email' => $individual['email'] + ])->first(); } if (empty($existingIndividual)) { $entityToSave = $this->newEmptyEntity(); @@ -90,4 +94,16 @@ class IndividualsTable extends AppTable } } } + + public function findAligned(Query $query, array $options) + { + $query = $query->select(['Individuals.id']); + if (empty($options['organisation_id'])) { + $query->leftJoinWith('Alignments')->where(['Alignments.organisation_id IS' => null]); + } else { + $query->innerJoinWith('Alignments') + ->where(['Alignments.organisation_id IN' => $options['organisation_id']]); + } + return $query->group(['Individuals.id', 'Individuals.uuid']); + } } From 304586ff1971dfd159b8081d3345a19ff2e99ad4 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 24 Feb 2022 13:47:49 +0100 Subject: [PATCH 04/10] chg: [user] view add link to user's individual --- templates/Users/view.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/Users/view.php b/templates/Users/view.php index fbddf52..3af7c4c 100644 --- a/templates/Users/view.php +++ b/templates/Users/view.php @@ -17,8 +17,11 @@ echo $this->element( 'path' => 'username' ], [ + 'type' => 'generic', 'key' => __('Email'), - 'path' => 'individual.email' + 'path' => 'individual.email', + 'url' => '/individuals/view/{{0}}', + 'url_vars' => 'individual_id' ], [ 'type' => 'generic', From 678ad0fe8e9749aefdb0ec3470801e09a84828c7 Mon Sep 17 00:00:00 2001 From: iglocska Date: Thu, 24 Feb 2022 13:48:10 +0100 Subject: [PATCH 05/10] chg: [templates] for user creation now have a minimalist individiual creation included --- templates/Users/add.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/templates/Users/add.php b/templates/Users/add.php index d3fcb7f..3365150 100644 --- a/templates/Users/add.php +++ b/templates/Users/add.php @@ -1,4 +1,7 @@ request->getParam('action') === 'add') { + $dropdownData['individual'] = ['new' => __('New individual')] + $dropdownData['individual']; + } echo $this->element('genericElements/Form/genericForm', [ 'data' => [ 'description' => __('Roles define global rules for a set of users, including first and foremost access controls to certain functionalities.'), @@ -10,6 +13,32 @@ 'label' => __('Associated individual'), 'options' => $dropdownData['individual'] ], + [ + 'field' => 'individual.email', + 'stateDependence' => [ + 'source' => '#individual_id-field', + 'option' => 'new' + ], + 'required' => false + ], + [ + 'field' => 'individual.first_name', + 'label' => 'First name', + 'stateDependence' => [ + 'source' => '#individual_id-field', + 'option' => 'new' + ], + 'required' => false + ], + [ + 'field' => 'individual.last_name', + 'label' => 'Last name', + 'stateDependence' => [ + 'source' => '#individual_id-field', + 'option' => 'new' + ], + 'required' => false + ], [ 'field' => 'username', 'autocomplete' => 'off' From 6f6c10670eee8e0bff5d39d373832512ab9a9c61 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 25 Feb 2022 00:30:50 +0100 Subject: [PATCH 06/10] new: [CRUD] added beforeMarshal hook --- src/Controller/Component/CRUDComponent.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 14369ab..41fe9d1 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -169,6 +169,12 @@ class CRUDComponent extends Component if (!empty($params['fields'])) { $patchEntityParams['fields'] = $params['fields']; } + if (isset($params['beforeMarshal'])) { + $input = $params['beforeMarshal']($input); + if ($input === false) { + throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); @@ -310,9 +316,18 @@ class CRUDComponent extends Component if (!empty($params['fields'])) { $patchEntityParams['fields'] = $params['fields']; } + if (isset($params['beforeMarshal'])) { + $input = $params['beforeMarshal']($input); + if ($input === false) { + throw new NotFoundException(__('Could not save {0} due to the marshaling failing. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } + } $data = $this->Table->patchEntity($data, $input, $patchEntityParams); if (isset($params['beforeSave'])) { $data = $params['beforeSave']($data); + if ($data === false) { + throw new NotFoundException(__('Could not save {0} due to the input failing to meet expectations. Your input is bad and you should feel bad.', $this->ObjectAlias)); + } } $savedData = $this->Table->save($data); if ($savedData !== false) { From 79459838eb7871edfb4bf5e453d76bb766dda2f7 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 25 Feb 2022 00:31:19 +0100 Subject: [PATCH 07/10] chg: [user add] if no password was set, set a random one - can't be used so far as we have no emailing in place - it allows user creation when username/password mode is disabled --- src/Controller/UsersController.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index ab21c42..9347e6c 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -9,6 +9,7 @@ use \Cake\Database\Expression\QueryExpression; use Cake\Http\Exception\UnauthorizedException; use Cake\Http\Exception\MethodNotAllowedException; use Cake\Core\Configure; +use Cake\Utility\Security; class UsersController extends AppController { @@ -47,6 +48,7 @@ class UsersController extends AppController $individuals_params = [ 'sort' => ['email' => 'asc'] ]; + $individual_ids = []; if (!$currentUser['role']['perm_admin']) { $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0])->all()->toArray(); $individual_ids = $this->Users->Individuals->find('aligned', ['organisation_id' => $currentUser['organisation_id']])->all()->extract('id')->toArray(); @@ -60,6 +62,12 @@ class UsersController extends AppController $defaultRole = $this->Users->Roles->find()->select(['id'])->first()->toArray(); $individuals = $this->Users->Individuals->find('list', $individuals_params)->toArray(); $this->CRUD->add([ + 'beforeMarshal' => function($data) { + if (empty($data['password'])) { + $data['password'] = Security::randomString(20); + } + return $data; + }, 'beforeSave' => function($data) use ($currentUser, $validRoles, $defaultRole, $individual_ids) { if (!isset($data['role_id']) && !empty($defaultRole)) { $data['role_id'] = $defaultRole['id']; From 4902a3f8a6e1fa1ceeed68ffa730ba0d994c7b67 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 25 Feb 2022 00:33:00 +0100 Subject: [PATCH 08/10] new: [password auth] added setting to disable password auth - not needed in some cases for keycloak enabled instances --- .../CerebrateSettingsProvider.php | 32 +++++++++++++++++++ templates/Users/add.php | 15 ++++++--- templates/Users/login.php | 27 +++++++++------- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index 908d9aa..db8d1e4 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -151,6 +151,17 @@ class CerebrateSettingsProvider extends BaseSettingsProvider ], 'Authentication' => [ 'Providers' => [ + 'PasswordAuth' => [ + 'password_auth.enabled' => [ + 'name' => 'Disable password authentication', + 'type' => 'boolean', + 'severity' => 'warning', + 'description' => __('Enable username/password authentication.'), + 'default' => true, + 'test' => 'testEnabledAuth', + 'authentication_type' => 'password_auth' + ], + ], 'KeyCloak' => [ 'keycloak.enabled' => [ 'name' => 'Enabled', @@ -158,6 +169,7 @@ class CerebrateSettingsProvider extends BaseSettingsProvider 'severity' => 'warning', 'description' => __('Enable keycloak authentication'), 'default' => false, + 'authentication_type' => 'keycloak' ], 'keycloak.provider.applicationId' => [ 'name' => 'Client ID', @@ -374,4 +386,24 @@ class CerebrateSettingValidator extends SettingValidator } return true; } + + public function testEnabledAuth($value, &$setting) + { + $providers = [ + 'password_auth', + 'keycloak' + ]; + if (!$value) { + $foundEnabledAuth = __('Cannot make change - this would disable every possible authentication method.'); + foreach ($providers as $provider) { + if ($provider !== $setting['authentication_type']) { + if (Configure::read($provider . '.enable')) { + $foundEnabledAuth = true; + } + } + } + return $foundEnabledAuth; + } + return true; + } } diff --git a/templates/Users/add.php b/templates/Users/add.php index 3365150..1f68ae5 100644 --- a/templates/Users/add.php +++ b/templates/Users/add.php @@ -1,6 +1,11 @@ request->getParam('action') === 'add') { $dropdownData['individual'] = ['new' => __('New individual')] + $dropdownData['individual']; + if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) { + $passwordRequired = 'required'; + } } echo $this->element('genericElements/Form/genericForm', [ 'data' => [ @@ -54,16 +59,18 @@ 'field' => 'password', 'label' => __('Password'), 'type' => 'password', - 'required' => $this->request->getParam('action') === 'add' ? 'required' : false, + 'required' => $passwordRequired, 'autocomplete' => 'new-password', - 'value' => '' + 'value' => '', + 'requirements' => (bool)$passwordRequired ], [ 'field' => 'confirm_password', 'label' => __('Confirm Password'), 'type' => 'password', - 'required' => $this->request->getParam('action') === 'add' ? 'required' : false, - 'autocomplete' => 'off' + 'required' => $passwordRequired, + 'autocomplete' => 'off', + 'requirements' => (bool)$passwordRequired ], [ 'field' => 'role_id', diff --git a/templates/Users/login.php b/templates/Users/login.php index 8f2e3b2..7317a43 100644 --- a/templates/Users/login.php +++ b/templates/Users/login.php @@ -12,26 +12,29 @@ use Cake\Core\Configure; 'style' => ['filter: drop-shadow(4px 4px 4px #924da666);'] ]) ); - echo sprintf('

%s

', __('Sign In')); $template = [ 'inputContainer' => '
{{content}}
', 'formGroup' => '{{input}}{{label}}', 'submitContainer' => '
{{content}}
', ]; $this->Form->setTemplates($template); - echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]); - echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]); - echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); - echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']); - echo $this->Form->end(); - if (!empty(Configure::read('security.registration.self-registration'))) { - echo '
'; - echo sprintf('%s %s', __('Doesn\'t have an account?'), __('Sign up')); - echo '
'; + if (!Configure::check('password_auth.enabled') || Configure::read('password_auth.enabled')) { + echo sprintf('

%s

', __('Sign In')); + echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]); + echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]); + echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]); + echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']); + echo $this->Form->end(); + if (!empty(Configure::read('security.registration.self-registration'))) { + echo '
'; + echo sprintf('%s %s', __('Don\'t have an account?'), __('Sign up')); + echo '
'; + } + if (!empty(Configure::read('keycloak.enabled'))) { + echo sprintf('

%s
', __('Or')); + } } - if (!empty(Configure::read('keycloak.enabled'))) { - echo sprintf('

%s
', __('Or')); echo $this->Form->create(null, [ 'url' => Cake\Routing\Router::url([ 'prefix' => false, From 9d04533e14d568fe3ee986e9d49d1de8e3a7fdd3 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 25 Feb 2022 10:20:25 +0100 Subject: [PATCH 09/10] chg: [users] restrict org admins from creating other org admins - temporary solution for a single community, make this optional in the future --- src/Controller/UsersController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/UsersController.php b/src/Controller/UsersController.php index 9347e6c..27687ba 100644 --- a/src/Controller/UsersController.php +++ b/src/Controller/UsersController.php @@ -50,7 +50,7 @@ class UsersController extends AppController ]; $individual_ids = []; if (!$currentUser['role']['perm_admin']) { - $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0])->all()->toArray(); + $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_org_admin' => 0])->all()->toArray(); $individual_ids = $this->Users->Individuals->find('aligned', ['organisation_id' => $currentUser['organisation_id']])->all()->extract('id')->toArray(); if (empty($individual_ids)) { $individual_ids = [-1]; @@ -147,7 +147,7 @@ class UsersController extends AppController $currentUser = $this->ACL->getUser(); $validRoles = []; if (!$currentUser['role']['perm_admin']) { - $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0])->all()->toArray(); + $validRoles = $this->Users->Roles->find('list')->select(['id', 'name'])->order(['name' => 'asc'])->where(['perm_admin' => 0, 'perm_org_admin' => 0])->all()->toArray(); } else { $validRoles = $this->Users->Roles->find('list')->order(['name' => 'asc'])->all()->toArray(); } From 1e6b6a5abcecf4da76cb3eacbb87b56c84b277f5 Mon Sep 17 00:00:00 2001 From: iglocska Date: Mon, 28 Feb 2022 08:27:22 +0100 Subject: [PATCH 10/10] fix: [settings] added test for keycloak enabled - always require one auth method to be enabled --- src/Model/Table/SettingProviders/CerebrateSettingsProvider.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php index db8d1e4..5ed9f3b 100644 --- a/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php +++ b/src/Model/Table/SettingProviders/CerebrateSettingsProvider.php @@ -169,6 +169,7 @@ class CerebrateSettingsProvider extends BaseSettingsProvider 'severity' => 'warning', 'description' => __('Enable keycloak authentication'), 'default' => false, + 'test' => 'testEnabledAuth', 'authentication_type' => 'keycloak' ], 'keycloak.provider.applicationId' => [