add: migrate authkeys

pull/9215/head
Luciano Righetti 2023-07-26 16:02:22 +02:00
parent ac82a238ca
commit 5890b8c60f
14 changed files with 726 additions and 8 deletions

View File

@ -0,0 +1,287 @@
<?php
namespace App\Controller;
use App\Controller\AppController;
use Cake\Http\Exception\MethodNotAllowedException;
use Cake\ORM\Locator\LocatorAwareTrait;
use Cake\Core\Configure;
use Cake\Utility\Hash;
class AuthKeysController extends AppController
{
use LocatorAwareTrait;
public $paginate = [
'limit' => 60,
'maxLimit' => 9999,
'order' => [
'Authkey.id' => 'DESC'
],
];
public function index($user_id = false)
{
$conditions = $this->__prepareConditions();
$canCreateAuthkey = $this->__canCreateAuthKeyForUser($user_id);
if ($user_id) {
$this->set('user_id', $user_id);
$conditions['AND'][] = ['AuthKeys.user_id' => $user_id];
}
$this->set('canCreateAuthkey', $canCreateAuthkey);
$keyUsageEnabled = Configure::read('MISP.log_user_ips') && Configure::read('MISP.log_user_ips_authkeys');
$this->CRUD->index([
'filters' => ['Users.email', 'authkey_start', 'authkey_end', 'comment', 'Users.id'],
'quickFilters' => ['comment', 'authkey_start', 'authkey_end', 'Users.email'],
'conditions' => $conditions,
'contain' => ['Users' => ['fields' => ['id', 'email']]],
'afterFind' => function ($authKeys) use ($keyUsageEnabled) {
if ($keyUsageEnabled) {
$keyIds = Hash::extract($authKeys, "{n}.AuthKey.id");
$lastUsedById = $this->AuthKey->getLastUsageForKeys($keyIds);
}
$authKeys = $authKeys->toArray();
foreach ($authKeys as &$authKey) {
if ($keyUsageEnabled) {
$lastUsed = $lastUsedById[$authKey['id']];
$authKey['last_used'] = $lastUsed;
}
}
return $authKeys;
}
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$this->set('title_for_layout', __('Auth Keys'));
$this->set('advancedEnabled', !empty(Configure::read('Security.advanced_authkeys')));
$this->set('keyUsageEnabled', $keyUsageEnabled);
$this->set('menuData', [
'menuList' => $this->isSiteAdmin() ? 'admin' : 'globalActions',
'menuItem' => 'authkeys_index',
]);
}
public function delete($id)
{
if (!$this->__canEditAuthKey($id)) {
throw new MethodNotAllowedException(__('Invalid user or insufficient privileges to interact with an authkey for the given user.'));
}
$this->CRUD->delete($id, [
'conditions' => $this->__prepareConditions(),
'contain' => ['User'],
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
}
public function edit($id)
{
if (!$this->__canEditAuthKey($id)) {
throw new MethodNotAllowedException(__('Invalid user or insufficient privileges to interact with an authkey for the given user.'));
}
$this->CRUD->edit($id, [
'conditions' => $this->__prepareConditions(),
'afterFind' => function (\App\Model\Entity\AuthKey $authKey) {
return $authKey;
},
'fields' => ['comment', 'allowed_ips', 'expiration', 'read_only'],
'contain' => ['Users' => ['fields' => ['id', 'org_id']]]
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$this->set('dropdownData', [
'user' => $this->Users->find('list', [
'sort' => ['username' => 'asc'],
'conditions' => ['id' => $this->entity['user_id']],
])
]);
$this->set('menuData', [
'menuList' => $this->isSiteAdmin() ? 'admin' : 'globalActions',
'menuItem' => 'authKeyAdd',
]);
$this->set('edit', true);
$this->set('validity', Configure::read('Security.advanced_authkeys_validity'));
$this->set('title_for_layout', __('Edit auth key'));
$this->render('add');
}
public function add($user_id = false)
{
$loggedUser = $this->ACL->getUser();
$options = $this->request->getParam('user_id');
if (!empty($params['user_id'])) {
$user_id = $options['user_id'];
}
$params = [
'displayOnSuccess' => 'authkey_display',
'override' => ['authkey' => null], // do not allow to use own key, always generate random one
'afterFind' => function (array $authKey, array $savedData) { // remove hashed key from response
unset($authKey['authkey']);
$authKey['authkey_raw'] = $savedData['authkey_raw'];
return $authKey;
}
];
if ($user_id === 'me' || $user_id === false) {
$user_id = $loggedUser->id;
}
$selectConditions = [];
if ($user_id) {
if ($this->__canCreateAuthKeyForUser($user_id)) {
$selectConditions['AND'][] = ['Users.id' => $user_id];
$params['override']['user_id'] = $user_id;
} else {
throw new MethodNotAllowedException(__('Invalid user or insufficient privileges to interact with an authkey for the given user.'));
}
} else {
$selectConditions['AND'][] = ['Users.id' => $loggedUser->id];
$params['override']['user_id'] = $loggedUser->id;
}
$this->CRUD->add($params);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$dropdownData = [
'user' => $this->AuthKeys->Users->find('list', [
'sort' => ['username' => 'asc'],
'conditions' => $selectConditions,
])
];
$this->set(compact('dropdownData'));
$this->set('title_for_layout', __('Add auth key'));
$this->set('menuData', [
'menuList' => $this->isSiteAdmin() ? 'admin' : 'globalActions',
'menuItem' => 'authKeyAdd',
]);
$this->set('validity', Configure::read('Security.advanced_authkeys_validity'));
}
public function view($id = false)
{
$this->CRUD->view($id, [
'contain' => ['Users' => ['fields' => ['id', 'email']]],
'conditions' => $this->__prepareConditions(),
'afterFind' => function (\App\Model\Entity\AuthKey $authKey) {
return $authKey;
}
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
if (Configure::read('MISP.log_user_ips') && Configure::read('MISP.log_user_ips_authkeys')) {
list($keyUsage, $lastUsed, $uniqueIps) = $this->AuthKey->getKeyUsage($id);
$this->set('keyUsage', $keyUsage);
$this->set('lastUsed', $lastUsed);
$this->set('uniqueIps', $uniqueIps);
}
$this->set('title_for_layout', __('Auth key'));
$this->set('menuData', [
'menuList' => $this->isSiteAdmin() ? 'admin' : 'globalActions',
'menuItem' => 'authKeyView',
]);
}
public function pin($id, $ip)
{
if (!$this->__canEditAuthKey($id)) {
throw new MethodNotAllowedException(__('Invalid user or insufficient privileges to interact with an authkey for the given user.'));
}
if ($this->request->is('post')) {
// find entry, to confirm user is authorized
$conditions = $this->__prepareConditions();
$conditions['AND'][]['AuthKey.id'] = $id;
$authKey = $this->AuthKey->find(
'first',
[
'conditions' => $conditions,
'recursive' => 1
]
);
// update the key with the source IP
if ($authKey) {
$authKey['allowed_ips'] = $ip;
$this->AuthKey->save($authKey, ['fieldList' => ['allowed_ips']]);
$this->Flash->success(__('IP address set as allowed source for the Key.'));
} else {
$this->Flash->error(__('Failed to set IP as source'));
}
}
$this->redirect($this->referer());
// $this->redirect(['controller' => 'auth_keys', 'view' => 'index']);
}
/**
* Return conditions according to current user permission.
* @return array
*/
private function __prepareConditions()
{
$user = $this->ACL->getUser();
if ($user['Role']['perm_site_admin']) {
$conditions = []; // site admin can see/edit all keys
} else if ($user['Role']['perm_admin']) {
$conditions['AND'][]['org_id'] = $user['org_id']; // org admin can see his/her user org auth keys
} else {
$conditions['AND'][]['id'] = $user['id'];
}
return $conditions;
}
private function __canCreateAuthKeyForUser($user_id)
{
$loggedUser = $this->ACL->getUser();
if (!$user_id)
return true;
if ($this->isAdmin) {
if ($this->isSiteAdmin()) {
return true; // site admin is OK for all
} else {
// org admin only for non-admin users and themselves
$user = $this->AuthKey->User->find('first', [
'recursive' => -1,
'conditions' => [
'User.id' => $user_id,
'User.disabled' => false
],
'fields' => ['User.id', 'User.org_id', 'User.disabled'],
'contain' => [
'Role' => [
'fields' => [
'Role.perm_site_admin', 'Role.perm_admin', 'Role.perm_auth'
]
]
]
]);
if (
$user['Role']['perm_site_admin'] ||
($user['Role']['perm_admin'] && $user['User']['id'] !== $loggedUser->id) ||
!$user['Role']['perm_auth']
) {
// no create/edit for site_admin or other org admin
return false;
} else {
// ok for themselves or users
return true;
}
}
} else {
// user for themselves
return (int)$user_id === (int)$loggedUser->id;
}
}
private function __canEditAuthKey($key_id)
{
$user_id = $this->AuthKeys->find('column', [
'fields' => ['user_id'],
'conditions' => [
'id' => $key_id
]
]);
return $this->__canCreateAuthKeyForUser($user_id);
}
}

View File

@ -3,10 +3,8 @@
namespace App\Model\Entity;
use App\Model\Entity\AppModel;
use Cake\ORM\Entity;
class AuthKey extends AppModel
{
protected $_hidden = ['authkey'];
}

View File

@ -3,13 +3,13 @@
namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\Validation\Validator;
use ArrayObject;
use Cake\Auth\DefaultPasswordHasher;
use Cake\Datasource\EntityInterface;
use Cake\Event\Event;
use Cake\Event\EventInterface;
use Cake\Auth\DefaultPasswordHasher;
use Cake\Utility\Security;
use ArrayObject;
use Cake\Validation\Validator;
class AuthKeysTable extends AppTable
{
@ -19,7 +19,12 @@ class AuthKeysTable extends AppTable
$this->addBehavior('UUID');
$this->addBehavior('AuditLog');
$this->belongsTo(
'Users'
'Users',
[
'dependent' => false,
'cascadeCallbacks' => false,
'propertyName' => 'User'
]
);
$this->setDisplayField('comment');
}
@ -65,14 +70,16 @@ class AuthKeysTable extends AppTable
}
$start = substr($authkey, 0, 4);
$end = substr($authkey, -4);
$candidates = $this->find()->where([
$candidates = $this->find()->where(
[
'authkey_start' => $start,
'authkey_end' => $end,
'OR' => [
'expiration' => 0,
'expiration >' => time()
]
]);
]
);
if (!empty($candidates)) {
foreach ($candidates as $candidate) {
if ((new DefaultPasswordHasher())->check($authkey, $candidate['authkey'])) {

View File

@ -0,0 +1,48 @@
<?php
echo $this->element('genericElements/Form/genericForm', [
'data' => [
'title' => isset($edit) ? __('Edit auth key') : __('Add auth key'),
'description' => __('Auth keys are used for API access. A user can have more than one authkey, so if you would like to use separate keys per tool that queries MISP, add additional keys. Use the comment field to make identifying your keys easier.'),
'fields' => [
[
'field' => 'user_id',
'label' => __('User'),
'options' => $dropdownData['user'],
'type' => 'dropdown',
'class' => 'span6'
],
[
'field' => 'comment',
'label' => __('Comment'),
'class' => 'span6',
'rows' => 4,
],
[
'field' => 'allowed_ips',
'label' => __('Allowed IPs'),
'class' => 'span6',
'rows' => 4,
],
[
'field' => 'expiration',
'label' => __('Expiration (%s)', $validity ? __('keep empty for maximal validity of %s days', $validity) : __('keep empty for indefinite')),
'class' => 'datepicker span6',
'placeholder' => "YYYY-MM-DD",
'type' => 'text'
],
[
'field' => 'read_only',
'label' => __('Read only (it will be not possible to do any change operation with this token)'),
'type' => 'checkbox',
]
],
'submit' => [
'action' => $this->request->getParam('action'),
'ajaxSubmit' => 'submitGenericFormInPlace();'
]
]
]);
// TODO: [3.x-MIGRATION]
// if (!$ajax) {
// echo $this->element('/genericElements/SideMenu/side_menu', $menuData);
// }

View File

@ -0,0 +1,28 @@
<?php
if ($ajax) {
?>
<div id="genericModal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="genericModalLabel" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
<h3 id="genericModalLabel"><?= __('Auth key created'); ?></h3>
</div>
<div class="modal-body modal-body-long">
<p><?= __('Please make sure that you note down the auth key below, this is the only time the auth key is shown in plain text, so make sure you save it. If you lose the key, simply remove the entry and generate a new one.'); ?></p>
<p><?=__('MISP will use the first and the last 4 characters for identification purposes.')?></p>
<pre class="quickSelect"><?= h($entity['AuthKey']['authkey_raw']) ?></pre>
</div>
<div class="modal-footer">
<a href="<?= h($referer) ?>" class="btn btn-primary"><?= __('I have noted down my key, take me back now') ?></a>
</div>
</div>
<?php
} else {
?>
<h4><?= __('Auth key created'); ?></h4>
<p><?= __('Please make sure that you note down the auth key below, this is the only time the auth key is shown in plain text, so make sure you save it. If you lose the key, simply remove the entry and generate a new one.'); ?></p>
<p><?=__('MISP will use the first and the last 4 characters for identification purposes.')?></p>
<pre class="quickSelect"><?= h($entity['AuthKey']['authkey_raw']) ?></pre>
<a href="<?= h($referer) ?>" class="btn btn-primary"><?= __('I have noted down my key, take me back now') ?></a>
<?php
}
?>

View File

@ -0,0 +1,126 @@
<?php
echo sprintf('<div%s>', empty($ajax) ? ' class="index"' : '');
if (!$advancedEnabled) {
echo '<div class="alert">' . __('Advanced auth keys are not enabled.') . '</div>';
}
echo $this->element('genericElements/IndexTable/index_table', [
'data' => [
'data' => $data,
'top_bar' => [
'pull' => 'right',
'children' => [
[
'type' => 'simple',
'children' => [
'data' => [
'type' => 'simple',
'fa-icon' => 'plus',
'text' => __('Add authentication key'),
'class' => 'btn-primary modal-open',
'url' => '/auth-keys/add' . (empty($user_id) ? '' : ('/' . $user_id)),
'requirement' => $canCreateAuthkey
]
]
],
[
'type' => 'search',
'button' => __('Filter'),
'placeholder' => __('Enter value to search'),
'searchKey' => 'quickFilter',
]
]
],
'fields' => [
[
'name' => '#',
'sort' => 'id',
'data_path' => 'id',
],
[
'name' => __('User'),
'sort' => 'User.email',
'data_path' => 'User.email',
'url' => '/users/view',
'url_params_data_paths' => ['User.id'],
'requirement' => $loggedUser['Role']['perm_admin'] || $loggedUser['Role']['perm_site_admin'],
],
[
'name' => __('Auth Key'),
'sort' => 'authkey_start',
'element' => 'authkey',
'data_path' => 'AuthKey',
],
[
'name' => __('Expiration'),
'sort' => 'expiration',
'data_path' => 'expiration',
'element' => 'expiration'
],
[
'name' => ('Last used'),
'data_path' => 'last_used',
'element' => 'datetime',
'requirements' => $keyUsageEnabled,
'empty' => __('Never'),
],
[
'name' => __('Comment'),
'sort' => 'comment',
'data_path' => 'comment',
],
[
'name' => __('Allowed IPs'),
'data_path' => 'allowed_ips',
],
[
'name' => __('Seen IPs'),
'data_path' => 'unique_ips',
'element' => 'authkey_pin',
]
],
'title' => empty($ajax) ? __('Authentication key Index') : false,
'description' => empty($ajax) ? __('A list of API keys bound to a user.') : false,
'pull' => 'right',
'actions' => [
[
'url' => '/auth-keys/view',
'url_params_data_paths' => array(
'id'
),
'icon' => 'eye',
'title' => 'View auth key',
],
[
'url' => '/auth-keys/edit',
'url_params_data_paths' => array(
'id'
),
'icon' => 'edit',
'title' => 'Edit auth key',
'requirement' => $canCreateAuthkey
],
[
'class' => 'modal-open',
'url' => '/authKeys/delete',
'url_params_data_paths' => ['id'],
'icon' => 'trash',
'title' => __('Delete auth key'),
'requirement' => $canCreateAuthkey
]
]
]
]);
echo '</div>';
// TODO: [3.x-MIGRATION]
// if (empty($ajax)) {
// echo $this->element('/genericElements/SideMenu/side_menu', $menuData);
// }
?>
<script type="text/javascript">
var passedArgsArray = <?php echo $passedArgs; ?>;
$(function() {
$('#quickFilterButton').click(function() {
runIndexQuickFilter();
});
});
</script>

View File

@ -0,0 +1,91 @@
<?php
$keyUsageCsv = null;
if (isset($keyUsage)) {
$todayString = date('Y-m-d');
$today = strtotime($todayString);
$startDate = key($keyUsage); // oldest date for sparkline
$startDate = strtotime($startDate) - (3600 * 24 * 3);
$keyUsageCsv = 'Date,Close\n';
for ($date = $startDate; $date <= $today; $date += (3600 * 24)) {
$dateAsString = date('Y-m-d', $date);
$keyUsageCsv .= $dateAsString . ',' . (isset($keyUsage[$dateAsString]) ? $keyUsage[$dateAsString] : 0) . '\n';
}
} else {
$lastUsed = null;
$uniqueIps = null;
}
echo $this->element('genericElements/SingleViews/single_view', [
'title' => 'Auth key view',
'data' => $entity,
'fields' => [
[
'key' => __('ID'),
'path' => 'id'
],
[
'key' => __('UUID'),
'path' => 'uuid',
],
[
'key' => __('Auth Key'),
'path' => 'AuthKey',
'type' => 'authkey'
],
[
'key' => __('User'),
'path' => 'User.id',
'pathName' => 'User.email',
'model' => 'users',
'type' => 'model'
],
[
'key' => __('Comment'),
'path' => 'comment'
],
[
'key' => __('Allowed IPs'),
'type' => 'custom',
'function' => function (array $data) {
if (is_array($data['allowed_ips'])) {
return implode("<br />", array_map('h', $data['allowed_ips']));
}
return __('All');
}
],
[
'key' => __('Created'),
'path' => 'created',
'type' => 'datetime'
],
[
'key' => __('Expiration'),
'path' => 'expiration',
'type' => 'expiration'
],
[
'key' => __('Read only'),
'path' => 'read_only',
'type' => 'boolean'
],
[
'key' => __('Key usage'),
'type' => 'sparkline',
'path' => 'id',
'csv' => [
'data' => $keyUsageCsv,
],
'requirement' => isset($keyUsage),
],
[
'key' => __('Last used'),
'raw' => $lastUsed ? $this->Time->time($lastUsed) : __('Not used yet'),
'requirement' => isset($keyUsage),
],
[
'key' => __('Seen IPs'),
'path' => 'unique_ips',
'type' => 'authkey_pin'
]
],
]);

View File

@ -0,0 +1,27 @@
<?php
$data_path = $this->Hash->extract($row, $field['data_path']);
$result = [];
foreach ($data_path as $key => $ip) {
$data_ip['ip'] = $ip;
$action = ['class' => 'modal-open',
'url' => $baseurl. "/authKeys/pin/" . h($row['AuthKey']['id']) . '/' . h($ip),
'icon' => 'thumbtack',
'postLink' => true,
'postLinkConfirm' => __('Use this as only possible source IP?'),
'title' => __('Use this IP')];
$form = $this->Form->postLink(
'',
$action['url'],
array(
'class' => $this->FontAwesome->getClass($action['icon']) . ' ' . (empty($action['class']) ? '' : h($action['class'])),
'title' => empty($action['title']) ? '' : h($action['title']),
'aria-label' => empty($action['title']) ? '' : h($action['title']),
),
$action['postLinkConfirm']
) . ' ';
$result[$key] = h($ip) . " " . $form;
}
$result = implode('<br />', $result);
echo $result;
?>

View File

@ -0,0 +1,8 @@
<?php
$authKey = $this->Hash->extract($data, $field['path']);
echo sprintf(
'<span class="authkey">%s</span>%s<span class="authkey">%s</span>',
h($authKey['authkey_start']),
str_repeat('&bull;', 32),
h($authKey['authkey_end'])
);

View File

@ -0,0 +1,28 @@
<?php
$data_path = $this->Hash->extract($data, $field['path']);
$result = [];
foreach ($data_path as $key => $ip) {
$data_ip['ip'] = $ip;
$action = [
'class' => 'modal-open',
'url' => $baseurl . "/authKeys/pin/" . h($data['AuthKey']['id']) . '/' . h($ip),
'icon' => 'thumbtack',
'postLink' => true,
'postLinkConfirm' => __('Use this as only possible source IP?'),
'title' => __('Use this IP')
];
$form = $this->Form->postLink(
'',
$action['url'],
array(
'class' => $this->FontAwesome->getClass($action['icon']) . ' ' . (empty($action['class']) ? '' : h($action['class'])),
'title' => empty($action['title']) ? '' : h($action['title']),
'aria-label' => empty($action['title']) ? '' : h($action['title']),
),
$action['postLinkConfirm']
) . ' ';
$result[$key] = h($ip) . " " . $form;
}
$result = implode('<br />', $result);
echo $result;

View File

@ -0,0 +1,3 @@
<?php
$value = $this->Hash->extract($data, $field['path'])[0];
echo $this->Time->time($value);

View File

@ -0,0 +1,37 @@
<?php
$data = $this->Hash->extract($data, $field['path']);
if (is_array($data)) {
if (count($data) > 1) {
$data = implode(', ', $data);
} else {
if (count($data) > 0) {
$data = $data[0];
} else {
$data = '';
}
}
}
$data = h($data);
if (is_numeric($data)) {
if ($data == 0) {
$data = '<span class="text-success">' . __('Indefinite') . '</span>';
} else {
if ($data <= time()) {
$title = __('Expired at %s', date('Y-m-d H:i:s', $data));
$data = '<span class="red bold" title="' . $title . '">' . __('Expired') . '</span>';
} else {
$diffInDays = floor(($data - time()) / (3600 * 24));
$class = $diffInDays <= 14 ? 'text-warning bold' : 'text-success';
$title = __n('Will expire in %s day', 'Will expire in %s days', $diffInDays, $diffInDays);
$data = '<span class="' . $class . '" title="' . $title . '">' . date('Y-m-d H:i:s', $data) . '</span>';
}
}
}
if (!empty($field['onClick'])) {
$data = sprintf(
'<span onclick="%s">%s</span>',
$field['onClick'],
$data
);
}
echo $data;

View File

@ -0,0 +1,16 @@
<?php
$path = $this->Hash->extract($data, $field['path']);
$pathName = $this->Hash->extract($data, $field['pathName']);
if (!empty($path) && !empty($pathName)) {
$id = $this->Hash->extract($data, $field['path'])[0];
$pathName = $this->Hash->extract($data, $field['pathName'])[0];
echo sprintf(
'<a href="%s/%s/view/%s">%s</a>',
$baseurl,
$field['model'],
h($id),
h($pathName)
);
} else {
echo empty($field['error']) ? '&nbsp;' : h($field['error']);
}

View File

@ -0,0 +1,14 @@
<?php
$elementId = $this->Hash->extract($data, $field['path'])[0];
if (!empty($field['csv_data_path'])) {
$csv = $this->Hash->extract($data, $field['csv_data_path']);
if (!empty($csv)) {
$csv = $csv[0];
}
} else {
$csv = $field['csv']['data'];
}
if (!empty($csv)) {
$scope = empty($field['csv']['scope']) ? '' : $field['csv']['scope'];
echo $this->element('sparkline', array('scope' => $scope, 'id' => $elementId, 'csv' => $csv));
}