Merge branch 'totp' into develop

bad_encoding_pymisp
iglocska 2023-05-31 15:17:32 +02:00
commit 1dee4a760d
No known key found for this signature in database
GPG Key ID: BEA224F1FEF113AC
12 changed files with 356 additions and 26 deletions

View File

@ -310,7 +310,7 @@ class AppController extends Controller
$this->__accessMonitor($user);
} else {
$preAuthActions = array('login', 'register', 'getGpgPublicKey', 'logout401');
$preAuthActions = array('login', 'register', 'getGpgPublicKey', 'logout401', 'otp');
if (!empty(Configure::read('Security.email_otp_enabled'))) {
$preAuthActions[] = 'email_otp';
}
@ -601,6 +601,12 @@ class AppController extends Controller
return true;
}
// Check if user must create TOTP secret, force them to be on that page as long as needed.
if (empty($user['totp']) && Configure::read('Security.otp_required') && !$this->_isControllerAction(['users' => ['terms', 'change_pw', 'logout', 'login', 'totp_new']])) { // TOTP is mandatory for users, prevent login until the user has configured their TOTP
$this->redirect(array('controller' => 'users', 'action' => 'totp_new', 'admin' => false));
return false;
}
// Check if user accepted terms and conditions
if (!$user['termsaccepted'] && !empty(Configure::read('MISP.terms_file')) && !$this->_isControllerAction(['users' => ['terms', 'logout', 'login', 'downloadTerms']])) {
//if ($this->_isRest()) throw new MethodNotAllowedException('You have not accepted the terms of use yet, please log in via the web interface and accept them.');

View File

@ -745,6 +745,10 @@ class ACLComponent extends Component
'downloadTerms' => array('*'),
'edit' => array('self_management_enabled'),
'email_otp' => array('*'),
'otp' => array('*'),
'hotp' => array('*'),
'totp_new' => array('*'),
'totp_delete' => array('perm_site_admin'),
'searchGpgKey' => array('*'),
'fetchGpgKey' => array('*'),
'histogram' => array('*'),

View File

@ -1,5 +1,5 @@
<?php
App::uses('AppController', 'Controller');
App::uses('AppController', 'Controller', 'OTPHP\TOTP');
/**
* @property User $User
@ -29,7 +29,7 @@ class UsersController extends AppController
parent::beforeFilter();
// what pages are allowed for non-logged-in users
$allowedActions = array('login', 'logout', 'getGpgPublicKey', 'logout401');
$allowedActions = array('login', 'logout', 'getGpgPublicKey', 'logout401', 'otp');
if(!empty(Configure::read('Security.email_otp_enabled'))) {
$allowedActions[] = 'email_otp';
}
@ -88,6 +88,7 @@ class UsersController extends AppController
unset($user['User']['authkey']);
}
$user['User']['password'] = '*****';
$user['User']['totp'] = '*****';
$temp = [];
$objectsToInclude = array('User', 'Role', 'UserSetting', 'Organisation');
foreach ($objectsToInclude as $objectToInclude) {
@ -594,17 +595,7 @@ class UsersController extends AppController
unset($user['User']['authkey']);
}
if ($this->_isRest()) {
$user['User']['password'] = '*****';
$temp = array();
foreach ($user['UserSetting'] as $v) {
$temp[$v['setting']] = $v['value'];
}
$user['UserSetting'] = $temp;
return $this->RestResponse->viewData(array(
'User' => $user['User'],
'Role' => $user['Role'],
'UserSetting' => $user['UserSetting']
), $this->response->type());
return $this->RestResponse->viewData($this->__massageUserObject($user), $this->response->type());
}
$this->set('user', $user);
@ -1192,18 +1183,30 @@ class UsersController extends AppController
throw new ForbiddenException('You have reached the maximum number of login attempts. Please wait ' . $expire . ' seconds and try again.');
}
}
// Check the length of the user's authkey match old format. This can be removed in future.
$userPass = $this->User->find('first', [
$unauth_user = $this->User->find('first', [
'conditions' => ['User.email' => $this->request->data['User']['email']],
'fields' => ['User.password'],
'fields' => ['User.password', 'User.totp', 'User.hotp_counter'],
'recursive' => -1,
]);
if (!empty($userPass) && strlen($userPass['User']['password']) === 40) {
$oldHash = true;
unset($this->Auth->authenticate['Form']['passwordHasher']); // use default password hasher
$this->Auth->constructAuthenticate();
if ($unauth_user) {
// Check the length of the user's authkey match old format. This can be removed in future.
$userPass = $unauth_user['User']['password'];
if (!empty($userPass) && strlen($userPass) === 40) {
$oldHash = true;
unset($this->Auth->authenticate['Form']['passwordHasher']); // use default password hasher
$this->Auth->constructAuthenticate();
}
// user has TOTP token, check creds and redirect to TOTP validation
if (!empty($unauth_user['User']['totp']) && !$unauth_user['User']['disabled'] && class_exists('\OTPHP\TOTP')) {
$user = $this->Auth->identify($this->request, $this->response);
if ($user && !$user['disabled']) {
$this->Session->write('otp_user', $user);
return $this->redirect('otp');
}
}
}
}
// if instance requires email OTP
if ($this->request->is('post') && Configure::read('Security.email_otp_enabled')) {
$user = $this->Auth->identify($this->request, $this->response);
if ($user && !$user['disabled']) {
@ -1754,6 +1757,167 @@ class UsersController extends AppController
}
}
public function otp()
{
$user = $this->Session->read('otp_user');
if (empty($user)) {
$this->redirect('login');
}
if ($this->request->is('post') && isset($this->request->data['User']['otp'])) {
$this->Bruteforce = ClassRegistry::init('Bruteforce');
if ($this->Bruteforce->isBlocklisted($user['email'])) {
$expire = Configure::check('SecureAuth.expire') ? Configure::read('SecureAuth.expire') : 300;
throw new ForbiddenException('You have reached the maximum number of login attempts. Please wait ' . $expire . ' seconds and try again.');
}
$secret = $user['totp'];
$totp = \OTPHP\TOTP::create($secret);
$hotp = \OTPHP\HOTP::create($secret);
if ($totp->verify(trim($this->request->data['User']['otp']))) {
// OTP is correct, we login the user with CakePHP
$this->Auth->login($user);
$this->_postlogin();
} elseif (isset($user['hotp_counter']) && $hotp->verify(trim($this->request->data['User']['otp']), $user['hotp_counter'])) {
// HOTP is correct, update the counter and login
$this->User->id = $user['id'];
$this->User->saveField('hotp_counter', $user['hotp_counter']+1);
$this->Auth->login($user);
$this->_postlogin();
} else {
$this->Flash->error(__("The OTP is incorrect or has expired"));
$fieldsDescrStr = 'User (' . $user['id'] . '): ' . $user['email']. ' wrong OTP token';
$this->User->extralog($user, "login_fail", $fieldsDescrStr, '');
$this->Bruteforce->insert($user['email']);
}
}
// GET Request or wrong OTP, just show the form
$this->set('totp', $user['totp']? true : false);
$this->set('hotp_counter', $user['hotp_counter']);
}
public function hotp()
{
if (!class_exists('\OTPHP\HOTP')) {
$this->Flash->error(__("The required PHP libraries to support OTP are not installed. Please contact your administrator to address this."));
$this->redirect($this->referer());
}
$user = $this->User->find('first', array(
'recursive' => -1,
'conditions' => array('User.id' => $this->Auth->user('id')),
'fields' => array(
'totp', 'email', 'id', 'hotp_counter'
)
));
$hotp = \OTPHP\HOTP::create($user['User']['totp'], $user['User']['hotp_counter']);
$hotp_codes = [];
for ($i=$user['User']['hotp_counter']; $i < $user['User']['hotp_counter']+50 ; $i++) {
$hotp_codes[$i] = $hotp->at($i);
}
$this->set('hotp_codes', $hotp_codes);
}
public function totp_new()
{
if (Configure::read('LinOTPAuth.enabled')) {
$this->Flash->error(__("LinOTP is enabled for this instance. Build-in TOTP should not be used."));
$this->redirect($this->referer());
}
if (!class_exists('\OTPHP\TOTP') || !class_exists('\BaconQrCode\Writer')) {
$this->Flash->error(__("The required PHP libraries to support TOTP are not installed. Please contact your administrator to address this."));
$this->redirect($this->referer());
}
// only allow the users themselves to generate a TOTP secret.
// If TOTP is enforced they will be invited to generate it at first login
$user = $this->User->find('first', array(
'recursive' => -1,
'conditions' => array('User.id' => $this->Auth->user('id')),
'fields' => array(
'totp', 'email', 'id'
)
));
if (empty($user)) {
throw new NotFoundException(__('Invalid user'));
}
// do not allow this page to be accessed if the current already has a TOTP. Just redirect to the users details page with a Flash->error()
if ($user['User']['totp']) {
$this->Flash->error(__("Your account already has an TOTP. Please contact your organisational administrator to change or delete it."));
$this->redirect($this->referer());
}
$secret = $this->Session->read('otp_secret'); // Reload secret from session.
if ($secret) {
$totp = \OTPHP\TOTP::create($secret);
} else {
$totp = \OTPHP\TOTP::create();
$secret = $totp->getSecret();
$this->Session->write('otp_secret', $secret); // Store in session, this is to keep the same QR code even if the page refreshes.
}
if ($this->request->is('post') && isset($this->request->data['User']['otp'])) {
if ($totp->verify(trim($this->request->data['User']['otp']))) {
// we know the user can generate TOTP tokens, save the new TOTP to the database
$this->User->id = $user['User']['id'];
$this->User->saveField('totp', $secret);
$this->User->saveField('hotp_counter', 0);
$this->_refreshAuth();
$this->Flash->info(__('The OTP is correct and now active for your account.'));
$fieldsDescrStr = 'User (' . $user['User']['id'] . '): ' . $user['User']['email']. ' TOTP token created';
$this->User->extralog($this->Auth->user(), "update", $fieldsDescrStr, '');
// redirect to a page that gives the next 50 HOTP
$this->redirect(array('controller' => 'users', 'action'=> 'hotp'));
} else {
$this->Flash->error(__("The OTP is incorrect or has expired."));
}
} else {
// GET Request, just show the form
}
// generate QR code with the secret
$renderer = new \BaconQrCode\Renderer\ImageRenderer(
new \BaconQrCode\Renderer\RendererStyle\RendererStyle(200),
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer($renderer);
$totp->setLabel($user['User']['email']);
$totp->setIssuer(Configure::read('MISP.org') . ' MISP');
$qrcode = $writer->writeString($totp->getProvisioningUri());
$qrcode = preg_replace('/^.+\n/', '', $qrcode); // ignore first <?xml version line
$this->set('qrcode', $qrcode);
$this->set('secret', $secret);
}
public function totp_delete($id) {
if ($this->request->is('post') || $this->request->is('delete')) {
$user = $this->User->find('first', array(
'conditions' => $this->__adminFetchConditions($id),
'recursive' => -1
));
if (empty($user)) {
throw new NotFoundException(__('Invalid user'));
}
$this->User->id = $id;
if ($this->User->saveField('totp', null)) {
$fieldsDescrStr = 'User (' . $id . '): ' . $user['User']['email'] . ' TOTP deleted';
$this->User->extralog($this->Auth->user(), "update", $fieldsDescrStr, '');
if ($this->_isRest()) {
return $this->RestResponse->saveSuccessResponse('User', 'admin_totp_delete', $id, $this->response->type(), 'User TOTP deleted.');
} else {
$this->Flash->success(__('User TOTP deleted'));
$this->redirect('/admin/users/index');
}
}
$this->Flash->error(__('User TOTP was not deleted'));
$this->redirect('/admin/users/index');
} else {
$this->set(
'question',
__('Are you sure you want to delete the TOTP of the user?.')
);
$this->set('title', __('Delete user TOTP'));
$this->set('actionName', 'Delete');
$this->render('/genericTemplates/confirm');
}
}
public function email_otp()
{
$user = $this->Session->read('email_otp_user');
@ -1773,6 +1937,8 @@ class UsersController extends AppController
$this->_postlogin();
} else {
$this->Flash->error(__("The OTP is incorrect or has expired"));
$fieldsDescrStr = 'User (' . $user['id'] . '): ' . $user['email']. ' wrong email OTP token';
$this->User->extralog($user, "login_fail", $fieldsDescrStr, '');
}
} else {
// GET Request

View File

@ -84,7 +84,7 @@ class AppModel extends Model
87 => false, 88 => false, 89 => false, 90 => false, 91 => false, 92 => false,
93 => false, 94 => false, 95 => true, 96 => false, 97 => true, 98 => false,
99 => false, 100 => false, 101 => false, 102 => false, 103 => false, 104 => false,
105 => false, 106 => false, 107 => false, 108 => false, 109 => false
105 => false, 106 => false, 107 => false, 108 => false, 109 => false, 110 => false
);
const ADVANCED_UPDATES_DESCRIPTION = array(
@ -1952,6 +1952,9 @@ class AppModel extends Model
break;
case 109:
$sqlArray[] = "UPDATE `over_correlating_values` SET `value` = LOWER(`value`) COLLATE utf8mb4_unicode_ci;";
case 110:
$sqlArray[] = "ALTER TABLE `users` ADD `totp` varchar(255) DEFAULT NULL;";
$sqlArray[] = "ALTER TABLE `users` ADD `hotp_counter` int(11) DEFAULT NULL;";
break;
case 'fixNonEmptySharingGroupID':
$sqlArray[] = 'UPDATE `events` SET `sharing_group_id` = 0 WHERE `distribution` != 4;';

View File

@ -2154,7 +2154,7 @@ class Server extends AppModel
return true;
}
public function otpBeforeHook($setting, $value)
public function email_otpBeforeHook($setting, $value)
{
if ($value && !empty(Configure::read('MISP.disable_emailing'))) {
return __('Emailing is currently disabled. Enabling OTP without e-mailing being configured would lock all users out.');
@ -2162,6 +2162,17 @@ class Server extends AppModel
return true;
}
public function otpBeforeHook($setting, $value)
{
if ($value && (!class_exists('\OTPHP\TOTP') || !class_exists('\BaconQrCode\Writer'))) {
return __('The TOTP and QR code generation libraries are not installed. Enabling OTP without those libraries installed would lock all users out.');
}
if ($value && Configure::read('LinOTPAuth.enabled')) {
return __('The TOTP and LinOTPAuth should not be used at the same time.');
}
return true;
}
public function testForRPZSerial($value)
{
if ($this->testForEmpty($value) !== true) {
@ -6386,12 +6397,21 @@ class Server extends AppModel
'type' => 'boolean',
'null' => true,
],
'otp_required' => array(
'level' => 2,
'description' => __('Require authentication with OTP. Users that do not have (T/H)OTP configured will be forced to create a token at first login. You cannot use it in combination with external authentication plugins.'),
'value' => false,
'test' => 'testBool',
'beforeHook' => 'otpBeforeHook',
'type' => 'boolean',
'null' => true
),
'email_otp_enabled' => array(
'level' => 2,
'description' => __('Enable two step authentication with a OTP sent by email. Requires e-mailing to be enabled. Warning: You cannot use it in combination with external authentication plugins.'),
'value' => false,
'test' => 'testBool',
'beforeHook' => 'otpBeforeHook',
'beforeHook' => 'email_otpBeforeHook',
'type' => 'boolean',
'null' => true
),

View File

@ -0,0 +1,5 @@
<?php
// Beware, this field type does NOT protect against injections.
// So please ensure all data is safe or use h() on the variables before sending them here
echo $fieldData['html'];

View File

@ -143,6 +143,16 @@
'privacy' => 1,
'requirement' => empty(Configure::read('Security.advanced_authkeys'))
),
array(
'name' => '',
'header_title' => __('TOTP'),
'icon' => 'mobile',
'element' => 'boolean',
'sort' => 'User.totp',
'class' => 'short',
'data_path' => 'User.totp',
'colors' => true,
),
array(
'name' => '',
'header_title' => __('Contact alert'),

24
app/View/Users/hotp.ctp Normal file
View File

@ -0,0 +1,24 @@
<div class="users form">
<h2><?php echo __('Paper based Single Use Tokens');?></h2>
<p><?php echo __('The following list contains the next tokens in case you do not have your phone/software. <br />Make sure you print these out.');?></p>
<pre><?php
$count = count($hotp_codes);
$rows = round($count / 5); // 5 rows
$i = 1;
foreach ($hotp_codes as $key => $value) {
if ($key < 10) print(" ");
print("$key: $value");
if ($i == 5) {
print("\n");
$i = 1;
} else {
print(" ");
$i++;
}
}
?>
</pre>
</div>
<?= $this->element('/genericElements/SideMenu/side_menu', array('menuList' => 'globalActions', 'menuItem' => 'view'));

28
app/View/Users/otp.ctp Normal file
View File

@ -0,0 +1,28 @@
<?php echo $this->Flash->render(); ?>
<div class="actions sideMenu">
<div style="padding: 10px;">
<p><?php echo __("Your account requires an OTP token to login. (One-Time Password)");?></p>
</div>
</div>
<?php
$label = __("Enter either your TOTP or paper based Single Use Token number ") . $hotp_counter;
echo $this->element('/genericElements/Form/genericForm', array(
"form" => $this->Form,
"data" => array(
"title" => __("Validate your One Time Password"),
"fields" => array(
array(
"field" => "otp",
"label" => $label,
"type" => "text",
"placeholder" => __("Enter your OTP here"),
)
),
"submit" => array (
"action" => "otp",
),
)));
?>

View File

@ -0,0 +1,38 @@
<?php echo $this->Flash->render(); ?>
<?php
$detailsHtml = __("To enable TOTP for your account, scan the following QR code with your TOTP application and validate the token.");;
$secretHtml = __("Alternatively you can enter the following secret in your TOTP application: ") . "<pre>" . $secret . "</pre>";
echo $this->element('/genericElements/Form/genericForm', array(
"form" => $this->Form,
"data" => array(
"title" => __("Validate your One Time Password"),
"fields" => array(
array(
"type" => 'html',
"field" => "html",
"html" => $detailsHtml
),
array(
"type" => 'html',
"field" => 'qrcode',
"html" => $qrcode
),
array(
"type" => 'html',
"field" => "secret",
"html" => $secretHtml
),
array(
"field" => "otp",
"label" => __("One Time Password verification"),
"type" => "text",
"placeholder" => __("Enter your OTP code here"),
)
),
"submit" => array (
"action" => "totp",
),
)));
?>
</div>

View File

@ -18,6 +18,23 @@ foreach ($notificationTypes as $notificationType => $description) {
}
$notificationsHtml .= '</table>';
$isTotp = isset($user['User']['totp']) ? true : false;
$boolean = sprintf(
'<span class="%s">%s</span>',
$isTotp ? 'label label-success label-padding' : 'label label-important label-padding',
$isTotp ? __('Yes') : __('No'));
$totpHtml = $boolean;
$totpHtml .= (!$isTotp && !$admin_view ? $this->Html->link(__('Generate'), array('action' => 'totp_new')) : '');
$totpHtml .= ($isTotp && !$admin_view ? $this->Html->link(__('View paper tokens'), array('action' => 'hotp', $user['User']['id'])): '');
if ($admin_view && $isSiteAdmin && $isTotp) {
$totpHtml .= sprintf(
'<a href="#" onClick="openGenericModal(\'%s/users/totp_delete/%s\')">%s</a>',
h($baseurl),
h($user['User']['id']),
__('Delete')
);
}
$table_data = [
array('key' => __('ID'), 'value' => $user['User']['id']),
array(
@ -37,6 +54,11 @@ $notificationsHtml .= '</table>';
'key' => __('Role'),
'html' => $this->Html->link($user['Role']['name'], array('controller' => 'roles', 'action' => 'view', $user['Role']['id'])),
),
// array('key' => __('TOTP'), 'boolean' => isset($user['User']['totp']) ? true : false),
array(
'key' => __('TOTP'),
'html' => $totpHtml
),
array(
'key' => __('Email notifications'),
'html' => $notificationsHtml,

View File

@ -11,7 +11,9 @@
"ext-pcre": "*",
"kamisama/cake-resque": "4.1.2",
"pear/crypt_gpg": "1.6.7",
"monolog/monolog": "1.24.0"
"monolog/monolog": "1.24.0",
"spomky-labs/otphp": "^10.0",
"bacon/bacon-qr-code": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^8",
@ -38,7 +40,9 @@
"supervisorphp/supervisor": "For managing background jobs",
"lstrojny/fxmlrpc": "Required for supervisorphp/supervisor XML-RPC requests",
"guzzlehttp/guzzle": "Required for supervisorphp/supervisor XML-RPC requests",
"php-http/message": "Required for supervisorphp/supervisor XML-RPC requests"
"php-http/message": "Required for supervisorphp/supervisor XML-RPC requests",
"spomky-labs/otphp": "Required for strong authentication with TOTP",
"bacon/bacon-qr-code": "Required for strong authentication with TOTP"
},
"config": {
"vendor-dir": "Vendor",