chg: [security] OTP support for HOTP

pull/9085/head
Christophe Vandeplas 2023-05-25 23:28:14 +02:00
parent afbb9fab95
commit cb74ad507f
8 changed files with 99 additions and 39 deletions

View File

@ -310,7 +310,7 @@ class AppController extends Controller
$this->__accessMonitor($user);
} else {
$preAuthActions = array('login', 'register', 'getGpgPublicKey', 'logout401', 'totp');
$preAuthActions = array('login', 'register', 'getGpgPublicKey', 'logout401', 'otp');
if (!empty(Configure::read('Security.email_otp_enabled'))) {
$preAuthActions[] = 'email_otp';
}
@ -602,7 +602,7 @@ class AppController extends Controller
}
// Check if user must create TOTP secret, force them to be on that page as long as needed.
if (!$user['totp'] && Configure::read('Security.totp_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
if (!$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;
}

View File

@ -745,7 +745,8 @@ class ACLComponent extends Component
'downloadTerms' => array('*'),
'edit' => array('self_management_enabled'),
'email_otp' => array('*'),
'totp' => array('*'),
'otp' => array('*'),
'hotp' => array('*'),
'totp_new' => array('*'),
'totp_delete' => array('perm_admin'),
'searchGpgKey' => array('*'),

View File

@ -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', 'totp');
$allowedActions = array('login', 'logout', 'getGpgPublicKey', 'logout401', 'otp');
if(!empty(Configure::read('Security.email_otp_enabled'))) {
$allowedActions[] = 'email_otp';
}
@ -1185,7 +1185,7 @@ class UsersController extends AppController
}
$unauth_user = $this->User->find('first', [
'conditions' => ['User.email' => $this->request->data['User']['email']],
'fields' => ['User.password', 'User.totp'],
'fields' => ['User.password', 'User.totp', 'User.hotp_counter'],
'recursive' => -1,
]);
if ($unauth_user) {
@ -1200,8 +1200,8 @@ class UsersController extends AppController
if ($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('totp_user', $user);
return $this->redirect('totp');
$this->Session->write('otp_user', $user);
return $this->redirect('otp');
}
}
}
@ -1757,9 +1757,9 @@ class UsersController extends AppController
}
}
public function totp()
public function otp()
{
$user = $this->Session->read('totp_user');
$user = $this->Session->read('otp_user');
if (empty($user)) {
$this->redirect('login');
}
@ -1770,22 +1770,50 @@ class UsersController extends AppController
throw new ForbiddenException('You have reached the maximum number of login attempts. Please wait ' . $expire . ' seconds and try again.');
}
$secret = $user['totp'];
$otp = \OTPHP\TOTP::create($secret);
$otp_now = $otp->now();
if (trim($this->request->data['User']['otp']) == $otp_now) {
// we invalidate the previously generated OTP
// We login the user with CakePHP
$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 TOTP token';
$fieldsDescrStr = 'User (' . $user['id'] . '): ' . $user['email']. ' wrong OTP token';
$this->User->extralog($user, "login_fail", $fieldsDescrStr, '');
$this->Bruteforce->insert($user['email']);
}
} else {
// GET Request, just show the form
}
// 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()
@ -1816,25 +1844,26 @@ class UsersController extends AppController
$this->redirect($this->referer());
}
$secret = $this->Session->read('totp_secret'); // Reload secret from session.
$secret = $this->Session->read('otp_secret'); // Reload secret from session.
if ($secret) {
$otp = \OTPHP\TOTP::create($secret);
$totp = \OTPHP\TOTP::create($secret);
} else {
$otp = \OTPHP\TOTP::create();
$secret = $otp->getSecret();
$this->Session->write('totp_secret', $secret); // Store in session, this is to keep the same QR code even if the page refreshes.
$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'])) {
$otp_now = $otp->now();
if (trim($this->request->data['User']['otp']) == $otp_now) {
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->_refreshAuth();
$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, '');
$this->redirect(array('controller' => 'events', 'action'=> 'index'));
// 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."));
}
@ -1847,8 +1876,10 @@ class UsersController extends AppController
new \BaconQrCode\Renderer\Image\SvgImageBackEnd()
);
$writer = new \BaconQrCode\Writer($renderer);
$qrcode = $writer->writeString('otpauth://totp/' . Configure::read('MISP.org') . ' MISP (' . $user['User']['email'] . ')?secret=' . $secret);
$writer = preg_replace('/^.+\n/', '', $qrcode); // ignore first <?xml version line
$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);

View File

@ -1954,6 +1954,7 @@ class AppModel extends Model
$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,7 +2162,7 @@ class Server extends AppModel
return true;
}
public function totpBeforeHook($setting, $value)
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.');
@ -6397,12 +6397,12 @@ class Server extends AppModel
'type' => 'boolean',
'null' => true,
],
'totp_required' => array(
'otp_required' => array(
'level' => 2,
'description' => __('Require authentication with TOTP. Users that do not have TOTP configured will be forced to create a token at first login. You cannot use it in combination with external authentication plugins.'),
'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' => 'totpBeforeHook',
'beforeHook' => 'otpBeforeHook',
'type' => 'boolean',
'null' => true
),
@ -6411,7 +6411,7 @@ class Server extends AppModel
'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
),

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'));

View File

@ -2,11 +2,13 @@
<div class="actions sideMenu">
<div style="padding: 10px;">
<p><?php echo __("Your account requires an TOTP token to login. (Time-Based One-Time Password)");?></p>
<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(
@ -14,13 +16,13 @@ echo $this->element('/genericElements/Form/genericForm', array(
"fields" => array(
array(
"field" => "otp",
"label" => __("One Time Password"),
"label" => $label,
"type" => "text",
"placeholder" => __("Enter your OTP here"),
),
)
),
"submit" => array (
"action" => "totp",
"action" => "otp",
),
)));
?>

View File

@ -24,7 +24,8 @@ $boolean = sprintf(
$isTotp ? 'label label-success label-padding' : 'label label-important label-padding',
$isTotp ? __('Yes') : __('No'));
$totpHtml = $boolean;
$totpHtml .= ($isTotp ? '' : $this->Html->link(__('Generate'), array('action' => 'totp_new', $user['User']['id'])));
$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'])): '');
$totpHtml .= ($admin_view && $isTotp ? ' ' . $this->Form->postLink(__('Delete'), array('action' => 'totp_delete', $user['User']['id'])) : '');