Merge pull request #73 from mokaddem/feature-self-registration

new: [user:registration] Added user self-registration feature
pull/79/head
Andras Iklody 2021-10-20 22:41:01 +02:00 committed by GitHub
commit 85741402e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 244 additions and 159 deletions

View File

@ -1,5 +1,6 @@
<?php <?php
use Cake\ORM\TableRegistry; use Cake\ORM\TableRegistry;
use Authentication\PasswordHasher\DefaultPasswordHasher;
require_once(ROOT . DS . 'libraries' . DS . 'default' . DS . 'InboxProcessors' . DS . 'GenericInboxProcessor.php'); require_once(ROOT . DS . 'libraries' . DS . 'default' . DS . 'InboxProcessors' . DS . 'GenericInboxProcessor.php');
@ -42,13 +43,25 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
'message' => 'E-mail must be valid' 'message' => 'E-mail must be valid'
]) ])
->notEmpty('first_name', 'A first name must be provided') ->notEmpty('first_name', 'A first name must be provided')
->notEmpty('last_name', 'A last name must be provided'); ->notEmpty('last_name', 'A last name must be provided')
->add('password', 'password_complexity', [
'rule' => function($value, $context) {
if (!preg_match('/^((?=.*\d)|(?=.*\W+))(?![\n])(?=.*[A-Z])(?=.*[a-z]).*$|.{16,}/s', $value) || strlen($value) < 12) {
return false;
}
return true;
},
'message' => __('Invalid password. Passwords have to be either 16 character long or 12 character long with 3/4 special groups.')
]);
} }
public function create($requestData) { public function create($requestData) {
$this->validateRequestData($requestData); $this->validateRequestData($requestData);
$requestData['data']['password'] = (new DefaultPasswordHasher())->hash($requestData['data']['password']);
$requestData['title'] = __('User account creation requested for {0}', $requestData['data']['email']); $requestData['title'] = __('User account creation requested for {0}', $requestData['data']['email']);
return parent::create($requestData); $creationResponse = parent::create($requestData);
$creationResponse['message'] = __('User account creation requested. Please wait for an admin to approve your account.');
return $creationResponse;
} }
public function getViewVariables($request) public function getViewVariables($request)
@ -72,6 +85,11 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
'username' => !empty($request['data']['username']) ? $request['data']['username'] : '', 'username' => !empty($request['data']['username']) ? $request['data']['username'] : '',
'role_id' => !empty($request['data']['role_id']) ? $request['data']['role_id'] : '', 'role_id' => !empty($request['data']['role_id']) ? $request['data']['role_id'] : '',
'disabled' => !empty($request['data']['disabled']) ? $request['data']['disabled'] : '', 'disabled' => !empty($request['data']['disabled']) ? $request['data']['disabled'] : '',
'email' => !empty($request['data']['email']) ? $request['data']['email'] : '',
'first_name' => !empty($request['data']['first_name']) ? $request['data']['first_name'] : '',
'last_name' => !empty($request['data']['last_name']) ? $request['data']['last_name'] : '',
'position' => !empty($request['data']['position']) ? $request['data']['position'] : '',
]); ]);
return [ return [
'dropdownData' => $dropdownData, 'dropdownData' => $dropdownData,
@ -82,6 +100,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
public function process($id, $requestData, $inboxRequest) public function process($id, $requestData, $inboxRequest)
{ {
$hashedPassword = $inboxRequest['data']['password'];
if ($requestData['individual_id'] == -1) { if ($requestData['individual_id'] == -1) {
$individual = $this->Users->Individuals->newEntity([ $individual = $this->Users->Individuals->newEntity([
'uuid' => $requestData['uuid'], 'uuid' => $requestData['uuid'],
@ -101,6 +120,7 @@ class RegistrationProcessor extends UserInboxProcessor implements GenericInboxPr
'role_id' => $requestData['role_id'], 'role_id' => $requestData['role_id'],
'disabled' => $requestData['disabled'], 'disabled' => $requestData['disabled'],
]); ]);
$user->set('password', $hashedPassword, ['setter' => false]); // ignore default password hashing as it has already been hashed
$user = $this->Users->save($user); $user = $this->Users->save($user);
if ($user !== false) { if ($user !== false) {

View File

@ -1,99 +1,88 @@
<?php <?php
$formUser = $this->element('genericElements/Form/genericForm', [ $combinedForm = $this->element('genericElements/Form/genericForm', [
'entity' => $userEntity, 'entity' => $userEntity,
'ajax' => false, 'ajax' => false,
'raw' => true, 'raw' => true,
'data' => [ 'data' => [
'description' => __('Create user account'), 'description' => __('Create user account'),
'model' => 'User', 'model' => 'User',
'fields' => [ 'fields' => [
[ [
'field' => 'individual_id', 'field' => 'individual_id',
'type' => 'dropdown', 'type' => 'dropdown',
'label' => __('Associated individual'), 'label' => __('Associated individual'),
'options' => $dropdownData['individual'], 'options' => $dropdownData['individual'],
],
[
'field' => 'username',
'autocomplete' => 'off',
],
[
'field' => 'role_id',
'type' => 'dropdown',
'label' => __('Role'),
'options' => $dropdownData['role']
],
[
'field' => 'disabled',
'type' => 'checkbox',
'label' => 'Disable'
]
], ],
'submit' => [ [
'action' => $this->request->getParam('action') 'field' => 'username',
] 'autocomplete' => 'off',
] ],
]); [
'field' => 'role_id',
$formIndividual = $this->element('genericElements/Form/genericForm', [ 'type' => 'dropdown',
'entity' => $individualEntity, 'label' => __('Role'),
'ajax' => false, 'options' => $dropdownData['role']
'raw' => true, ],
'data' => [ [
'description' => __('Create individual'), 'field' => 'disabled',
'model' => 'Individual', 'type' => 'checkbox',
'fields' => [ 'label' => 'Disable'
[
'field' => 'email',
'autocomplete' => 'off'
],
[
'field' => 'uuid',
'label' => 'UUID',
'type' => 'uuid',
'autocomplete' => 'off'
],
[
'field' => 'first_name',
'autocomplete' => 'off'
],
[
'field' => 'last_name',
'autocomplete' => 'off'
],
[
'field' => 'position',
'autocomplete' => 'off'
],
], ],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);
echo $this->Bootstrap->modal([ sprintf('<div class="pb-2 fs-4">%s</div>', __('Create individual')),
'title' => __('Register user'), [
'size' => 'lg', 'field' => 'email',
'type' => 'confirm', 'autocomplete' => 'off'
'bodyHtml' => sprintf('<div class="user-container">%s</div><div class="individual-container">%s</div>', ],
$formUser, [
$formIndividual 'field' => 'uuid',
), 'label' => 'UUID',
'confirmText' => __('Create user'), 'type' => 'uuid',
'confirmFunction' => 'submitRegistration' 'autocomplete' => 'off'
]); ],
[
'field' => 'first_name',
'autocomplete' => 'off'
],
[
'field' => 'last_name',
'autocomplete' => 'off'
],
[
'field' => 'position',
'autocomplete' => 'off'
],
],
'submit' => [
'action' => $this->request->getParam('action')
]
]
]);
echo $this->Bootstrap->modal([
'title' => __('Register user'),
'size' => 'lg',
'type' => 'confirm',
'bodyHtml' => sprintf(
'<div class="form-container">%s</div>',
$combinedForm
),
'confirmText' => __('Create user'),
'confirmFunction' => 'submitRegistration'
]);
?> ?>
</div> </div>
<script> <script>
function submitRegistration(modalObject, tmpApi) { function submitRegistration(modalObject, tmpApi) {
const $forms = modalObject.$modal.find('form') const $form = modalObject.$modal.find('form')
const url = $forms[0].action tmpApi.postForm($form[0]).then(() => {
const data1 = getFormData($forms[0]) const url = '/inbox/index'
const data2 = getFormData($forms[1]) const $container = $('div[id^="table-container-"]')
const data = {...data1, ...data2} const randomValue = $container.attr('id').split('-')[2]
return tmpApi.postData(url, data) UI.reload(url, $(`#table-container-${randomValue}`), $(`#table-container-${randomValue} table.table`))
})
} }
$(document).ready(function() { $(document).ready(function() {
@ -107,7 +96,7 @@
}) })
function getFormData(form) { function getFormData(form) {
return Object.values(form).reduce((obj,field) => { return Object.values(form).reduce((obj, field) => {
if (field.type === 'checkbox') { if (field.type === 'checkbox') {
obj[field.name] = field.checked; obj[field.name] = field.checked;
} else { } else {
@ -119,7 +108,8 @@
</script> </script>
<style> <style>
div.individual-container > div, div.user-container > div { div.individual-container>div,
font-size: 1.5rem; div.user-container>div {
} font-size: 1.5rem;
}
</style> </style>

View File

@ -254,7 +254,7 @@ class ACLComponent extends Component
*/ */
public function setPublicInterfaces(): void public function setPublicInterfaces(): void
{ {
$this->Authentication->allowUnauthenticated(['login']); $this->Authentication->allowUnauthenticated(['login', 'register']);
} }
private function checkAccessInternal($controller, $action, $soft): bool private function checkAccessInternal($controller, $action, $soft): bool

View File

@ -6,6 +6,8 @@ use Cake\Utility\Hash;
use Cake\Utility\Text; use Cake\Utility\Text;
use Cake\ORM\TableRegistry; use Cake\ORM\TableRegistry;
use \Cake\Database\Expression\QueryExpression; use \Cake\Database\Expression\QueryExpression;
use Cake\Http\Exception\UnauthorizedException;
use Cake\Core\Configure;
class UsersController extends AppController class UsersController extends AppController
{ {
@ -160,19 +162,27 @@ class UsersController extends AppController
public function register() public function register()
{ {
$this->InboxProcessors = TableRegistry::getTableLocator()->get('InboxProcessors'); if (empty(Configure::read('Cerebrate')['security.registration.self-registration'])) {
$processor = $this->InboxProcessors->getProcessor('User', 'Registration'); throw new UnauthorizedException(__('User self-registration is not open.'));
$data = [ }
'origin' => '127.0.0.1', if ($this->request->is('post')) {
'comment' => 'Hi there!, please create an account', $data = $this->request->getData();
'data' => [ $this->InboxProcessors = TableRegistry::getTableLocator()->get('InboxProcessors');
'username' => 'foobar', $processor = $this->InboxProcessors->getProcessor('User', 'Registration');
'email' => 'foobar@admin.test', $data = [
'first_name' => 'foo', 'origin' => $this->request->clientIp(),
'last_name' => 'bar', 'comment' => '-no comment-',
], 'data' => [
]; 'username' => $data['username'],
$processorResult = $processor->create($data); 'email' => $data['email'],
return $processor->genHTTPReply($this, $processorResult, ['controller' => 'Inbox', 'action' => 'index']); 'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'password' => $data['password'],
],
];
$processorResult = $processor->create($data);
return $processor->genHTTPReply($this, $processorResult, ['controller' => 'Inbox', 'action' => 'index']);
}
$this->viewBuilder()->setLayout('login');
} }
} }

View File

@ -240,6 +240,16 @@ class CerebrateSettingsProvider extends BaseSettingsProvider
] ]
], ],
'Security' => [ 'Security' => [
'Registration' => [
'Registration' => [
'security.registration.self-registration' => [
'name' => __('Allow self-registration'),
'type' => 'boolean',
'description' => __('Enable the self-registration feature where user can request account creation. Admin can view the request and accept it in the application inbox.'),
'default' => false,
],
]
],
'Development' => [ 'Development' => [
'Debugging' => [ 'Debugging' => [
'security.debug' => [ 'security.debug' => [

View File

@ -1,53 +1,58 @@
<?php <?php
use Cake\Core\Configure; use Cake\Core\Configure;
echo '<div class="form-signin panel shadow position-absolute start-50 translate-middle">';
echo sprintf(
'<div class="text-center mb-4">%s</div>',
$this->Html->image('logo-purple.png', [
'alt' => __('Cerebrate logo'),
'width' => 100, 'height' => 100,
'style' => ['filter: drop-shadow(4px 4px 4px #924da666);']
])
);
echo sprintf('<h4 class="text-uppercase fw-light mb-3">%s</h4>', __('Sign in'));
$template = [
'inputContainer' => '<div class="form-floating input {{type}}{{required}}">{{content}}</div>',
'formGroup' => '{{input}}{{label}}',
'submitContainer' => '<div class="submit d-grid">{{content}}</div>',
];
$this->Form->setTemplates($template);
echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]);
echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]);
echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]);
echo $this->Form->control(__('Submit'), ['type' => 'submit', 'class' => 'btn btn-primary']);
echo $this->Form->end();
if (!empty(Configure::read('keycloak'))) {
echo sprintf('<div class="d-flex align-items-center my-3"><hr class="d-inline-block flex-grow-1"/><span class="mx-3 fw-light">%s</span><hr class="d-inline-block flex-grow-1"/></div>', __('Or'));
echo $this->Form->create(null, [
'url' => Cake\Routing\Router::url([
'prefix' => false,
'plugin' => 'ADmad/SocialAuth',
'controller' => 'Auth',
'action' => 'login',
'provider' => 'keycloak',
'?' => ['redirect' => $this->request->getQuery('redirect')]
]),
]);
echo $this->Bootstrap->button([
'type' => 'submit',
'text' => __('Login with Keycloak'),
'variant' => 'secondary',
'class' => ['d-block', 'w-100'],
'image' => [
'path' => '/img/keycloak_logo.png',
'alt' => 'Keycloak'
]
]);
echo $this->Form->end();
}
echo '</div>';
?> ?>
<div class="form-signin panel shadow position-absolute start-50 translate-middle">
<?php
echo sprintf(
'<div class="text-center mb-4">%s</div>',
$this->Html->image('logo-purple.png', [
'alt' => __('Cerebrate logo'),
'width' => 100, 'height' => 100,
'style' => ['filter: drop-shadow(4px 4px 4px #924da666);']
])
);
echo sprintf('<h4 class="text-uppercase fw-light mb-3">%s</h4>', __('Sign up'));
$template = [
'inputContainer' => '<div class="form-floating input {{type}}{{required}}">{{content}}</div>',
'formGroup' => '{{input}}{{label}}',
'submitContainer' => '<div class="submit d-grid">{{content}}</div>',
];
$this->Form->setTemplates($template);
echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'login']]);
echo $this->Form->control('username', ['label' => 'Username', 'class' => 'form-control mb-2', 'placeholder' => __('Username')]);
echo $this->Form->control('password', ['type' => 'password', 'label' => 'Password', 'class' => 'form-control mb-3', 'placeholder' => __('Password')]);
echo $this->Form->control(__('Login'), ['type' => 'submit', 'class' => 'btn btn-primary']);
echo $this->Form->end();
if (!empty(Configure::read('Cerebrate')['security.registration.self-registration'])) {
echo '<div class="text-end">';
echo sprintf('<span class="text-secondary ms-auto" style="font-size: 0.8rem">%s <a href="/users/register" class="text-decoration-none link-primary fw-bold">%s</a></span>', __('Doesn\'t have an account?'), __('Sign up'));
echo '</div>';
}
if (!empty(Configure::read('keycloak'))) {
echo sprintf('<div class="d-flex align-items-center my-2"><hr class="d-inline-block flex-grow-1"/><span class="mx-3 fw-light">%s</span><hr class="d-inline-block flex-grow-1"/></div>', __('Or'));
echo $this->Form->create(null, [
'url' => Cake\Routing\Router::url([
'prefix' => false,
'plugin' => 'ADmad/SocialAuth',
'controller' => 'Auth',
'action' => 'login',
'provider' => 'keycloak',
'?' => ['redirect' => $this->request->getQuery('redirect')]
]),
]);
echo $this->Bootstrap->button([
'type' => 'submit',
'text' => __('Login with Keycloak'),
'variant' => 'secondary',
'class' => ['d-block', 'w-100'],
'image' => [
'path' => '/img/keycloak_logo.png',
'alt' => 'Keycloak'
]
]);
echo $this->Form->end();
}
?>
</div> </div>

View File

@ -0,0 +1,42 @@
<?php
use Cake\Core\Configure;
?>
<div class="form-signin panel shadow position-absolute start-50 translate-middle">
<?php
echo sprintf(
'<div class="text-center mb-4">%s</div>',
$this->Html->image('logo-purple.png', [
'alt' => __('Cerebrate logo'),
'width' => 100, 'height' => 100,
'style' => ['filter: drop-shadow(4px 4px 4px #924da666);']
])
);
echo sprintf('<h4 class="text-uppercase fw-light mb-3">%s</h4>', __('Sign in'));
$template = [
'inputContainer' => '<div class="input {{type}}{{required}}">{{content}}</div>',
'formGroup' => '{{label}}{{input}}',
'submitContainer' => '<div class="submit d-grid">{{content}}</div>',
'label' => '<label class="fw-light fs-7" {{attrs}}>{{text}}</label>'
];
$this->Form->setTemplates($template);
echo $this->Form->create(null, ['url' => ['controller' => 'users', 'action' => 'register']]);
echo $this->Form->control('username', ['label' => __('Username'), 'class' => 'form-control mb-2']);
echo $this->Form->control('email', ['label' => __('E-mail Address'), 'class' => 'form-control mb-3']);
echo '<div class="row g-1 mb-3">';
echo '<div class="col-md">';
echo $this->Form->control('first_name', ['label' => __('First Name'), 'class' => 'form-control']);
echo '</div>';
echo '<div class="col-md">';
echo $this->Form->control('last_name', ['label' => __('Last Name'), 'class' => 'form-control mb-2']);
echo '</div>';
echo '</div>';
echo $this->Form->control('password', ['type' => 'password', 'label' => __('Password'), 'class' => 'form-control mb-4']);
echo $this->Form->control(__('Sign up'), ['type' => 'submit', 'class' => 'btn btn-primary']);
echo $this->Form->end();
?>
</div>

View File

@ -168,3 +168,11 @@ input[type="checkbox"]:disabled.change-cursor {
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
-webkit-mask-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' height='223.995' width='383.98752' viewBox='0 0 383.98752 223.995' role='img'%3E%3Cpath d='m 367.9975,0 h -118.06 c -21.38,0 -32.09,25.85 -16.97,40.97 l 32.4,32.4 -73.37,73.38 -73.37,-73.37 c -12.5,-12.5 -32.76,-12.5 -45.25,0 l -68.69,68.69 c -6.25,6.25 -6.25,16.38 0,22.63 l 22.62,22.62 c 6.25,6.25 16.38,6.25 22.63,0 l 46.06,-46.07 73.37,73.37 c 12.5,12.5 32.76,12.5 45.25,0 l 96,-96 32.4,32.4 c 15.12,15.12 40.97,4.41 40.97,-16.97 V 16 c 0.01,-8.84 -7.15,-16 -15.99,-16 z' /%3E%3C/svg%3E%0A"); -webkit-mask-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' height='223.995' width='383.98752' viewBox='0 0 383.98752 223.995' role='img'%3E%3Cpath d='m 367.9975,0 h -118.06 c -21.38,0 -32.09,25.85 -16.97,40.97 l 32.4,32.4 -73.37,73.38 -73.37,-73.37 c -12.5,-12.5 -32.76,-12.5 -45.25,0 l -68.69,68.69 c -6.25,6.25 -6.25,16.38 0,22.63 l 22.62,22.62 c 6.25,6.25 16.38,6.25 22.63,0 l 46.06,-46.07 73.37,73.37 c 12.5,12.5 32.76,12.5 45.25,0 l 96,-96 32.4,32.4 c 15.12,15.12 40.97,4.41 40.97,-16.97 V 16 c 0.01,-8.84 -7.15,-16 -15.99,-16 z' /%3E%3C/svg%3E%0A");
} }
.fs-7 {
font-size: .875rem !important;
}
.fs-8 {
font-size: .7rem !important;
}