Merge branch 'develop'
commit
2acadfb587
|
@ -13,11 +13,12 @@
|
|||
"cakephp/migrations": "^3.0",
|
||||
"cakephp/plugin-installer": "^1.2",
|
||||
"erusev/parsedown": "^1.7",
|
||||
"mobiledetect/mobiledetectlib": "^2.8"
|
||||
"mobiledetect/mobiledetectlib": "^2.8",
|
||||
"pear/crypt_gpg": "^1.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"cakephp/bake": "^2.0.3",
|
||||
"cakephp/cakephp-codesniffer": "~4.0.0",
|
||||
"cakephp/cakephp-codesniffer": "^4.0",
|
||||
"cakephp/debug_kit": "^4.0",
|
||||
"cebe/php-openapi": "^1.6",
|
||||
"fzaninotto/faker": "^1.9",
|
||||
|
@ -68,7 +69,11 @@
|
|||
},
|
||||
"prefer-stable": true,
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true,
|
||||
"cakephp/plugin-installer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Migrations\AbstractMigration;
|
||||
|
||||
final class Bruteforces extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
public function change(): void
|
||||
{
|
||||
$exists = $this->hasTable('bruteforces');
|
||||
if (!$exists) {
|
||||
$table = $this->table('bruteforces', [
|
||||
'signed' => false,
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
]);
|
||||
$table
|
||||
->addColumn('user_ip', 'string', [
|
||||
'null' => false,
|
||||
'length' => 45,
|
||||
])
|
||||
->addColumn('username', 'string', [
|
||||
'null' => false,
|
||||
'length' => 191,
|
||||
'collation' => 'utf8mb4_unicode_ci'
|
||||
])
|
||||
->addColumn('expiration', 'datetime', [
|
||||
'null' => false
|
||||
])
|
||||
->addIndex('user_ip')
|
||||
->addIndex('username')
|
||||
->addIndex('expiration');
|
||||
$table->create();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Migrations\AbstractMigration;
|
||||
|
||||
final class UniqueUserNames extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->table('users');
|
||||
$exists = $table->hasIndexByName('users', 'username');
|
||||
$this->execute('DELETE FROM users WHERE id NOT IN (SELECT MIN(id) FROM (select * from users) AS u2 GROUP BY LOWER(u2.username));');
|
||||
if (!$exists) {
|
||||
$table->addIndex(
|
||||
[
|
||||
'username'
|
||||
],
|
||||
[
|
||||
'unique' => true
|
||||
]
|
||||
)->save();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -35,8 +35,8 @@ use Cake\Cache\Cache;
|
|||
use Cake\Core\Configure;
|
||||
use Cake\Core\Configure\Engine\PhpConfig;
|
||||
use Cake\Datasource\ConnectionManager;
|
||||
use Cake\Error\ConsoleErrorHandler;
|
||||
use Cake\Error\ErrorHandler;
|
||||
use Cake\Error\ErrorTrap;
|
||||
use Cake\Error\ExceptionTrap;
|
||||
use Cake\Filesystem\File;
|
||||
use Cake\Http\ServerRequest;
|
||||
use Cake\Log\Log;
|
||||
|
@ -132,9 +132,9 @@ ini_set('intl.default_locale', Configure::read('App.defaultLocale'));
|
|||
*/
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
if ($isCli) {
|
||||
(new ConsoleErrorHandler(Configure::read('Error')))->register();
|
||||
(new ErrorTrap(Configure::read('Error')))->register();
|
||||
} else {
|
||||
(new ErrorHandler(Configure::read('Error')))->register();
|
||||
(new ExceptionTrap(Configure::read('Error')))->register();
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -13,6 +13,7 @@ class KeycloakSyncCommand extends Command
|
|||
public function execute(Arguments $args, ConsoleIo $io)
|
||||
{
|
||||
if (!empty(Configure::read('keycloak'))) {
|
||||
$this->loadModel('Users');
|
||||
$results = $this->fetchTable()->syncWithKeycloak();
|
||||
$tableData = [
|
||||
['Changes to', 'Count']
|
||||
|
|
|
@ -23,8 +23,8 @@ class AlignmentsController extends AppController
|
|||
$alignments = $query->all();
|
||||
return $this->RestResponse->viewData($alignments, 'json');
|
||||
} else {
|
||||
$this->loadComponent('Paginator');
|
||||
$alignments = $this->Paginator->paginate($query);
|
||||
$this->paginate['contain'] = ['Individuals', 'Organisations'];
|
||||
$alignments = $this->paginate($query);
|
||||
$this->set('data', $alignments);
|
||||
$this->set('metaGroup', 'ContactDB');
|
||||
}
|
||||
|
|
|
@ -99,8 +99,8 @@ class AppController extends Controller
|
|||
{
|
||||
$this->loadModel('Users');
|
||||
$this->Users->checkForNewInstance();
|
||||
$this->authApiUser();
|
||||
if ($this->ParamHandler->isRest()) {
|
||||
$this->authApiUser();
|
||||
$this->Security->setConfig('unlockedActions', [$this->request->getParam('action')]);
|
||||
}
|
||||
$this->ACL->setPublicInterfaces();
|
||||
|
@ -149,6 +149,7 @@ class AppController extends Controller
|
|||
if ($this->modelClass == 'Tags.Tags') {
|
||||
$this->set('metaGroup', !empty($this->isAdmin) ? 'Administration' : 'Cerebrate');
|
||||
}
|
||||
$this->response = $this->response->withHeader('X-Frame-Options', 'DENY');
|
||||
}
|
||||
if (mt_rand(1, 50) === 1) {
|
||||
$this->FloodProtection->cleanup();
|
||||
|
|
|
@ -8,6 +8,7 @@ use Cake\ORM\TableRegistry;
|
|||
use \Cake\Database\Expression\QueryExpression;
|
||||
use Cake\Http\Exception\UnauthorizedException;
|
||||
use Cake\Core\Configure;
|
||||
use PhpParser\Node\Stmt\Echo_;
|
||||
|
||||
class AuditLogsController extends AppController
|
||||
{
|
||||
|
@ -19,11 +20,14 @@ class AuditLogsController extends AppController
|
|||
{
|
||||
$this->CRUD->index([
|
||||
'contain' => $this->containFields,
|
||||
'order' => ['AuditLogs.id' => 'DESC'],
|
||||
'filters' => $this->filterFields,
|
||||
'quickFilters' => $this->quickFilterFields,
|
||||
'afterFind' => function($data) {
|
||||
$data['request_ip'] = inet_ntop(stream_get_contents($data['request_ip']));
|
||||
$data['changed'] = stream_get_contents($data['changed']);
|
||||
$request_ip = is_resource($data['request_ip']) ? stream_get_contents($data['request_ip']) : $data['request_ip'];
|
||||
$change = is_resource($data['changed']) ? stream_get_contents($data['changed']) : $data['changed'];
|
||||
$data['request_ip'] = inet_ntop($request_ip);
|
||||
$data['changed'] = $change;
|
||||
return $data;
|
||||
}
|
||||
]);
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Component;
|
||||
|
||||
use Cake\Controller\Component;
|
||||
use App\Model\Entity\User;
|
||||
use Cake\Http\Exception\NotFoundException;
|
||||
use Cake\Http\Exception\MethodNotAllowedException;
|
||||
use Cake\Http\Exception\ForbiddenException;
|
||||
use Cake\ORM\TableRegistry;
|
||||
use Cake\Core\Configure;
|
||||
use Cake\Core\Configure\Engine\PhpConfig;
|
||||
use Cake\Utility\Inflector;
|
||||
use Cake\Routing\Router;
|
||||
|
||||
class APIRearrangeComponent extends Component
|
||||
{
|
||||
public function rearrangeForAPI(object $data): object
|
||||
{
|
||||
if (is_subclass_of($data, 'Iterator')) {
|
||||
$data->each(function ($value, $key) {
|
||||
$value->rearrangeForAPI();
|
||||
});
|
||||
} else {
|
||||
$data->rearrangeForAPI();
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
|
@ -69,6 +69,9 @@ class CRUDComponent extends Component
|
|||
if (!empty($options['fields'])) {
|
||||
$query->select($options['fields']);
|
||||
}
|
||||
if (!empty($options['order'])) {
|
||||
$query->order($options['order']);
|
||||
}
|
||||
if ($this->Controller->ParamHandler->isRest()) {
|
||||
$data = $query->all();
|
||||
if (isset($options['hidden'])) {
|
||||
|
@ -100,8 +103,7 @@ class CRUDComponent extends Component
|
|||
if ($this->metaFieldsSupported()) {
|
||||
$query = $this->includeRequestedMetaFields($query);
|
||||
}
|
||||
$this->Controller->loadComponent('Paginator');
|
||||
$data = $this->Controller->Paginator->paginate($query, $this->Controller->paginate ?? []);
|
||||
$data = $this->Controller->paginate($query, $this->Controller->paginate ?? []);
|
||||
if (isset($options['afterFind'])) {
|
||||
$function = $options['afterFind'];
|
||||
if (is_callable($function)) {
|
||||
|
@ -145,8 +147,17 @@ class CRUDComponent extends Component
|
|||
if (is_string($statIgnoreNull)) {
|
||||
$statIgnoreNull = $statIgnoreNull == 'true' ? true : false;
|
||||
}
|
||||
$statistics_entry_amount = $this->request->getQuery('statistics_entry_amount');
|
||||
if (
|
||||
!is_numeric($statistics_entry_amount) ||
|
||||
intval($statistics_entry_amount) <= 0
|
||||
) {
|
||||
$statistics_entry_amount = 5;
|
||||
} else {
|
||||
$statistics_entry_amount = intval($statistics_entry_amount);
|
||||
}
|
||||
$statsOptions = [
|
||||
'limit' => !is_numeric($this->request->getQuery('statistics_entry_amount')) ? 5 : $this->request->getQuery('statistics_entry_amount'),
|
||||
'limit' => $statistics_entry_amount,
|
||||
'includeOthers' => $statIncludeRemaining,
|
||||
'ignoreNull' => $statIgnoreNull,
|
||||
];
|
||||
|
@ -382,7 +393,6 @@ class CRUDComponent extends Component
|
|||
} else {
|
||||
$entity->meta_fields = [];
|
||||
}
|
||||
|
||||
$metaFieldsToDelete = [];
|
||||
foreach ($input['MetaTemplates'] as $template_id => $template) {
|
||||
foreach ($template['meta_template_fields'] as $meta_template_field_id => $meta_template_field) {
|
||||
|
|
|
@ -7,6 +7,7 @@ use Cake\Controller\ComponentRegistry;
|
|||
use Cake\Http\Exception\NotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Cake\Controller\Component\PaginatorComponent;
|
||||
use Cake\Datasource\Pagination\NumericPaginator;
|
||||
use Cake\Utility\Hash;
|
||||
|
||||
class CustomPaginationComponent extends Component
|
||||
|
|
|
@ -34,7 +34,7 @@ class FloodProtectionComponent extends Component
|
|||
|
||||
public function check(string $action, int $limit = 5, int $expiration_time = 300): bool
|
||||
{
|
||||
$results = $this->FloodProtections->find('all')->where(['request_action' => $action, 'remote_ip' => $this->remote_ip, 'expiration' > time()])->toList();
|
||||
$results = $this->FloodProtections->find()->where(['request_action' => $action, 'remote_ip' => $this->remote_ip, 'expiration' > time()])->all()->toList();
|
||||
if (count($results) >= $limit) {
|
||||
throw new TooManyRequestsException(__('Too many {0} requests have been issued ({1} requests allowed ever {2} seconds)', [$action, $limit, $expiration_time]));
|
||||
}
|
||||
|
|
|
@ -68,7 +68,7 @@ class MetaTemplatesNavigation extends BaseNavigation
|
|||
public function addActions()
|
||||
{
|
||||
$totalUpdateCount = 0;
|
||||
if (!empty($this->viewVars['updateableTemplates']['not-up-to-date']) && !empty($this->viewVars['updateableTemplates']['new'])) {
|
||||
if (!empty($this->viewVars['updateableTemplates']['not-up-to-date']) || !empty($this->viewVars['updateableTemplates']['new'])) {
|
||||
$udpateCount = count($this->viewVars['updateableTemplates']['not-up-to-date']) ?? 0;
|
||||
$newCount = count($this->viewVars['updateableTemplates']['new']) ?? 0;
|
||||
$totalUpdateCount = $udpateCount + $newCount;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
namespace BreadcrumbNavigation;
|
||||
|
||||
use Cake\Core\Configure;
|
||||
require_once(APP . 'Controller' . DS . 'Component' . DS . 'Navigation' . DS . 'base.php');
|
||||
|
||||
class UsersNavigation extends BaseNavigation
|
||||
|
@ -24,6 +25,7 @@ class UsersNavigation extends BaseNavigation
|
|||
$bcf = $this->bcf;
|
||||
$request = $this->request;
|
||||
$passedData = $this->request->getParam('pass');
|
||||
$currentUserId = empty($this->currentUserId) ? null : $this->currentUserId;
|
||||
$currentUser = $this->currentUser;
|
||||
$this->bcf->addLink('Users', 'view', 'UserSettings', 'index', function ($config) use ($bcf, $request, $passedData, $currentUser) {
|
||||
if (!empty($passedData[0])) {
|
||||
|
@ -69,6 +71,23 @@ class UsersNavigation extends BaseNavigation
|
|||
}
|
||||
return [];
|
||||
});
|
||||
if (
|
||||
!empty($this->loggedUser['social_profile']) &&
|
||||
!empty(Configure::read('keycloak.enabled')) &&
|
||||
!empty(Configure::read('keycloak.provider.baseUrl')) &&
|
||||
!empty(Configure::read('keycloak.provider.realm')) &&
|
||||
!empty($passedData[0]) &&
|
||||
$currentUserId == $passedData[0]
|
||||
) {
|
||||
$url = sprintf(
|
||||
'%s/realms/%s/account',
|
||||
Configure::read('keycloak.provider.baseUrl'),
|
||||
Configure::read('keycloak.provider.realm')
|
||||
);
|
||||
foreach (['edit', 'view', 'settings'] as $sourceAction) {
|
||||
$this->bcf->addCustomLink('Users', $sourceAction, $url, __('Manage KeyCloak Account'));
|
||||
}
|
||||
}
|
||||
|
||||
$this->bcf->addLink('Users', 'settings', 'Users', 'view', function ($config) use ($bcf, $request, $passedData) {
|
||||
if (!empty($passedData[0])) {
|
||||
|
|
|
@ -12,6 +12,9 @@ class BaseNavigation
|
|||
{
|
||||
$this->bcf = $bcf;
|
||||
$this->request = $request;
|
||||
if (!empty($this->request->getAttribute('identity'))) {
|
||||
$this->currentUserId = $this->request->getAttribute('identity')->getIdentifier();
|
||||
}
|
||||
$this->viewVars = $viewVars;
|
||||
}
|
||||
|
||||
|
|
|
@ -346,6 +346,18 @@ class BreadcrumbFactory
|
|||
$this->endpoints[$sourceController][$sourceAction]['links'] = $links;
|
||||
}
|
||||
|
||||
public function addCustomLink(string $sourceController, string $sourceAction, string $targetUrl, string $label, $overrides = [])
|
||||
{
|
||||
$routeSourceConfig = $this->getRouteConfig($sourceController, $sourceAction, true);
|
||||
$links = array_merge($routeSourceConfig['links'] ?? [], [[
|
||||
'url' => $targetUrl,
|
||||
'icon' => 'link',
|
||||
'label' => $label,
|
||||
'route_path' => 'foo:bar'
|
||||
]]);
|
||||
$this->endpoints[$sourceController][$sourceAction]['links'] = $links;
|
||||
}
|
||||
|
||||
public function addAction(string $sourceController, string $sourceAction, string $targetController, string $targetAction, $overrides = [])
|
||||
{
|
||||
$routeSourceConfig = $this->getRouteConfig($sourceController, $sourceAction, true);
|
||||
|
|
|
@ -8,7 +8,7 @@ use Cake\Utility\Inflector;
|
|||
|
||||
class RestResponseComponent extends Component
|
||||
{
|
||||
public $components = ['ACL'];
|
||||
public $components = ['ACL', 'APIRearrange'];
|
||||
|
||||
public $headers = [];
|
||||
|
||||
|
@ -558,6 +558,9 @@ class RestResponseComponent extends Component
|
|||
if (!empty($errors)) {
|
||||
$data['errors'] = $errors;
|
||||
}
|
||||
if (!$raw && is_object($data)) {
|
||||
$data = $this->APIRearrange->rearrangeForAPI($data);
|
||||
}
|
||||
return $this->__sendResponse($data, 200, $format, $raw, $download, $headers);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,8 @@ class EncryptionKeysController extends AppController
|
|||
|
||||
public function index()
|
||||
{
|
||||
$this->EncryptionKeys->initializeGpg();
|
||||
$Model = $this->EncryptionKeys;
|
||||
$this->CRUD->index([
|
||||
'quickFilters' => $this->quickFilterFields,
|
||||
'filters' => $this->filterFields,
|
||||
|
@ -31,6 +33,20 @@ class EncryptionKeysController extends AppController
|
|||
],
|
||||
'contain' => $this->containFields,
|
||||
'statisticsFields' => $this->statisticsFields,
|
||||
'afterFind' => function($data) use ($Model) {
|
||||
if ($data['type'] === 'pgp') {
|
||||
$keyInfo = $Model->verifySingleGPG($data);
|
||||
$data['status'] = __('OK');
|
||||
$data['fingerprint'] = __('N/A');
|
||||
if (!$keyInfo[0]) {
|
||||
$data['status'] = $keyInfo[2];
|
||||
}
|
||||
if (!empty($keyInfo[4])) {
|
||||
$data['fingerprint'] = $keyInfo[4];
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
]);
|
||||
$responsePayload = $this->CRUD->getResponsePayload();
|
||||
if (!empty($responsePayload)) {
|
||||
|
@ -155,8 +171,22 @@ class EncryptionKeysController extends AppController
|
|||
|
||||
public function view($id = false)
|
||||
{
|
||||
$this->EncryptionKeys->initializeGpg();
|
||||
$Model = $this->EncryptionKeys;
|
||||
$this->CRUD->view($id, [
|
||||
'contain' => ['Individuals', 'Organisations']
|
||||
'contain' => ['Individuals', 'Organisations'],
|
||||
'afterFind' => function($data) use ($Model) {
|
||||
if ($data['type'] === 'pgp') {
|
||||
$keyInfo = $Model->verifySingleGPG($data);
|
||||
if (!$keyInfo[0]) {
|
||||
$data['pgp_error'] = $keyInfo[2];
|
||||
}
|
||||
if (!empty($keyInfo[4])) {
|
||||
$data['pgp_fingerprint'] = $keyInfo[4];
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
]);
|
||||
$responsePayload = $this->CRUD->getResponsePayload();
|
||||
if (!empty($responsePayload)) {
|
||||
|
|
|
@ -27,9 +27,9 @@ class MetaTemplatesController extends AppController
|
|||
return $this->RestResponse->viewData($result, 'json');
|
||||
} else {
|
||||
if ($result['success']) {
|
||||
$message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), $result['files_processed']);
|
||||
$message = __n('{0} templates updated.', 'The template has been updated.', empty($template_id), count($result['files_processed']));
|
||||
} else {
|
||||
$message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), $result['files_processed']);
|
||||
$message = __n('{0} templates could not be updated.', 'The template could not be updated.', empty($template_id), count($result['files_processed']));
|
||||
}
|
||||
$this->CRUD->setResponseForController('updateAllTemplate', $result['success'], $message, $result['files_processed'], $result['update_errors'], ['redirect' => $this->referer()]);
|
||||
$responsePayload = $this->CRUD->getResponsePayload();
|
||||
|
|
|
@ -90,6 +90,12 @@ class UsersController extends AppController
|
|||
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.'));
|
||||
}
|
||||
if (Configure::read('keycloak.enabled')) {
|
||||
$existingUserForIndividual = $this->Users->find()->where(['individual_id' => $data['individual_id']])->first();
|
||||
if (!empty($existingUserForIndividual)) {
|
||||
throw new MethodNotAllowedException(__('Invalid individual selected - when KeyCloak is enabled, only one user account may be assigned to an individual.'));
|
||||
}
|
||||
}
|
||||
$this->Users->enrollUserRouter($data);
|
||||
return $data;
|
||||
}
|
||||
|
@ -291,30 +297,44 @@ class UsersController extends AppController
|
|||
|
||||
public function login()
|
||||
{
|
||||
$result = $this->Authentication->getResult();
|
||||
// If the user is logged in send them away.
|
||||
$logModel = $this->Users->auditLogs();
|
||||
if ($result->isValid()) {
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'request_action' => 'login',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'changed' => []
|
||||
]);
|
||||
$target = $this->Authentication->getLoginRedirect() ?? '/instance/home';
|
||||
return $this->redirect($target);
|
||||
$blocked = false;
|
||||
if ($this->request->is('post')) {
|
||||
$BruteforceTable = TableRegistry::getTableLocator()->get('Bruteforces');
|
||||
$input = $this->request->getData();
|
||||
$blocked = $BruteforceTable->isBlocklisted($_SERVER['REMOTE_ADDR'], $input['username']);
|
||||
if ($blocked) {
|
||||
$this->Authentication->logout();
|
||||
$this->Flash->error(__('Too many attempts, brute force protection triggered. Wait 5 minutes before trying again.'));
|
||||
$this->redirect(['controller' => 'users', 'action' => 'login']);
|
||||
}
|
||||
}
|
||||
if ($this->request->is('post') && !$result->isValid()) {
|
||||
$logModel->insert([
|
||||
'request_action' => 'login_fail',
|
||||
'model' => 'Users',
|
||||
'model_id' => 0,
|
||||
'model_title' => 'unknown_user',
|
||||
'changed' => []
|
||||
]);
|
||||
$this->Flash->error(__('Invalid username or password'));
|
||||
if (!$blocked) {
|
||||
$result = $this->Authentication->getResult();
|
||||
// If the user is logged in send them away.
|
||||
$logModel = $this->Users->auditLogs();
|
||||
if ($result->isValid()) {
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'request_action' => 'login',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'changed' => []
|
||||
]);
|
||||
$target = $this->Authentication->getLoginRedirect() ?? '/instance/home';
|
||||
return $this->redirect($target);
|
||||
}
|
||||
if ($this->request->is('post') && !$result->isValid()) {
|
||||
$BruteforceTable->insert($_SERVER['REMOTE_ADDR'], $input['username']);
|
||||
$logModel->insert([
|
||||
'request_action' => 'login_fail',
|
||||
'model' => 'Users',
|
||||
'model_id' => 0,
|
||||
'model_title' => 'unknown_user',
|
||||
'changed' => []
|
||||
]);
|
||||
$this->Flash->error(__('Invalid username or password'));
|
||||
}
|
||||
}
|
||||
$this->viewBuilder()->setLayout('login');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace App\Lib\Tools;
|
||||
|
||||
use Cake\Core\Exception\Exception;
|
||||
use Cake\Core\Configure;
|
||||
|
||||
class CryptGpgExtended extends \Crypt_GPG
|
||||
{
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
if (!method_exists($this, '_prepareInput')) {
|
||||
$reflector = new \ReflectionClass('Crypt_GPG');
|
||||
$classPath = $reflector->getFileName();
|
||||
throw new Exception("Crypt_GPG class from '$classPath' is too old, at least version 1.6.1 is required.");
|
||||
}
|
||||
parent::__construct($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the smallest public key possible from the keyring.
|
||||
*
|
||||
* This removes all signatures except the most recent self-signature on each user ID. This option is the same as
|
||||
* running the --edit-key command "minimize" before export except that the local copy of the key is not modified.
|
||||
*
|
||||
* The exported key remains on the keyring. To delete the public key, use
|
||||
* {@link Crypt_GPG::deletePublicKey()}.
|
||||
*
|
||||
* If more than one key fingerprint is available for the specified
|
||||
* <kbd>$keyId</kbd> (for example, if you use a non-unique uid) only the
|
||||
* first public key is exported.
|
||||
*
|
||||
* @param string $keyId either the full uid of the public key, the email
|
||||
* part of the uid of the public key or the key id of
|
||||
* the public key. For example,
|
||||
* "Test User (example) <test@example.com>",
|
||||
* "test@example.com" or a hexadecimal string.
|
||||
* @param boolean $armor optional. If true, ASCII armored data is returned;
|
||||
* otherwise, binary data is returned. Defaults to
|
||||
* true.
|
||||
*
|
||||
* @return string the public key data.
|
||||
*
|
||||
* @throws Crypt_GPG_KeyNotFoundException if a public key with the given
|
||||
* <kbd>$keyId</kbd> is not found.
|
||||
*
|
||||
* @throws Crypt_GPG_Exception if an unknown or unexpected error occurs.
|
||||
* Use the <kbd>debug</kbd> option and file a bug report if these
|
||||
* exceptions occur.
|
||||
*/
|
||||
public function exportPublicKeyMinimal($keyId, $armor = true)
|
||||
{
|
||||
$fingerprint = $this->getFingerprint($keyId);
|
||||
|
||||
if ($fingerprint === null) {
|
||||
throw new \Crypt_GPG_KeyNotFoundException(
|
||||
'Key not found: ' . $keyId,
|
||||
self::ERROR_KEY_NOT_FOUND,
|
||||
$keyId
|
||||
);
|
||||
}
|
||||
|
||||
$keyData = '';
|
||||
$operation = '--export';
|
||||
$operation .= ' ' . escapeshellarg($fingerprint);
|
||||
|
||||
$arguments = array('--export-options', 'export-minimal');
|
||||
if ($armor) {
|
||||
$arguments[] = '--armor';
|
||||
}
|
||||
|
||||
$this->engine->reset();
|
||||
$this->engine->setPins($this->passphrases);
|
||||
$this->engine->setOutput($keyData);
|
||||
$this->engine->setOperation($operation, $arguments);
|
||||
$this->engine->run();
|
||||
|
||||
return $keyData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return key info without importing it when GPG supports --import-options show-only, otherwise just import and
|
||||
* then return details.
|
||||
*
|
||||
* @param string $key
|
||||
* @return Crypt_GPG_Key[]
|
||||
* @throws Crypt_GPG_Exception
|
||||
* @throws Crypt_GPG_InvalidOperationException
|
||||
*/
|
||||
public function keyInfo($key)
|
||||
{
|
||||
$version = $this->engine->getVersion();
|
||||
if (version_compare($version, '2.1.23', 'le')) {
|
||||
$importResult = $this->importKey($key);
|
||||
$keys = [];
|
||||
foreach ($importResult['fingerprints'] as $fingerprint) {
|
||||
foreach ($this->getKeys($fingerprint) as $key) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
return $keys;
|
||||
}
|
||||
|
||||
$input = $this->_prepareInput($key, false, false);
|
||||
|
||||
$output = '';
|
||||
$this->engine->reset();
|
||||
$this->engine->setInput($input);
|
||||
$this->engine->setOutput($output);
|
||||
$this->engine->setOperation('--import', ['--import-options', 'show-only', '--with-colons']);
|
||||
$this->engine->run();
|
||||
|
||||
$keys = [];
|
||||
$key = null; // current key
|
||||
$subKey = null; // current sub-key
|
||||
|
||||
foreach (explode(PHP_EOL, $output) as $line) {
|
||||
$lineExp = explode(':', $line);
|
||||
|
||||
if ($lineExp[0] === 'pub') {
|
||||
// new primary key means last key should be added to the array
|
||||
if ($key !== null) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
|
||||
$key = new \Crypt_GPG_Key();
|
||||
|
||||
$subKey = \Crypt_GPG_SubKey::parse($line);
|
||||
$key->addSubKey($subKey);
|
||||
|
||||
} elseif ($lineExp[0] === 'sub') {
|
||||
$subKey = \Crypt_GPG_SubKey::parse($line);
|
||||
$key->addSubKey($subKey);
|
||||
|
||||
} elseif ($lineExp[0] === 'fpr') {
|
||||
$fingerprint = $lineExp[9];
|
||||
|
||||
// set current sub-key fingerprint
|
||||
$subKey->setFingerprint($fingerprint);
|
||||
|
||||
} elseif ($lineExp[0] === 'uid') {
|
||||
$string = stripcslashes($lineExp[9]); // as per documentation
|
||||
$userId = new \Crypt_GPG_UserId($string);
|
||||
|
||||
if ($lineExp[1] === 'r') {
|
||||
$userId->setRevoked(true);
|
||||
}
|
||||
|
||||
$key->addUserId($userId);
|
||||
}
|
||||
}
|
||||
|
||||
// add last key
|
||||
if ($key !== null) {
|
||||
$keys[] = $key;
|
||||
} else {
|
||||
throw new \Crypt_GPG_Exception("Key data provided, but gpg process output could not be parsed: $output");
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return string
|
||||
* @throws Crypt_GPG_Exception
|
||||
* @throws Crypt_GPG_InvalidOperationException
|
||||
*/
|
||||
public function enarmor($key)
|
||||
{
|
||||
$input = $this->_prepareInput($key, false, false);
|
||||
|
||||
$armored = '';
|
||||
$this->engine->reset();
|
||||
$this->engine->setInput($input);
|
||||
$this->engine->setOutput($armored);
|
||||
$this->engine->setOperation('--enarmor');
|
||||
$this->engine->run();
|
||||
|
||||
return $armored;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @param bool $isFile
|
||||
* @param bool $allowEmpty
|
||||
* @return resource|string|null
|
||||
* @throws Crypt_GPG_FileException
|
||||
* @throws Crypt_GPG_NoDataException
|
||||
*/
|
||||
protected function _prepareInput($data, $isFile = false, $allowEmpty = true)
|
||||
{
|
||||
if ($isFile && $data instanceof TmpFileTool) {
|
||||
return $data->resource();
|
||||
}
|
||||
|
||||
return parent::_prepareInput($data, $isFile, $allowEmpty);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,248 @@
|
|||
<?php
|
||||
|
||||
namespace App\Lib\Tools;
|
||||
|
||||
use Cake\Core\Exception\Exception;
|
||||
use Cake\Core\Configure;
|
||||
|
||||
class GpgTool
|
||||
{
|
||||
/** @var CryptGpgExtended */
|
||||
private $gpg;
|
||||
|
||||
/**
|
||||
* @return CryptGpgExtended
|
||||
* @throws Exception
|
||||
*/
|
||||
public static function initializeGpg()
|
||||
{
|
||||
if (!class_exists('Crypt_GPG')) {
|
||||
// 'Crypt_GPG' class cannot be autoloaded, try to require from include_path.
|
||||
if (!stream_resolve_include_path('Crypt/GPG.php')) {
|
||||
throw new Exception("Crypt_GPG is not installed.");
|
||||
}
|
||||
require_once 'Crypt/GPG.php';
|
||||
}
|
||||
require_once ROOT . '/src/Lib/Tools/CryptGpgExtended.php';
|
||||
|
||||
$homedir = Configure::read('GnuPG.homedir') ?? ROOT . '/.gnupg';
|
||||
if ($homedir === null) {
|
||||
throw new Exception("Configuration option 'GnuPG.homedir' is not set, Crypt_GPG cannot be initialized.");
|
||||
}
|
||||
|
||||
$options = [
|
||||
'homedir' => $homedir,
|
||||
'gpgconf' => Configure::read('GnuPG.gpgconf'),
|
||||
'binary' => Configure::read('GnuPG.binary') ?: '/usr/bin/gpg',
|
||||
];
|
||||
return new CryptGpgExtended($options);
|
||||
}
|
||||
|
||||
public function __construct(CryptGpgExtended $gpg = null)
|
||||
{
|
||||
$this->gpg = $gpg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $search
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function searchGpgKey($search)
|
||||
{
|
||||
$uri = 'https://openpgp.circl.lu/pks/lookup?search=' . urlencode($search) . '&op=index&fingerprint=on&options=mr';
|
||||
try {
|
||||
$response = $this->keyServerLookup($uri);
|
||||
} catch (HttpSocketHttpException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
return [];
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $this->extractKeySearch($response->body);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fingerprint
|
||||
* @return string|null
|
||||
* @throws Exception
|
||||
*/
|
||||
public function fetchGpgKey($fingerprint)
|
||||
{
|
||||
$uri = 'https://openpgp.circl.lu/pks/lookup?search=0x' . urlencode($fingerprint) . '&op=get&options=mr';
|
||||
try {
|
||||
$response = $this->keyServerLookup($uri);
|
||||
} catch (HttpSocketHttpException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
return null;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$key = $response->body;
|
||||
|
||||
if ($this->gpg) {
|
||||
$fetchedFingerprint = $this->validateGpgKey($key);
|
||||
if (strtolower($fingerprint) !== strtolower($fetchedFingerprint)) {
|
||||
throw new Exception("Requested fingerprint do not match with fetched key fingerprint ($fingerprint != $fetchedFingerprint)");
|
||||
}
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates PGP key
|
||||
* @param string $keyData
|
||||
* @return string Primary key fingerprint
|
||||
* @throws Exception
|
||||
*/
|
||||
public function validateGpgKey($keyData)
|
||||
{
|
||||
if (!$this->gpg instanceof CryptGpgExtended) {
|
||||
throw new InvalidArgumentException("Valid CryptGpgExtended instance required.");
|
||||
}
|
||||
$fetchedKeyInfo = $this->gpg->keyInfo($keyData);
|
||||
if (count($fetchedKeyInfo) !== 1) {
|
||||
throw new Exception("Multiple keys found");
|
||||
}
|
||||
$primaryKey = $fetchedKeyInfo[0]->getPrimaryKey();
|
||||
if (empty($primaryKey)) {
|
||||
throw new Exception("No primary key found");
|
||||
}
|
||||
$this->gpg->importKey($keyData);
|
||||
return $primaryKey->getFingerprint();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $body
|
||||
* @return array
|
||||
*/
|
||||
private function extractKeySearch($body)
|
||||
{
|
||||
$final = array();
|
||||
$lines = explode("\n", $body);
|
||||
foreach ($lines as $line) {
|
||||
$parts = explode(":", $line);
|
||||
|
||||
if ($parts[0] === 'pub') {
|
||||
if (!empty($temp)) {
|
||||
$final[] = $temp;
|
||||
$temp = array();
|
||||
}
|
||||
|
||||
if (strpos($parts[6], 'r') !== false || strpos($parts[6], 'd') !== false || strpos($parts[6], 'e') !== false) {
|
||||
continue; // skip if key is expired, revoked or disabled
|
||||
}
|
||||
|
||||
$temp = array(
|
||||
'fingerprint' => $parts[1],
|
||||
'key_id' => substr($parts[1], -8),
|
||||
'date' => date('Y-m-d', $parts[4]),
|
||||
);
|
||||
|
||||
} else if ($parts[0] === 'uid' && !empty($temp)) {
|
||||
$temp['address'] = urldecode($parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($temp)) {
|
||||
$final[] = $temp;
|
||||
}
|
||||
|
||||
return $final;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://tools.ietf.org/html/draft-koch-openpgp-webkey-service-10
|
||||
* @param string $email
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function wkd($email)
|
||||
{
|
||||
if (!$this->gpg instanceof CryptGpgExtended) {
|
||||
throw new InvalidArgumentException("Valid CryptGpgExtended instance required.");
|
||||
}
|
||||
|
||||
$parts = explode('@', $email);
|
||||
if (count($parts) !== 2) {
|
||||
throw new InvalidArgumentException("Invalid e-mail address provided.");
|
||||
}
|
||||
|
||||
list($localPart, $domain) = $parts;
|
||||
$localPart = strtolower($localPart);
|
||||
$localPartHash = $this->zbase32(sha1($localPart, true));
|
||||
|
||||
$advancedUrl = "https://openpgpkey.$domain/.well-known/openpgpkey/" . strtolower($domain) . "/hu/$localPartHash";
|
||||
try {
|
||||
$response = $this->keyServerLookup($advancedUrl);
|
||||
return $this->gpg->enarmor($response->body());
|
||||
} catch (Exception $e) {
|
||||
// pass, continue to direct method
|
||||
}
|
||||
|
||||
$directUrl = "https://$domain/.well-known/openpgpkey/hu/$localPartHash";
|
||||
try {
|
||||
$response = $this->keyServerLookup($directUrl);
|
||||
} catch (HttpSocketHttpException $e) {
|
||||
if ($e->getCode() === 404) {
|
||||
throw new NotFoundException("Key not found");
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
return $this->gpg->enarmor($response->body());
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts data to zbase32 string.
|
||||
*
|
||||
* @see http://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
|
||||
* @param string $data
|
||||
* @return string
|
||||
*/
|
||||
private function zbase32($data)
|
||||
{
|
||||
$chars = 'ybndrfg8ejkmcpqxot1uwisza345h769'; // lower-case
|
||||
$res = '';
|
||||
$remainder = 0;
|
||||
$remainderSize = 0;
|
||||
|
||||
for ($i = 0; $i < strlen($data); $i++) {
|
||||
$b = ord($data[$i]);
|
||||
$remainder = ($remainder << 8) | $b;
|
||||
$remainderSize += 8;
|
||||
while ($remainderSize > 4) {
|
||||
$remainderSize -= 5;
|
||||
$c = $remainder & (31 << $remainderSize);
|
||||
$c >>= $remainderSize;
|
||||
$res .= $chars[$c];
|
||||
}
|
||||
}
|
||||
if ($remainderSize > 0) {
|
||||
// remainderSize < 5:
|
||||
$remainder <<= (5 - $remainderSize);
|
||||
$c = $remainder & 31;
|
||||
$res .= $chars[$c];
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $uri
|
||||
* @return HttpSocketResponseExtended
|
||||
* @throws HttpSocketHttpException
|
||||
* @throws Exception
|
||||
*/
|
||||
private function keyServerLookup($uri)
|
||||
{
|
||||
App::uses('SyncTool', 'Tools');
|
||||
$syncTool = new SyncTool();
|
||||
$HttpSocket = $syncTool->createHttpSocket(['compress' => true]);
|
||||
$response = $HttpSocket->get($uri);
|
||||
if (!$response->isOk()) {
|
||||
throw new HttpSocketHttpException($response, $uri);
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
}
|
|
@ -30,7 +30,7 @@ class AuthKeycloakBehavior extends Behavior
|
|||
$raw_profile_payload = $profile->access_token->getJwt()->getPayload();
|
||||
$user = $this->extractProfileData($raw_profile_payload);
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('Unable to save new 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;
|
||||
|
@ -50,50 +50,13 @@ class AuthKeycloakBehavior extends Behavior
|
|||
$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->_table->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;
|
||||
}
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
|
@ -152,7 +115,8 @@ class AuthKeycloakBehavior extends Behavior
|
|||
foreach ($roles as $role) {
|
||||
$rolesParsed[$role['name']] = $role['id'];
|
||||
}
|
||||
if ($this->createUser($user, $clientId, $rolesParsed)) {
|
||||
$newUserId = $this->createUser($user, $clientId, $rolesParsed);
|
||||
if (!$newUserId) {
|
||||
$logChange = [
|
||||
'username' => $user['username'],
|
||||
'individual_id' => $user['individual']['id'],
|
||||
|
@ -178,10 +142,49 @@ class AuthKeycloakBehavior extends Behavior
|
|||
'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 boolean If the update was a success
|
||||
*/
|
||||
public function handleUserUpdate(\App\Model\Entity\User $user): bool
|
||||
{
|
||||
$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 !empty($changes);
|
||||
}
|
||||
|
||||
private function getAdminAccessToken()
|
||||
{
|
||||
$keycloakConfig = Configure::read('keycloak');
|
||||
|
@ -252,7 +255,6 @@ class AuthKeycloakBehavior extends Behavior
|
|||
{
|
||||
$keycloakRoles = $this->getAllRoles($clientId);
|
||||
$keycloakRolesParsed = Hash::extract($keycloakRoles, '{n}.name');
|
||||
$rolesToAdd = [];
|
||||
$scopeString = $scope . ':';
|
||||
$modified = 0;
|
||||
foreach ($roles as $role) {
|
||||
|
@ -387,7 +389,7 @@ class AuthKeycloakBehavior extends Behavior
|
|||
return false;
|
||||
}
|
||||
|
||||
private function createUser(array $user, string $clientId, array $rolesParsed): bool
|
||||
private function createUser(array $user, string $clientId, array $rolesParsed)
|
||||
{
|
||||
$newUser = [
|
||||
'username' => $user['username'],
|
||||
|
@ -409,7 +411,11 @@ class AuthKeycloakBehavior extends Behavior
|
|||
]
|
||||
]);
|
||||
}
|
||||
$newUser = $this->restApiRequest('%s/admin/realms/%s/users?username=' . urlencode($user['username']), [], 'get');
|
||||
$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;
|
||||
|
@ -419,7 +425,7 @@ class AuthKeycloakBehavior extends Behavior
|
|||
}
|
||||
$user['id'] = $users[0]['id'];
|
||||
$this->assignRolesToUser($user, $rolesParsed, $clientId);
|
||||
return true;
|
||||
return $user['id'];
|
||||
}
|
||||
|
||||
private function assignRolesToUser(array $user, array $rolesParsed, string $clientId): bool
|
||||
|
@ -481,7 +487,7 @@ class AuthKeycloakBehavior extends Behavior
|
|||
$toRemove = array_diff($keycloakUserRoles, array_keys($userRoles));
|
||||
$changed = false;
|
||||
foreach ($toRemove as $k => $role) {
|
||||
if (substr($role, 0, strlen('Organisation:')) !== 'Organisation:' && substr($role, 0, strlen('Role:') !== 'Role:')) {
|
||||
if (substr($role, 0, strlen('Organisation:')) !== 'Organisation:' && substr($role, 0, strlen('Role:')) !== 'Role:') {
|
||||
unset($toRemove[$k]);
|
||||
} else {
|
||||
$toRemove[$k] = $assignedRolesParsed[$role];
|
||||
|
@ -509,6 +515,7 @@ class AuthKeycloakBehavior extends Behavior
|
|||
$toAdd[$k] = $userRoles[$name];
|
||||
}
|
||||
if (!empty($toAdd)) {
|
||||
$toAdd = array_values($toAdd);
|
||||
$response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'] . '/role-mappings/clients/' . $clientId, $toAdd, 'post');
|
||||
if (!$response->isOk()) {
|
||||
$this->_table->auditLogs()->insert([
|
||||
|
@ -527,4 +534,9 @@ class AuthKeycloakBehavior extends Behavior
|
|||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
private function urlencodeEscapeForSprintf(string $input): string
|
||||
{
|
||||
return str_replace('%', '%%', $input);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -111,7 +111,6 @@ class MetaFieldsBehavior extends Behavior
|
|||
$property = $this->getConfig('metaFieldsAssoc.propertyName');
|
||||
$options['accessibleFields'][$property] = true;
|
||||
$options['associated']['MetaFields']['accessibleFields']['id'] = true;
|
||||
|
||||
if (isset($data[$property])) {
|
||||
if (!empty($data[$property])) {
|
||||
$data[$property] = $this->normalizeMetafields($data[$property]);
|
||||
|
|
|
@ -32,4 +32,52 @@ class AppModel extends Entity
|
|||
{
|
||||
return $this->_accessibleOnNew ?? [];
|
||||
}
|
||||
|
||||
public function rearrangeForAPI(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function rearrangeMetaFields(): void
|
||||
{
|
||||
$this->meta_fields = [];
|
||||
foreach ($this->MetaTemplates as $template) {
|
||||
foreach ($template['meta_template_fields'] as $field) {
|
||||
if ($field['counter'] > 0) {
|
||||
foreach ($field['metaFields'] as $metaField) {
|
||||
if (!empty($this->meta_fields[$template['name']][$field['field']])) {
|
||||
if (!is_array($this->meta_fields[$template['name']][$field['field']])) {
|
||||
$this->meta_fields[$template['name']][$field['field']] = [$this->meta_fields[$template['name']][$field['field']]];
|
||||
}
|
||||
$this->meta_fields[$template['name']][$field['field']][] = $metaField['value'];
|
||||
} else {
|
||||
$this->meta_fields[$template['name']][$field['field']] = $metaField['value'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function rearrangeTags(array $tags): array
|
||||
{
|
||||
foreach ($tags as &$tag) {
|
||||
unset($tag['_joinData']);
|
||||
}
|
||||
return $tags;
|
||||
}
|
||||
|
||||
public function rearrangeAlignments(array $alignments): array
|
||||
{
|
||||
$rearrangedAlignments = [];
|
||||
$validAlignmentTypes = ['individual', 'organisation'];
|
||||
foreach ($alignments as $alignment) {
|
||||
foreach ($validAlignmentTypes as $type) {
|
||||
if (isset($alignment[$type])) {
|
||||
$alignment[$type]['type'] = $alignment['type'];
|
||||
$rearrangedAlignments[$type][] = $alignment[$type];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rearrangedAlignments;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,4 +65,14 @@ class AuditLog extends AppModel
|
|||
}
|
||||
return $title;
|
||||
}
|
||||
|
||||
public function rearrangeForAPI(): void
|
||||
{
|
||||
if (!empty($this->user)) {
|
||||
$this->user = $this->user->toArray();
|
||||
}
|
||||
if (!empty($this->user['user_settings_by_name_with_fallback'])) {
|
||||
unset($this->user['user_settings_by_name_with_fallback']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Model\Entity;
|
||||
|
||||
use App\Model\Entity\AppModel;
|
||||
use Cake\ORM\Entity;
|
||||
|
||||
class Bruteforce extends AppModel
|
||||
{
|
||||
|
||||
}
|
|
@ -16,4 +16,20 @@ class Organisation extends AppModel
|
|||
protected $_accessibleOnNew = [
|
||||
'created' => true
|
||||
];
|
||||
|
||||
public function rearrangeForAPI(): void
|
||||
{
|
||||
if (!empty($this->tags)) {
|
||||
$this->tags = $this->rearrangeTags($this->tags);
|
||||
}
|
||||
if (!empty($this->alignments)) {
|
||||
$this->alignments = $this->rearrangeAlignments($this->alignments);
|
||||
}
|
||||
if (!empty($this->meta_fields)) {
|
||||
$this->rearrangeMetaFields();
|
||||
}
|
||||
if (!empty($this->MetaTemplates)) {
|
||||
unset($this->MetaTemplates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ class AppTable extends Table
|
|||
->limit($options['limit'])
|
||||
->page(1)
|
||||
->enableHydration(false);
|
||||
$topUsage = $queryTopUsage->toList();
|
||||
$topUsage = $queryTopUsage->all()->toList();
|
||||
$stats[$scope] = $topUsage;
|
||||
if (
|
||||
!empty($options['includeOthers']) && !empty($topUsage) &&
|
||||
|
@ -73,7 +73,7 @@ class AppTable extends Table
|
|||
}
|
||||
})
|
||||
->enableHydration(false);
|
||||
$othersUsage = $queryOthersUsage->toList();
|
||||
$othersUsage = $queryOthersUsage->all()->toList();
|
||||
if (!empty($othersUsage)) {
|
||||
$stats[$scope][] = [
|
||||
$scope => __('Others'),
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Model\Table;
|
||||
|
||||
use App\Model\Table\AppTable;
|
||||
use Cake\ORM\Table;
|
||||
use Cake\Validation\Validator;
|
||||
use Cake\ORM\TableRegistry;
|
||||
|
||||
class BruteforcesTable extends AppTable
|
||||
{
|
||||
private $logModel = null;
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->setDisplayField('email');
|
||||
$this->logModel = TableRegistry::getTableLocator()->get('AuditLogs');
|
||||
}
|
||||
|
||||
public function insert($ip, $username)
|
||||
{
|
||||
$expire = 300;
|
||||
$amount = 5;
|
||||
$expire = time() + $expire;
|
||||
$expire = date('Y-m-d H:i:s', $expire);
|
||||
$bruteforceEntry = $this->newEntity([
|
||||
'user_ip' => $ip,
|
||||
'username' => trim(strtolower($username)),
|
||||
'expiration' => $expire
|
||||
]);
|
||||
$this->save($bruteforceEntry);
|
||||
$title = 'Failed login attempt using username ' . $username . ' from IP: ' . $ip . '.';
|
||||
if ($this->isBlocklisted($ip, $username)) {
|
||||
$title .= 'This has tripped the bruteforce protection after ' . $amount . ' failed attempts. The user is now blocklisted for ' . $expire . ' seconds.';
|
||||
}
|
||||
$this->logModel->insert([
|
||||
'request_action' => 'login_fail',
|
||||
'model' => 'Users',
|
||||
'model_id' => 0,
|
||||
'model_title' => 'bruteforce_block',
|
||||
'changed' => []
|
||||
]);
|
||||
}
|
||||
|
||||
public function clean()
|
||||
{
|
||||
$expire = date('Y-m-d H:i:s', time());
|
||||
$this->deleteAll(['expiration <=' => $expire]);
|
||||
}
|
||||
|
||||
public function isBlocklisted($ip, $username)
|
||||
{
|
||||
// first remove old expired rows
|
||||
$this->clean();
|
||||
// count
|
||||
$count = $this->find('all', [
|
||||
'conditions' => [
|
||||
'user_ip' => $ip,
|
||||
'username' => trim($username)
|
||||
]
|
||||
])->count();
|
||||
if ($count >= 5) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,6 +10,9 @@ use ArrayObject;
|
|||
|
||||
class EncryptionKeysTable extends AppTable
|
||||
{
|
||||
|
||||
public $gpg = null;
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
|
@ -56,4 +59,92 @@ class EncryptionKeysTable extends AppTable
|
|||
->requirePresence(['type', 'encryption_key', 'owner_id', 'owner_model'], 'create');
|
||||
return $validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 0 - true if key is valid
|
||||
* 1 - User e-mail
|
||||
* 2 - Error message
|
||||
* 3 - Not used
|
||||
* 4 - Key fingerprint
|
||||
* 5 - Key fingerprint
|
||||
* @param \App\Model\Entity\EncryptionKey $encryptionKey
|
||||
* @return array
|
||||
*/
|
||||
public function verifySingleGPG(\App\Model\Entity\EncryptionKey $encryptionKey): array
|
||||
{
|
||||
$result = [0 => false, 1 => null];
|
||||
|
||||
$gpg = $this->initializeGpg();
|
||||
if (!$gpg) {
|
||||
$result[2] = 'GnuPG is not configured on this system.';
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$currentTimestamp = time();
|
||||
$keys = $gpg->keyInfo($encryptionKey['encryption_key']);
|
||||
if (count($keys) !== 1) {
|
||||
$result[2] = 'Multiple or no key found';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$key = $keys[0];
|
||||
$result[4] = $key->getPrimaryKey()->getFingerprint();
|
||||
$result[5] = $result[4];
|
||||
|
||||
$sortedKeys = ['valid' => 0, 'expired' => 0, 'noEncrypt' => 0];
|
||||
foreach ($key->getSubKeys() as $subKey) {
|
||||
$expiration = $subKey->getExpirationDate();
|
||||
if ($expiration != 0 && $currentTimestamp > $expiration) {
|
||||
$sortedKeys['expired']++;
|
||||
continue;
|
||||
}
|
||||
if (!$subKey->canEncrypt()) {
|
||||
$sortedKeys['noEncrypt']++;
|
||||
continue;
|
||||
}
|
||||
$sortedKeys['valid']++;
|
||||
}
|
||||
if (!$sortedKeys['valid']) {
|
||||
$result[2] = 'The user\'s PGP key does not include a valid subkey that could be used for encryption.';
|
||||
if ($sortedKeys['expired']) {
|
||||
$result[2] .= ' ' . __n('Found %s subkey that have expired.', 'Found %s subkeys that have expired.', $sortedKeys['expired'], $sortedKeys['expired']);
|
||||
}
|
||||
if ($sortedKeys['noEncrypt']) {
|
||||
$result[2] .= ' ' . __n('Found %s subkey that is sign only.', 'Found %s subkeys that are sign only.', $sortedKeys['noEncrypt'], $sortedKeys['noEncrypt']);
|
||||
}
|
||||
} else {
|
||||
$result[0] = true;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$result[2] = $e->getMessage();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize GPG. Returns `null` if initialization failed.
|
||||
*
|
||||
* @return null|CryptGpgExtended
|
||||
*/
|
||||
public function initializeGpg()
|
||||
{
|
||||
require_once(ROOT . '/src/Lib/Tools/GpgTool.php');
|
||||
if ($this->gpg !== null) {
|
||||
if ($this->gpg === false) { // initialization failed
|
||||
return null;
|
||||
}
|
||||
return $this->gpg;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->gpg = \App\Lib\Tools\GpgTool::initializeGpg();
|
||||
return $this->gpg;
|
||||
} catch (\Exception $e) {
|
||||
//$this->logException("GPG couldn't be initialized, GPG encryption and signing will be not available.", $e, LOG_NOTICE);
|
||||
$this->gpg = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,12 +7,14 @@ 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\Event\EventInterface;
|
||||
use Cake\Datasource\EntityInterface;
|
||||
use Cake\Http\Session;
|
||||
use Cake\Http\Client;
|
||||
use Cake\Utility\Security;
|
||||
use Cake\Core\Configure;
|
||||
use Cake\Utility\Text;
|
||||
use ArrayObject;
|
||||
|
||||
class UsersTable extends AppTable
|
||||
{
|
||||
|
@ -54,6 +56,19 @@ class UsersTable extends AppTable
|
|||
$this->setDisplayField('username');
|
||||
}
|
||||
|
||||
public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)
|
||||
{
|
||||
$data['username'] = trim(mb_strtolower($data['username']));
|
||||
}
|
||||
|
||||
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
if (!$entity->isNew()) {
|
||||
$success = $this->handleUserUpdateRouter($entity);
|
||||
}
|
||||
return $success;
|
||||
}
|
||||
|
||||
private function initAuthBehaviors()
|
||||
{
|
||||
if (!empty(Configure::read('keycloak'))) {
|
||||
|
@ -87,8 +102,18 @@ class UsersTable extends AppTable
|
|||
'message' => __('Password confirmation missing or not matching the password.')
|
||||
]
|
||||
])
|
||||
->add('username', [
|
||||
'username_policy' => [
|
||||
'rule' => function($value, $context) {
|
||||
if (mb_strlen(trim($value)) < 5 || mb_strlen(trim($value)) > 50) {
|
||||
return __('Invalid username length. Make sure that you provide a username of at least 5 and up to 50 characters in length.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
]
|
||||
])
|
||||
->requirePresence(['username'], 'create')
|
||||
->notEmptyString('username', 'Please fill this field');
|
||||
->notEmptyString('username', __('Please fill this field'), 'create');
|
||||
return $validator;
|
||||
}
|
||||
|
||||
|
@ -191,4 +216,13 @@ class UsersTable extends AppTable
|
|||
$this->enrollUser($data);
|
||||
}
|
||||
}
|
||||
|
||||
public function handleUserUpdateRouter(\App\Model\Entity\User $user): bool
|
||||
{
|
||||
if (!empty(Configure::read('keycloak'))) {
|
||||
$success = $this->handleUserUpdate($user);
|
||||
//return $success !== false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2012,7 +2012,11 @@ class BoostrapDropdownMenu extends BootstrapGeneric
|
|||
$params['data-open-form-id'] = mt_rand();
|
||||
}
|
||||
|
||||
$label = $this->genNode('span', ['class' => 'mx-1'], h($entry['text']));
|
||||
$labelContent = sprintf('%s%s',
|
||||
h($entry['text']),
|
||||
!empty($entry['sup']) ? $this->genNode('sup', ['class' => 'ms-1 text-muted'], $entry['sup']) : ''
|
||||
);
|
||||
$label = $this->genNode('span', ['class' => 'mx-1'], $labelContent);
|
||||
$content = $icon . $label . $badge;
|
||||
|
||||
return $this->genNode('a', array_merge([
|
||||
|
|
|
@ -13,6 +13,11 @@ class SocialProviderHelper extends Helper
|
|||
'keycloak' => '/img/keycloak_logo.png',
|
||||
];
|
||||
|
||||
public function hasSocialProfile($identity): bool
|
||||
{
|
||||
return !empty($identity['social_profile']);
|
||||
}
|
||||
|
||||
public function getIcon($identity)
|
||||
{
|
||||
if (!empty($identity['social_profile'])) {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
echo $this->element('genericElements/IndexTable/index_table', [
|
||||
'data' => [
|
||||
'data' => $data,
|
||||
'top_bar' => [
|
||||
'children' => [
|
||||
|
||||
],
|
||||
],
|
||||
'fields' => [
|
||||
[
|
||||
'name' => '#',
|
||||
'sort' => 'id',
|
||||
'data_path' => 'id',
|
||||
],
|
||||
[
|
||||
'name' => __('Individual'),
|
||||
'data_path' => 'individual.email',
|
||||
'url' => '/individuals/view/{{0}}',
|
||||
'url_vars' => ['individual.id']
|
||||
],
|
||||
[
|
||||
'name' => __('Organisation'),
|
||||
'data_path' => 'organisation.name',
|
||||
'url' => '/organisations/view/{{0}}',
|
||||
'url_vars' => ['organisation.id']
|
||||
],
|
||||
[
|
||||
'name' => __('Type'),
|
||||
'sort' => 'type',
|
||||
'data_path' => 'type'
|
||||
],
|
||||
],
|
||||
'title' => __('User index'),
|
||||
'description' => __('The list of enrolled users in this Cerebrate instance. All of the users have or at one point had access to the system.'),
|
||||
'pull' => 'right',
|
||||
'actions' => [
|
||||
|
||||
]
|
||||
]
|
||||
]);
|
||||
echo '</div>';
|
||||
?>
|
|
@ -48,6 +48,14 @@ echo $this->element('genericElements/IndexTable/index_table', [
|
|||
'owner_model_path' => 'owner_model',
|
||||
'element' => 'owner'
|
||||
],
|
||||
[
|
||||
'name' => __('Revoked'),
|
||||
'data_path' => 'fingerprint'
|
||||
],
|
||||
[
|
||||
'name' => __('Status'),
|
||||
'data_path' => 'status'
|
||||
],
|
||||
[
|
||||
'name' => __('Revoked'),
|
||||
'sort' => 'revoked',
|
||||
|
@ -56,7 +64,8 @@ echo $this->element('genericElements/IndexTable/index_table', [
|
|||
],
|
||||
[
|
||||
'name' => __('Key'),
|
||||
'data_path' => 'encryption_key'
|
||||
'data_path' => 'encryption_key',
|
||||
'element' => 'pgp_key'
|
||||
],
|
||||
],
|
||||
'title' => __('Encryption key Index'),
|
||||
|
|
|
@ -1,32 +1,53 @@
|
|||
<?php
|
||||
echo $this->element(
|
||||
'/genericElements/SingleViews/single_view',
|
||||
[
|
||||
'data' => $entity,
|
||||
'fields' => [
|
||||
[
|
||||
'key' => __('ID'),
|
||||
'path' => 'id'
|
||||
],
|
||||
[
|
||||
'key' => __('Type'),
|
||||
'path' => 'type'
|
||||
],
|
||||
[
|
||||
'key' => __('Owner'),
|
||||
'path' => 'owner_id',
|
||||
'owner_model_path' => 'owner_model',
|
||||
'type' => 'owner'
|
||||
],
|
||||
[
|
||||
'key' => __('Revoked'),
|
||||
'path' => 'revoked'
|
||||
],
|
||||
|
||||
[
|
||||
'key' => __('Key'),
|
||||
'path' => 'encryption_key'
|
||||
]
|
||||
$fields = [
|
||||
[
|
||||
'key' => __('ID'),
|
||||
'path' => 'id'
|
||||
],
|
||||
[
|
||||
'key' => __('Type'),
|
||||
'path' => 'type'
|
||||
],
|
||||
[
|
||||
'key' => __('Owner'),
|
||||
'path' => 'owner_id',
|
||||
'owner_model_path' => 'owner_model',
|
||||
'type' => 'owner'
|
||||
],
|
||||
[
|
||||
'key' => __('Revoked'),
|
||||
'path' => 'revoked',
|
||||
'type' => 'boolean'
|
||||
],
|
||||
[
|
||||
'key' => __('Key'),
|
||||
'path' => 'encryption_key',
|
||||
'type' => 'key'
|
||||
]
|
||||
]
|
||||
);
|
||||
];
|
||||
if ($entity['type'] === 'pgp') {
|
||||
if (!empty($entity['pgp_fingerprint'])) {
|
||||
$fields[] = [
|
||||
'key' => __('Fingerprint'),
|
||||
'path' => 'pgp_fingerprint'
|
||||
];
|
||||
}
|
||||
if (!empty($entity['pgp_error'])) {
|
||||
$fields[] = [
|
||||
'key' => __('PGP Status'),
|
||||
'path' => 'pgp_error'
|
||||
];
|
||||
} else {
|
||||
$fields[] = [
|
||||
'key' => __('PGP Status'),
|
||||
'raw' => __('OK')
|
||||
];
|
||||
}
|
||||
}
|
||||
echo $this->element(
|
||||
'/genericElements/SingleViews/single_view',
|
||||
[
|
||||
'data' => $entity,
|
||||
'fields' => $fields
|
||||
]
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<?php
|
||||
|
||||
use Cake\Routing\Router;
|
||||
|
||||
$bodyHtml = '';
|
||||
|
@ -21,7 +22,8 @@ foreach ($templatesUpdateStatus as $uuid => $status) {
|
|||
if (!empty($status['new'])) {
|
||||
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
|
||||
} else {
|
||||
$tableHtml .= sprintf('<td><a href="%s">%s</a></td>',
|
||||
$tableHtml .= sprintf(
|
||||
'<td><a href="%s">%s</a></td>',
|
||||
Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]),
|
||||
h($status['existing_template']->id)
|
||||
);
|
||||
|
@ -29,7 +31,8 @@ foreach ($templatesUpdateStatus as $uuid => $status) {
|
|||
if (!empty($status['new'])) {
|
||||
$tableHtml .= sprintf('<td>%s</td>', h($uuid));
|
||||
} else {
|
||||
$tableHtml .= sprintf('<td><a href="%s">%s</a></td>',
|
||||
$tableHtml .= sprintf(
|
||||
'<td><a href="%s">%s</a></td>',
|
||||
Router::url(['controller' => 'MetaTemplates', 'action' => 'view', 'plugin' => null, h($status['existing_template']->id)]),
|
||||
h($status['existing_template']->name)
|
||||
);
|
||||
|
@ -37,7 +40,8 @@ foreach ($templatesUpdateStatus as $uuid => $status) {
|
|||
if (!empty($status['new'])) {
|
||||
$tableHtml .= sprintf('<td>%s</td>', __('N/A'));
|
||||
} else {
|
||||
$tableHtml .= sprintf('<td>%s %s %s</td>',
|
||||
$tableHtml .= sprintf(
|
||||
'<td>%s %s %s</td>',
|
||||
h($status['current_version']),
|
||||
$this->Bootstrap->icon('arrow-right', ['class' => 'fs-8']),
|
||||
h($status['next_version'])
|
||||
|
@ -107,6 +111,12 @@ if (empty($numberOfSkippedUpdates) && empty($numberOfUpdates)) {
|
|||
}
|
||||
|
||||
$bodyHtml .= $tableHtml;
|
||||
$form = sprintf(
|
||||
'<div class="d-none">%s%s</div>',
|
||||
$this->Form->create(null),
|
||||
$this->Form->end()
|
||||
);
|
||||
$bodyHtml .= $form;
|
||||
|
||||
echo $this->Bootstrap->modal([
|
||||
'title' => h($title),
|
||||
|
@ -117,3 +127,15 @@ echo $this->Bootstrap->modal([
|
|||
'confirmFunction' => 'updateMetaTemplate',
|
||||
]);
|
||||
?>
|
||||
|
||||
<script>
|
||||
function updateMetaTemplate(modalObject, tmpApi) {
|
||||
const $form = modalObject.$modal.find('form')
|
||||
return tmpApi.postForm($form[0]).catch((errors) => {
|
||||
const formHelper = new FormValidationHelper($form[0])
|
||||
const errorHTMLNode = formHelper.buildValidationMessageNode(errors, true)
|
||||
modalObject.$modal.find('div.form-error-container').append(errorHTMLNode)
|
||||
return errors
|
||||
})
|
||||
}
|
||||
</script>
|
|
@ -87,7 +87,7 @@
|
|||
'field' => 'disabled',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Disable'
|
||||
]
|
||||
],
|
||||
],
|
||||
'submit' => [
|
||||
'action' => $this->request->getParam('action')
|
||||
|
|
|
@ -107,7 +107,7 @@
|
|||
empty($action['title']) ? '' : h($action['title']),
|
||||
empty($action['dbclickAction']) ? '' : 'class="dblclickActionElement"',
|
||||
empty($action['onclick']) ? '' : sprintf('onClick="%s"', $action['onclick']),
|
||||
empty($action['variant']) ? 'outline-dark' : h($action['variant']),
|
||||
empty($action['variant']) ? 'outline-text' : h($action['variant']),
|
||||
$this->FontAwesome->getClass($action['icon'])
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
$value = Cake\Utility\Hash::extract($row, $field['data_path']);
|
||||
$value = empty($value[0]) ? '' : $value[0];
|
||||
echo $this->element('/genericElements/key', ['value' => $value, 'description' => $description ?? null]);
|
||||
?>
|
|
@ -57,7 +57,7 @@ foreach ($statistics['usage'] as $scope => $graphData) {
|
|||
'bodyClass' => 'py-1 px-2',
|
||||
'class' => ['shadow-sm', 'h-100']
|
||||
]);
|
||||
$statisticsHtml .= sprintf('<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="height: 90px;">%s</div>', $statPie);
|
||||
$statisticsHtml .= sprintf('<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="min-height: 90px;">%s</div>', $statPie);
|
||||
}
|
||||
?>
|
||||
|
||||
|
|
|
@ -95,7 +95,7 @@ $card = $this->Bootstrap->card([
|
|||
|
||||
?>
|
||||
|
||||
<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="height: 90px;"><?= $card ?></div>
|
||||
<div class="col-sm-6 col-md-5 col-lg-4 col-xl-3 mb-1" style="min-height: 90px;"><?= $card ?></div>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
|
|
@ -22,6 +22,7 @@ if (!empty($meta_templates)) {
|
|||
$numberActiveMetaField = !empty($tableSettings['visible_meta_column'][$meta_template->id]) ? count($tableSettings['visible_meta_column'][$meta_template->id]) : 0;
|
||||
$metaTemplateColumnMenu[] = [
|
||||
'text' => $meta_template->name,
|
||||
'sup' => $meta_template->version,
|
||||
'badge' => [
|
||||
'text' => $numberActiveMetaField,
|
||||
'variant' => 'secondary',
|
||||
|
|
|
@ -22,7 +22,9 @@ if (!empty($field['url'])) {
|
|||
'<a href="%s%s">%s</a>',
|
||||
$baseurl,
|
||||
h($field['url']),
|
||||
$string
|
||||
h($string)
|
||||
);
|
||||
} else if (empty($field['raw'])) {
|
||||
$string = h($string);
|
||||
}
|
||||
echo $string;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
$value = Cake\Utility\Hash::extract($data, $field['path']);
|
||||
$value = empty($value[0]) ? '' : $value[0];
|
||||
echo $this->element('/genericElements/key', ['value' => $value, 'description' => $description ?? null]);
|
||||
?>
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
if (empty($value)) {
|
||||
echo sprintf(
|
||||
'<span class="bold red">%s</span>',
|
||||
__('N/A')
|
||||
);
|
||||
} else {
|
||||
echo sprintf(
|
||||
'<details>%s%s</details>',
|
||||
!empty($description) ?
|
||||
sprintf(
|
||||
'<summary style="cursor: pointer">%s</summary>',
|
||||
h($description)
|
||||
) : '',
|
||||
sprintf(
|
||||
'<pre class="quickSelect" style="line-height: 1.44">%s</pre>',
|
||||
h($value)
|
||||
)
|
||||
);
|
||||
}
|
||||
?>
|
|
@ -49,7 +49,11 @@ if (!empty($breadcrumb)) {
|
|||
if (!empty($lastCrumb['links'])) {
|
||||
// dd($lastCrumb['links']);
|
||||
foreach ($lastCrumb['links'] as $i => $linkEntry) {
|
||||
$active = $linkEntry['route_path'] == $lastCrumb['route_path'];
|
||||
if (empty($linkEntry['route_path'])) {
|
||||
$active = false;
|
||||
} else {
|
||||
$active = $linkEntry['route_path'] == $lastCrumb['route_path'];
|
||||
}
|
||||
if (!empty($linkEntry['url_vars'])) {
|
||||
$linkEntry['url'] = $this->DataFromPath->buildStringFromDataPath($linkEntry['url'], $entity, $linkEntry['url_vars']);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use Cake\Core\Configure;
|
||||
use Cake\Routing\Router;
|
||||
?>
|
||||
<div class="btn-group">
|
||||
|
@ -29,6 +30,32 @@ use Cake\Routing\Router;
|
|||
<i class="me-1 <?= $this->FontAwesome->getClass('user-cog') ?>"></i>
|
||||
<?= __('Account Settings') ?>
|
||||
</a>
|
||||
<?php
|
||||
if (
|
||||
!empty($this->SocialProvider->hasSocialProfile($this->request->getAttribute('identity'))) &&
|
||||
!empty(Configure::read('keycloak.enabled')) &&
|
||||
!empty(Configure::read('keycloak.provider.baseUrl')) &&
|
||||
!empty(Configure::read('keycloak.provider.realm')) &&
|
||||
!empty($this->request->getAttribute('identity')['id'])
|
||||
):
|
||||
?>
|
||||
<a
|
||||
class="dropdown-item"
|
||||
title="<?= __('Manage SSO account') ?>"
|
||||
href="<?= sprintf(
|
||||
'%s/realms/%s/account',
|
||||
Configure::read('keycloak.provider.baseUrl'),
|
||||
Configure::read('keycloak.provider.realm')
|
||||
); ?>"
|
||||
>
|
||||
<?php if (!empty($this->SocialProvider->getIcon($this->request->getAttribute('identity')))): ?>
|
||||
<?= $this->SocialProvider->getIcon($this->request->getAttribute('identity')) ?>
|
||||
<?php else: ?>
|
||||
<i class="me-1 <?= $this->FontAwesome->getClass('key') ?>"></i>
|
||||
<?php endif; ?>
|
||||
<?= __('SSO Account') ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item dropdown-item-outline-danger" href="<?= Router::url(['controller' => 'users', 'action' => 'logout', 'plugin' => null]) ?>">
|
||||
<i class="me-1 <?= $this->FontAwesome->getClass('sign-out-alt') ?>"></i>
|
||||
|
|
|
@ -513,6 +513,10 @@ ul.sidebar-elements > li.category > span.category-divider > hr {
|
|||
padding: 2px;
|
||||
border-radius: 0 0 0 5px;
|
||||
}
|
||||
.header-breadcrumb-children .dropdown-menu .dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-breadcrumb-children .dropdown-menu .dropdown-item > i {
|
||||
min-width: 25px;
|
||||
}
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #212529;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #212529;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #303030;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #fff;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #212529;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #212529;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #7b8a8b;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #212529;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #7b8a8b;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #212529;
|
||||
border-color: #212529;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(33, 37, 41, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #212529;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #212529;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #000;
|
||||
background-color: #fff;
|
||||
border-color: #fff;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #fff;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #272b30;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #aaa;
|
||||
border-color: #aaa;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #aaa;
|
||||
border-color: #aaa;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(170, 170, 170, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #aaa;
|
||||
border-color: #aaa;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(170, 170, 170, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #aaa;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -237,6 +237,31 @@
|
|||
background-color: #170229;
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
color: #32fbe2;
|
||||
border-color: #32fbe2;
|
||||
}
|
||||
.btn-outline-text:hover {
|
||||
color: #fff;
|
||||
background-color: #32fbe2;
|
||||
border-color: #32fbe2;
|
||||
}
|
||||
.btn-check:focus + .btn-outline-text, .btn-outline-text:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(50, 251, 226, 0.5);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text, .btn-check:active + .btn-outline-text, .btn-outline-text:active, .btn-outline-text.active, .btn-outline-text.dropdown-toggle.show {
|
||||
color: #fff;
|
||||
background-color: #32fbe2;
|
||||
border-color: #32fbe2;
|
||||
}
|
||||
.btn-check:checked + .btn-outline-text:focus, .btn-check:active + .btn-outline-text:focus, .btn-outline-text:active:focus, .btn-outline-text.active:focus, .btn-outline-text.dropdown-toggle.show:focus {
|
||||
box-shadow: 0 0 0 0.25rem rgba(50, 251, 226, 0.5);
|
||||
}
|
||||
.btn-outline-text:disabled, .btn-outline-text.disabled {
|
||||
color: #32fbe2;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
|
@ -66,6 +66,10 @@ $toast-color-level: 70% !default;
|
|||
}
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
@include button-outline-variant($body-color);
|
||||
}
|
||||
|
||||
/* Progress Timeline */
|
||||
.progress-timeline {
|
||||
padding: 0.2em 0.2em 0.5em 0.2em;
|
||||
|
|
Loading…
Reference in New Issue