cerebrate/src/Model/Behavior/AuthKeycloakBehavior.php

506 lines
19 KiB
PHP

<?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\Utility\Security;
use Cake\Utility\Hash;
use \Cake\Http\Session;
use Cake\Core\Configure;
use Cake\Http\Client;
use Cake\Http\Client\FormData;
use Cake\Http\Exception\NotFoundException;
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 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 ($existingUser['individual']['email'] !== $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=' . urlencode($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
]);
$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->_table->find()->contain(['Individuals', 'Organisations', 'Roles'])->select(
[
'id',
'uuid',
'username',
'disabled',
'Individuals.email',
'Individuals.first_name',
'Individuals.last_name',
'Individuals.uuid',
'Roles.name',
'Roles.uuid',
'Organisations.name',
'Organisations.uuid'
]
)->disableHydration()->toArray();
$clientId = $this->getClientId();
return $this->syncUsers($data['Users'], $clientId);
}
private function syncUsers(array $users, $clientId): array
{
$response = $this->restApiRequest('%s/admin/realms/%s/users', [], 'get');
$keycloakUsers = json_decode($response->getStringBody(), true);
$keycloakUsersParsed = [];
foreach ($keycloakUsers as $u) {
$keycloakUsersParsed[$u['username']] = [
'id' => $u['id'],
'username' => $u['username'],
'enabled' => $u['enabled'],
'firstName' => $u['firstName'],
'lastName' => $u['lastName'],
'email' => $u['email'],
'attributes' => [
'role_name' => $u['attributes']['role_name'][0] ?? '',
'role_uuid' => $u['attributes']['role_uuid'][0] ?? '',
'org_uuid' => $u['attributes']['org_uuid'][0] ?? '',
'org_name' => $u['attributes']['org_name'][0] ?? ''
]
];
}
$changes = [
'created' => [],
'modified' => [],
];
foreach ($users as &$user) {
$changed = false;
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'];
}
}
}
return $changes;
}
private function checkAndUpdateUser(array $keycloakUser, array $user): bool
{
if (
$keycloakUser['enabled'] == $user['disabled'] ||
$keycloakUser['firstName'] !== $user['individual']['first_name'] ||
$keycloakUser['lastName'] !== $user['individual']['last_name'] ||
$keycloakUser['email'] !== $user['individual']['email'] ||
(empty($keycloakUser['attributes']['role_name']) || $keycloakUser['attributes']['role_name'] !== $user['role']['name']) ||
(empty($keycloakUser['attributes']['role_uuid']) || $keycloakUser['attributes']['role_uuid'] !== $user['role']['uuid']) ||
(empty($keycloakUser['attributes']['org_name']) || $keycloakUser['attributes']['org_name'] !== $user['organisation']['name']) ||
(empty($keycloakUser['attributes']['org_uuid']) || $keycloakUser['attributes']['org_uuid'] !== $user['organisation']['uuid'])
) {
$change = [
'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/' . $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;
}
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;
}
}