new [Authkey] implementation ready

- users can have multiple keys
- keys are hashed with bcrypt
- each key can have its own expiration
- each key can have a contextual comment

- authentication via API requests happens with the Authorization header
pull/32/head
iglocska 2020-08-07 21:47:04 +02:00
parent 4a3daabf11
commit b027007618
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
14 changed files with 144 additions and 28 deletions

View File

@ -114,13 +114,7 @@ class AppController extends Controller
{
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && strlen($_SERVER['HTTP_AUTHORIZATION'])) {
$this->loadModel('AuthKeys');
$authKey = $this->AuthKeys->find()->where([
'authkey' => $_SERVER['HTTP_AUTHORIZATION'],
'OR' => [
'valid_until' => 0,
'valid_until >' => time()
]
])->first();
$authKey = $this->AuthKeys->checkKey($_SERVER['HTTP_AUTHORIZATION']);
if (!empty($authKey)) {
$this->loadModel('Users');
$user = $this->Users->get($authKey['user_id']);

View File

@ -19,12 +19,13 @@ class AuthKeysController extends AppController
$this->CRUD->index([
'filters' => ['users.username', 'authkey', 'comment', 'users.id'],
'quickFilters' => ['authkey', 'comment'],
'contain' => ['Users']
'contain' => ['Users'],
'exclude_fields' => ['authkey']
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$this->set('metaGroup', 'ContactDB');
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}
public function delete($id)
@ -33,12 +34,15 @@ class AuthKeysController extends AppController
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
$this->set('metaGroup', 'ContactDB');
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
}
public function add()
{
$this->CRUD->add();
$this->set('metaGroup', $this->isAdmin ? 'Administration' : 'Cerebrate');
$this->CRUD->add([
'displayOnSuccess' => 'authkey_display'
]);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}
@ -49,6 +53,5 @@ class AuthKeysController extends AppController
])
];
$this->set(compact('dropdownData'));
$this->set('metaGroup', 'ContactDB');
}
}

View File

@ -4,6 +4,7 @@ namespace App\Controller\Component;
use Cake\Controller\Component;
use Cake\Error\Debugger;
use Cake\Utility\Hash;
use Cake\Utility\Inflector;
class CRUDComponent extends Component
@ -62,10 +63,15 @@ class CRUDComponent extends Component
if ($this->Table->save($data)) {
$message = __('{0} added.', $this->ObjectAlias);
if ($this->Controller->ParamHandler->isRest()) {
$data = $this->Table->get($id);
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
} else {
$this->Controller->Flash->success($message);
if (!empty($params['displayOnSuccess'])) {
$this->Controller->set('entity', $data);
$this->Controller->set('referer', $this->Controller->referer());
$this->Controller->render($params['displayOnSuccess']);
return;
}
$this->Controller->redirect(['action' => 'index']);
}
} else {
@ -97,7 +103,6 @@ class CRUDComponent extends Component
if ($this->Table->save($data)) {
$message = __('{0} updated.', $this->ObjectAlias);
if ($this->Controller->ParamHandler->isRest()) {
$data = $this->Table->get($id);
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
} else {
$this->Controller->Flash->success($message);

View File

@ -37,7 +37,7 @@ class EncryptionKeysController extends AppController
public function add()
{
$this->CRUD->add();
$this->CRUD->add(['displayOnSuccess' => 'add_success']);
if ($this->ParamHandler->isRest()) {
return $this->restResponsePayload;
}

View File

@ -8,4 +8,5 @@ use Cake\ORM\Entity;
class AuthKey extends AppModel
{
protected $_hidden = ['authkey'];
}

View File

@ -8,6 +8,7 @@ use Authentication\PasswordHasher\DefaultPasswordHasher;
class User extends AppModel
{
protected $_hidden = ['password'];
protected function _setPassword(string $password) : ?string
{
if (strlen($password) > 0) {

View File

@ -5,8 +5,12 @@ namespace App\Model\Table;
use App\Model\Table\AppTable;
use Cake\ORM\Table;
use Cake\Validation\Validator;
use Cake\Datasource\EntityInterface;
use Cake\Event\Event;
use Cake\Event\EventInterface;
use Cake\Auth\DefaultPasswordHasher;
use Cake\Utility\Security;
use Cake\Http\Exception\MethodNotAllowedException;
use ArrayObject;
class AuthKeysTable extends AppTable
@ -24,11 +28,21 @@ class AuthKeysTable extends AppTable
public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)
{
$data['created'] = time();
if (empty($data['valid_until'])) {
$data['valid_until'] = 0;
if (empty($data['expiration'])) {
$data['expiration'] = 0;
} else {
$data['expiration'] = strtotime($data['expiration']);
}
if (empty($data['authkey'])) {
$data['authkey'] = $this->generateAuthKey();
}
public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options)
{
if (empty($entity->authkey)) {
$authkey = $this->generateAuthKey();
$entity->authkey_start = substr($authkey, 0, 4);
$entity->authkey_end = substr($authkey, -4);
$entity->authkey = (new DefaultPasswordHasher())->hash($authkey);
$entity->authkey_raw = $authkey;
}
}
@ -40,9 +54,33 @@ class AuthKeysTable extends AppTable
public function validationDefault(Validator $validator): Validator
{
$validator
->notEmptyString('authkey')
->notEmptyString('user_id')
->requirePresence(['authkey', 'user_id'], 'create');
->requirePresence(['user_id'], 'create');
return $validator;
}
public function checkKey($authkey)
{
if (strlen($authkey) != 40) {
return [];
}
$start = substr($authkey, 0, 4);
$end = substr($authkey, -4);
$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'])) {
return $candidate;
}
}
}
return [];
}
}

View File

@ -13,8 +13,8 @@ echo $this->element('genericElements/Form/genericForm', array(
'field' => 'comment'
),
array(
'field' => 'valid_until',
'label' => 'Validity'
'field' => 'expiration',
'label' => 'Expiration'
)
),
'submit' => array(

View File

@ -0,0 +1,5 @@
<h4><?= __('Authkey created'); ?></h4>
<p><?= __('Please make sure that you note down the authkey below, this is the only time the authkey 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><?=__('Cerebrate will use the first and the last 4 digit for identification purposes.')?></p>
<p><?= sprintf('%s: <span class="text-weight-bold">%s</span>', __('Authkey'), h($entity->authkey_raw)) ?></p>
<a href="<?= $referer ?>" class="btn btn-primary"><?= __('I have noted down my key, take me back now') ?></a>

View File

@ -39,16 +39,33 @@ echo $this->element('genericElements/IndexTable/index_table', [
[
'name' => __('Auth key'),
'sort' => 'authkey',
'data_path' => 'authkey',
'element' => 'authkey',
'privacy' => 1
]
],
[
'name' => __('Expiration'),
'sort' => 'expiration',
'data_path' => 'expiration',
'element' => 'expiration'
],
[
'name' => __('Disabled'),
'sort' => 'disabled',
'data_path' => 'disabled',
'element' => 'boolean'
],
[
'name' => __('Comment'),
'sort' => 'comment',
'data_path' => 'comment',
],
],
'title' => __('Authentication key Index'),
'description' => __('A list of API keys bound to a user.'),
'pull' => 'right',
'actions' => [
[
'onclick' => 'populateAndLoadModal(\'/encryptionKeys/delete/[onclick_params_data_path]\');',
'onclick' => 'populateAndLoadModal(\'/authKeys/delete/[onclick_params_data_path]\');',
'onclick_params_data_path' => 'id',
'icon' => 'trash'
]

View File

@ -3,7 +3,7 @@
echo sprintf(
'%s',
sprintf(
'<button id="submitButton" class="btn btn-primary" onClick="%s">%s</button>',
'<button id="submitButton" class="btn btn-primary" onClick="%s" autofocus>%s</button>',
"$('#form-" . h($formRandomValue) . "').submit()",
__('Submit')
)

View File

@ -0,0 +1,14 @@
<?php
$start = $this->Hash->extract($row, 'authkey_start')[0];
$end = $this->Hash->extract($row, 'authkey_end')[0];
echo sprintf(
'<div>%s: <span class="font-weight-bold text-info">%s</span></div>',
__('Starts with'),
h($start)
);
echo sprintf(
'<div>%s: <span class="font-weight-bold text-info">%s</span></div>',
__('Ends with'),
h($end)
);
?>

View File

@ -13,7 +13,11 @@
}
$data = h($data);
if (is_numeric($data)) {
$data = date('Y-m-d H:i:s', $data);
if ($data == 0) {
__('N/A');
} else {
$data = date('Y-m-d H:i:s', $data);
}
}
if (!empty($field['onClick'])) {
$data = sprintf(

View File

@ -0,0 +1,34 @@
<?php
$data = $this->Hash->extract($row, $field['data_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-primary font-weight-bold">' . __('Indefinite') . '</span>';
} else {
if ($data <= time()) {
$data = '<span class="text-danger font-weight-bold">' . __('Expired') . '</span>';
} else {
$data = '<span class="text-success font-weight-bold">' . 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;
?>