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 authenticate user. The KeyCloak and Cerebrate states of the user differ. This could be due to a missing synchronisation of the data.'); } return $user; } private function extractProfileData($profile_payload) { $mapping = Configure::read('keycloak.mapping'); $fields = [ '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]; } } $existingUser = $this->_table->find() ->where(['username' => $profile_payload[$fields['username']]]) ->contain('Individuals') ->first(); if (mb_strtolower($existingUser['individual']['email']) !== mb_strtolower($profile_payload[$fields['email']])) { return false; } return $existingUser; } /* * Run a rest query against keycloak * Auto sets the headers and uses a sprintf string to build the URL, injecting the baseurl + realm into the $pathString */ private function restApiRequest(string $pathString, array $payload, string $postRequestType = 'post'): Object { $token = $this->getAdminAccessToken(); $keycloakConfig = Configure::read('keycloak'); $http = new Client(); $url = sprintf( $pathString, $keycloakConfig['provider']['baseUrl'], $keycloakConfig['provider']['realm'] ); return $http->$postRequestType( $url, json_encode($payload), [ 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $token ] ] ); } public function getUserIdByUsername(string $username) { $response = $this->restApiRequest( '%s/admin/realms/%s/users/?username=' . $this->urlencodeEscapeForSprintf($username), [], 'GET' ); if (!$response->isOk()) { $responseBody = json_decode($response->getStringBody(), true); $this->_table->auditLogs()->insert([ 'request_action' => 'keycloakGetUser', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to fetch user ({0}) from keycloak', $username), 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] ]); } $responseBody = json_decode($response->getStringBody(), true); if (empty($responseBody[0]['id'])) { return false; } return $responseBody[0]['id']; } public function deleteUser($data): bool { $userId = $this->getUserIdByUsername($data['username']); if ($userId === false) { $this->_table->auditLogs()->insert([ 'request_action' => 'keycloakUserDeletion', 'model' => 'User', 'model_id' => 0, 'model_title' => __('User {0} not found in keycloak, deleting the user locally.', $data['username']), 'changed' => [] ]); return true; } $response = $this->restApiRequest( '%s/admin/realms/%s/users/' . urlencode($userId), [], 'delete' ); if (!$response->isOk()) { $responseBody = json_decode($response->getStringBody(), true); $this->_table->auditLogs()->insert([ 'request_action' => 'keycloakUserDeletion', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to delete user {0} ({1}) in keycloak', $data['username'], $userId), 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] ]); return false; } return true; } public function enrollUser($data): bool { $roleConditions = [ 'id' => $data['role_id'] ]; $user = [ 'username' => $data['username'], 'disabled' => false, 'individual' => $this->_table->Individuals->find()->where( [ 'id' => $data['individual_id'] ] )->first(), 'role' => $this->_table->Roles->find()->where($roleConditions)->first(), 'organisation' => $this->_table->Organisations->find()->where( [ 'id' => $data['organisation_id'] ] )->first() ]; $clientId = $this->getClientId(); $newUserId = $this->createUser($user, $clientId); if (!$newUserId) { $logChange = [ 'username' => $user['username'], 'individual_id' => $user['individual']['id'], 'role_id' => $user['role']['id'] ]; $this->_table->auditLogs()->insert([ 'request_action' => 'enrollUser', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed Keycloak enrollment for user {0}', $user['username']), 'changed' => $logChange ]); } else { $logChange = [ 'username' => $user['username'], 'individual_id' => $user['individual']['id'], 'role_id' => $user['role']['id'] ]; $this->_table->auditLogs()->insert([ 'request_action' => 'enrollUser', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Successful Keycloak enrollment for user {0}', $user['username']), 'changed' => $logChange ]); $saved_user = $this->getCerebrateUsers($user['id']); $clientId = $this->getClientId(); $this->syncUsers($saved_user, $clientId); $response = $this->restApiRequest( '%s/admin/realms/%s/users/' . urlencode($newUserId) . '/execute-actions-email', ['UPDATE_PASSWORD'], 'put' ); if (!$response->isOk()) { $responseBody = json_decode($response->getStringBody(), true); $this->_table->auditLogs()->insert([ 'request_action' => 'keycloakWelcomeEmail', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to send welcome mail to user ({0}) in keycloak', $user['username']), 'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']] ]); } } return true; } /** * handleUserUpdate * * @param \App\Model\Entity\User $user * @return array Containing changes if successful */ public function handleUserUpdate(\App\Model\Entity\User $user): array { $user['individual'] = $this->_table->Individuals->find()->where([ 'id' => $user['individual_id'] ])->first(); $user['role'] = $this->_table->Roles->find()->where([ 'id' => $user['role_id'] ])->first(); $user['organisation'] = $this->_table->Organisations->find()->where([ 'id' => $user['organisation_id'] ])->first(); $users = [$user->toArray()]; $clientId = $this->getClientId(); $changes = $this->syncUsers($users, $clientId); return $changes; } public function keyCloaklogout(): string { $keycloakConfig = Configure::read('keycloak'); $logoutUrl = sprintf( '%s/realms/%s/protocol/openid-connect/logout?redirect_uri=%s', $keycloakConfig['provider']['baseUrl'], $keycloakConfig['provider']['realm'], urlencode(Configure::read('App.fullBaseUrl')) ); return $logoutUrl; } 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']; } private function getClientId(): string { $response = $this->restApiRequest('%s/admin/realms/%s/clients?clientId=' . Configure::read('keycloak.provider.applicationId'), [], 'get'); $clientId = json_decode($response->getStringBody(), true); if (!empty($clientId[0]['id'])) { return $clientId[0]['id']; } else { throw new NotFoundException(__('Keycloak client ID not found or service account doesn\'t have the "view-clients" privilege.')); } } public function syncWithKeycloak(): array { $this->updateMappers(); $results = []; $data['Users'] = $this->getCerebrateUsers(); $clientId = $this->getClientId(); return $this->syncUsers($data['Users'], $clientId); } private function syncUsers(array $users, $clientId): array { $keycloakUsersParsed = $this->getParsedKeycloakUser(); $changes = [ 'created' => [], 'modified' => [], ]; foreach ($users as &$user) { try { if (empty($keycloakUsersParsed[$user['username']])) { if ($this->createUser($user, $clientId)) { $changes['created'][] = $user['username']; } } else { if ($this->checkAndUpdateUser($keycloakUsersParsed[$user['username']], $user)) { $changes['modified'][] = $user['username']; } } } catch (\Exception $e) { $this->_table->auditLogs()->insert([ 'request_action' => 'syncUsers', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to create or modify user ({0}) in keycloak', $user['username']), 'changed' => [ 'message' => $e->getMessage(), ] ]); } } return $changes; } public function getParsedKeycloakUser(): array { $response = $this->restApiRequest('%s/admin/realms/%s/users/?max=999999', [], 'get'); $keycloakUsers = json_decode($response->getStringBody(), true); $keycloakUsersParsed = []; $mappers = array_merge(['role_name', 'role_uuid', 'org_uuid', 'org_name'], $this->getMappedFieldList()); foreach ($keycloakUsers as $u) { $attributes = []; $keycloakUsersParsed[$u['username']] = [ 'id' => $u['id'], 'username' => $u['username'], 'enabled' => $u['enabled'], 'firstName' => $u['firstName'], 'lastName' => $u['lastName'], 'email' => $u['email'], 'attributes' => [] ]; foreach ($mappers as $mapper) { $keycloakUsersParsed[$u['username']]['attributes'][$mapper] = $u['attributes'][$mapper][0] ?? ''; } } return $keycloakUsersParsed; } private function getCerebrateUsers($id = null): array { $metaFieldsSelector = ['fields' => ['MetaFields.field', 'MetaFields.parent_id', 'MetaFields.value']]; $query = $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles', 'MetaFields' => $metaFieldsSelector])->select([ 'id', 'uuid', 'username', 'disabled', 'Individuals.email', 'Individuals.first_name', 'Individuals.last_name', 'Individuals.uuid', 'Roles.name', 'Roles.uuid', 'Organisations.name', 'Organisations.uuid' ]); if ($id) { $query->where(['User.id' => $id]); } $results = $query->disableHydration()->toArray(); foreach ($results as &$result) { if (!empty($result['meta_fields'])) { $temp = []; foreach ($result['meta_fields'] as $meta_field) { $temp[$meta_field['field']] = $meta_field['value']; } $result['meta_fields'] = $temp; } } return $results; } private function checkAndUpdateUser(array $keycloakUser, array $user): bool { if (!empty($user['meta_fields'][0])) { $temp = []; foreach ($user['meta_fields'] as $meta_field) { $temp[$meta_field['field']] = $meta_field['value']; } $user['meta_fields'] = $temp; } if ($this->checkKeycloakUserRequiresUpdate($keycloakUser, $user)) { $default_mappers = [ 'role' => [ 'uuid', 'name' ], 'organisation' => [ 'uuid', 'name' ] ]; $custom_mappers = $this->getMappedFieldList(); $change = [ 'enabled' => !$user['disabled'], 'firstName' => $user['individual']['first_name'], 'lastName' => $user['individual']['last_name'], 'email' => $user['individual']['email'], 'attributes' => [] ]; foreach ($default_mappers as $object_type => $object_data) { foreach ($object_data as $mapper) { $object_type_kc = $object_type === 'organisation' ? 'org' : $object_type; $change['attributes'][$object_type_kc . '_' . $mapper] = $user[$object_type][$mapper]; } } foreach ($custom_mappers as $mapper) { $change['attributes'][$mapper] = $user['meta_fields'][$mapper] ?? ''; } $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'], $change, 'put'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ 'request_action' => 'keycloakUpdateUser', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to update user ({0}) in keycloak', $user['username']), 'changed' => [ 'code' => $response->getStatusCode(), 'error_body' => $response->getStringBody() ] ]); } else { return true; } } return false; } public function checkKeycloakStatus(array $users, array $keycloakUsers): array { $users = Hash::combine($users, '{n}.username', '{n}'); $keycloakUsersParsed = Hash::combine($keycloakUsers, '{n}.username', '{n}'); $status = []; foreach ($users as $username => $user) { $temp = []; foreach ($user['meta_fields'] as $meta_field) { $temp[$meta_field['field']] = $meta_field['value']; } $user['meta_fields'] = $temp; $differences = []; $keycloakUser = $keycloakUsersParsed[$username] ?? []; if (empty($keycloakUser)) { $requireUpdate = true; $differences = [ 'user' => [ 'keycloak' => __('ERROR or USER NOT FOUND'), 'cerebrate' => $user['username'] ] ]; } else { $requireUpdate = $this->checkKeycloakUserRequiresUpdate($keycloakUser, $user, $differences); } $status[$user['id']] = [ 'require_update' => $requireUpdate, 'differences' => $differences, ]; } return $status; } private function checkKeycloakUserRequiresUpdate(array $keycloakUser, array $user, array &$differences = []): bool { $mappedFields = $this->getMappedFieldList(); $condEnabled = $keycloakUser['enabled'] == $user['disabled']; $condFirstname = mb_strtolower($keycloakUser['firstName']) !== mb_strtolower($user['individual']['first_name']); $condLastname = mb_strtolower($keycloakUser['lastName']) !== mb_strtolower($user['individual']['last_name']); $condEmail = mb_strtolower($keycloakUser['email']) !== mb_strtolower($user['individual']['email']); $condRolename = (empty($keycloakUser['attributes']['role_name']) || mb_strtolower($keycloakUser['attributes']['role_name']) !== mb_strtolower($user['role']['name'])); $condRoleuuid = (empty($keycloakUser['attributes']['role_uuid']) || mb_strtolower($keycloakUser['attributes']['role_uuid']) !== mb_strtolower($user['role']['uuid'])); $condOrgname = (empty($keycloakUser['attributes']['org_name']) || mb_strtolower($keycloakUser['attributes']['org_name']) !== mb_strtolower($user['organisation']['name'])); $condOrguuid = (empty($keycloakUser['attributes']['org_uuid']) || mb_strtolower($keycloakUser['attributes']['org_uuid']) !== mb_strtolower($user['organisation']['uuid'])); $condMapped = false; if (!empty($user['meta_fields']) && isset($user['meta_fields'][0])) { $temp = []; foreach ($user['meta_fields'] as $meta_field) { $temp[$meta_field['field']] = $meta_field['value']; } $user['meta_fields'] = $temp; } foreach ($mappedFields as $mappedField) { if (($keycloakUser['attributes'][$mappedField] ?? '') != ($user['meta_fields'][$mappedField] ?? '')) { $condMapped = true; $differences[$mappedField] = [ 'keycloak' => $keycloakUser['attributes'][$mappedField] ?? '', 'cerebrate' => $user['meta_fields'][$mappedField] ?? '' ]; } } if ($condEnabled || $condFirstname || $condLastname || $condEmail || $condRolename || $condRoleuuid || $condOrgname || $condOrguuid || $condMapped) { if ($condEnabled) { $differences['enabled'] = ['keycloak' => $keycloakUser['enabled'], 'cerebrate' => $user['disabled']]; } if ($condFirstname) { $differences['first_name'] = ['keycloak' => $keycloakUser['firstName'], 'cerebrate' => $user['individual']['first_name']]; } if ($condLastname) { $differences['last_name'] = ['keycloak' => $keycloakUser['lastName'], 'cerebrate' => $user['individual']['last_name']]; } if ($condEmail) { $differences['email'] = ['keycloak' => $keycloakUser['email'], 'cerebrate' => $user['individual']['email']]; } if ($condRolename) { $differences['role_name'] = ['keycloak' => $keycloakUser['attributes']['role_name'], 'cerebrate' => $user['role']['name']]; } if ($condRoleuuid) { $differences['role_uuid'] = ['keycloak' => $keycloakUser['attributes']['role_uuid'], 'cerebrate' => $user['role']['uuid']]; } if ($condOrgname) { $differences['org_name'] = ['keycloak' => $keycloakUser['attributes']['org_name'], 'cerebrate' => $user['organisation']['name']]; } if ($condOrguuid) { $differences['org_uuid'] = ['keycloak' => $keycloakUser['attributes']['org_uuid'], 'cerebrate' => $user['organisation']['uuid']]; } return true; } return false; } private function createUser(array $user, string $clientId) { $newUser = [ 'username' => $user['username'], 'enabled' => !$user['disabled'], 'firstName' => $user['individual']['first_name'], 'lastName' => $user['individual']['last_name'], 'email' => $user['individual']['email'], 'attributes' => [ 'role_name' => $user['role']['name'], 'role_uuid' => $user['role']['uuid'], 'org_name' => $user['organisation']['name'], 'org_uuid' => $user['organisation']['uuid'] ] ]; $response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post'); if (!$response->isOk()) { $this->_table->auditLogs()->insert([ 'request_action' => 'createUser', 'model' => 'User', 'model_id' => 0, 'model_title' => __('Failed to create user ({0}) in keycloak {0}', $user['username']), 'changed' => [ 'code' => $response->getStatusCode(), 'error_body' => $response->getStringBody() ] ]); } $newUser = $this->restApiRequest( '%s/admin/realms/%s/users?username=' . $this->urlencodeEscapeForSprintf(urlencode($user['username'])), [], 'get' ); $users = json_decode($newUser->getStringBody(), true); if (empty($users[0]['id'])) { return false; } if (is_array($users[0]['id'])) { $users[0]['id'] = $users[0]['id'][0]; } $user['id'] = $users[0]['id']; return $user['id']; } private function urlencodeEscapeForSprintf(string $input): string { return str_replace('%', '%%', $input); } public function updateMappers(): bool { $clientId = $this->getClientId(); $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/models?protocolMapper=oidc-usermodel-attribute-mapper', [], 'get'); if ($response->isOk()) { $mappers = json_decode($response->getStringBody(), true); } else { return false; } $enabledMappers = []; $defaultMappers = [ 'org_name' => 0, 'org_uuid' => 0, 'role_name' => 0, 'role_uuid' => 0 ]; $mappersToEnable = explode(',', Configure::read('keycloak.user_meta_mapping')); foreach ($mappers as $mapper) { if ($mapper['protocolMapper'] !== 'oidc-usermodel-attribute-mapper') { continue; } if (in_array($mapper['name'], array_keys($defaultMappers))) { $defaultMappers[$mapper['name']] = 1; continue; } $enabledMappers[$mapper['name']] = $mapper; } $payload = []; foreach ($mappersToEnable as $mapperToEnable) { $payload[] = [ 'protocol' => 'openid-connect', 'name' => $mapperToEnable, 'protocolMapper' => 'oidc-usermodel-attribute-mapper', 'config' => [ 'id.token.claim' => true, 'access.token.claim' => true, 'userinfo.token.claim' => true, 'user.attribute' => $mapperToEnable, 'claim.name' => $mapperToEnable ] ]; } foreach ($defaultMappers as $defaultMapper => $enabled) { if (!$enabled) { $payload[] = [ 'protocol' => 'openid-connect', 'name' => $defaultMapper, 'protocolMapper' => 'oidc-usermodel-attribute-mapper', 'config' => [ 'id.token.claim' => true, 'access.token.claim' => true, 'userinfo.token.claim' => true, 'user.attribute' => $defaultMapper, 'claim.name' => $defaultMapper ] ]; } } if (!empty($payload)) { $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/add-models', $payload, 'post'); if (!$response->isOk()) { return false; } } return true; } }