mirror of
https://github.com/Icinga/icingaweb2.git
synced 2025-07-30 09:14:08 +02:00
WIP Add: toggle 2FA control via preferences
This commit is contained in:
parent
b8cc14dc35
commit
9522d1457a
@ -4,11 +4,14 @@
|
||||
namespace Icinga\Controllers;
|
||||
|
||||
use Icinga\Application\Config;
|
||||
use Icinga\Application\Icinga;
|
||||
use Icinga\Authentication\User\UserBackend;
|
||||
use Icinga\Data\ConfigObject;
|
||||
use Icinga\Exception\ConfigurationError;
|
||||
use Icinga\Forms\Account\ChangePasswordForm;
|
||||
use Icinga\Forms\Account\TotpForm;
|
||||
use Icinga\Forms\PreferenceForm;
|
||||
use Icinga\Authentication\Totp;
|
||||
use Icinga\User\Preferences\PreferencesStore;
|
||||
use Icinga\Web\Controller;
|
||||
|
||||
@ -67,6 +70,26 @@ class AccountController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// create a form to add and enable 2FA via TOTP
|
||||
|
||||
if ( $user->can('user/two-factor-authentication') ) {
|
||||
$totp = new Totp($user->getUsername());
|
||||
$totpForm = (new TotpForm())
|
||||
->setPreferences($user->getPreferences())
|
||||
->setTotp($totp);
|
||||
if (isset($config->config_resource)) {
|
||||
$totpForm->setStore(PreferencesStore::create(new ConfigObject(array(
|
||||
'resource' => $config->config_resource
|
||||
)), $user));
|
||||
}
|
||||
|
||||
// $db = Icinga::app()->;
|
||||
// Totp::on()
|
||||
$totpForm->handleRequest();
|
||||
|
||||
$this->view->totpForm = $totpForm;
|
||||
}
|
||||
|
||||
$form = new PreferenceForm();
|
||||
$form->setPreferences($user->getPreferences());
|
||||
if (isset($config->config_resource)) {
|
||||
|
60
application/controllers/TryoutController.php
Normal file
60
application/controllers/TryoutController.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Icinga\controllers;
|
||||
|
||||
use Icinga\Authentication\Totp ;
|
||||
use ipl\Html\HtmlElement;
|
||||
use ipl\Web\Compat\CompatController;
|
||||
|
||||
|
||||
class TryoutController extends CompatController
|
||||
{
|
||||
|
||||
public function indexAction()
|
||||
{
|
||||
$this->addContent(
|
||||
HtmlElement::create(
|
||||
'h1',
|
||||
null,
|
||||
$this->translate('Tryout Section')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
// $clock = new PsrClock();
|
||||
// $otp = TOTP::generate($clock);
|
||||
|
||||
// $otp->getQrCodeUri()
|
||||
// $secret = $otp->getSecret();
|
||||
// $secret = '73P442OENPZ5ZUSIWR6VGHPD4XKANATHJYFCD7SVXR2KXBOS3PJY3FHCPBM3NLAB4NMOCUP7ZC53KEQJWLUCTKQXHTIGFZOVQC77M2Y';
|
||||
// $otp = TOTP::createFromSecret($secret, $clock);
|
||||
|
||||
$totp = new Totp('icingaadmi');
|
||||
// if ($totp->userHasSecret()) {
|
||||
$secret = $totp->getSecret();
|
||||
$tmpSecret = $totp->generateSecret()->getTemporarySecret();
|
||||
$tmpSecret2 = $totp->generateSecret()->getTemporarySecret();
|
||||
|
||||
$this->addContent(
|
||||
HtmlElement::create(
|
||||
'div',
|
||||
null,
|
||||
[
|
||||
HtmlElement::create('p', null, sprintf('The OTP secret is: %s', $secret)),
|
||||
HtmlElement::create('p', null, sprintf('Temp OTP secret is: %s', $tmpSecret)),
|
||||
HtmlElement::create('p', null, sprintf('Temp2 OTP secret is: %s', $tmpSecret2)),
|
||||
HtmlElement::create('p', null, sprintf('The current OTP is: %s', $totp->getCurrentCode()))
|
||||
]
|
||||
)
|
||||
);
|
||||
// } else {
|
||||
// $this->addContent(
|
||||
// HtmlElement::create(
|
||||
// 'div',
|
||||
// null,
|
||||
// HtmlElement::create('p', null, 'No TOTP secret found for user icingaadmi')
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
234
application/forms/Account/TotpForm.php
Normal file
234
application/forms/Account/TotpForm.php
Normal file
@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace Icinga\Forms\Account;
|
||||
|
||||
use Exception;
|
||||
use Icinga\Application\Logger;
|
||||
use Icinga\Authentication\Auth;
|
||||
use Icinga\Authentication\Totp;
|
||||
use Icinga\Data\Filter\Filter;
|
||||
use Icinga\Forms\PreferenceForm;
|
||||
use Icinga\User\Preferences;
|
||||
use Icinga\Web\Form;
|
||||
use Icinga\Web\Notification;
|
||||
use Icinga\Web\Session;
|
||||
|
||||
/**
|
||||
* Form for creating, updating, enable and disable TOTP settings
|
||||
*
|
||||
* This form is used to manage the TOTP settings of a user account.
|
||||
*/
|
||||
class TotpForm extends PreferenceForm
|
||||
{
|
||||
protected Totp $totp;
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function init()
|
||||
{
|
||||
$this->setName('form_totp');
|
||||
$this->setSubmitLabel($this->translate('Save Changes'));
|
||||
$this->setProgressLabel($this->translate('Saving'));
|
||||
}
|
||||
|
||||
public function setTotp(Totp $totp): self
|
||||
{
|
||||
$this->totp = $totp;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function createElements(array $formData)
|
||||
{
|
||||
$this->addElement(
|
||||
'checkbox',
|
||||
'enabled_2fa',
|
||||
[
|
||||
'required' => false,
|
||||
'autosubmit' => true,
|
||||
'label' => $this->translate('Enable TOTP 2FA'),
|
||||
'description' => $this->translate(
|
||||
'This option allows you to enable or to disable the second factor authentication via TOTP'
|
||||
),
|
||||
'value' => '',
|
||||
]
|
||||
);
|
||||
|
||||
if (isset($formData['enabled_2fa']) && $formData['enabled_2fa']) {
|
||||
|
||||
$this->addElement(
|
||||
'text',
|
||||
'totp_secret',
|
||||
[
|
||||
'label' => $this->translate('TOTP Secret:'),
|
||||
'value' => $this->totp->getSecret() ?? $this->translate('No Secret set'),
|
||||
'description' => $this->translate(
|
||||
'If you generate a new TOTP secret, you will need to reconfigure your TOTP application with the new secret. ' .
|
||||
'If you reset the TOTP secret, you will lose access to your TOTP application and will need to set it up again.'
|
||||
),
|
||||
'disabled' => true,
|
||||
// 'decorators' => ['ViewHelper']
|
||||
]
|
||||
);
|
||||
|
||||
if ($this->totp->getSecret() !== null) {
|
||||
$this->addElement(
|
||||
'submit',
|
||||
'btn_renew_totp',
|
||||
array(
|
||||
'ignore' => true,
|
||||
'label' => $this->translate('Renew TOTP Secret'),
|
||||
'decorators' => array('ViewHelper'),
|
||||
)
|
||||
);
|
||||
|
||||
$this->addElement(
|
||||
'submit',
|
||||
'btn_delete_totp',
|
||||
array(
|
||||
'ignore' => true,
|
||||
'label' => $this->translate('Delete TOTP Secret'),
|
||||
'decorators' => array('ViewHelper')
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$this->addElement(
|
||||
'submit',
|
||||
'btn_generate_totp',
|
||||
array(
|
||||
'ignore' => true,
|
||||
'label' => $this->translate('Generate TOTP Secret'),
|
||||
'decorators' => array('ViewHelper')
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->addElement(
|
||||
'submit',
|
||||
'btn_submit',
|
||||
array(
|
||||
'ignore' => true,
|
||||
'label' => $this->translate('Save to the Preferences'),
|
||||
'decorators' => array('ViewHelper'),
|
||||
'class' => 'btn-primary'
|
||||
)
|
||||
);
|
||||
|
||||
$this->addDisplayGroup(
|
||||
array('btn_submit', 'btn_delete_totp', 'btn_renew_totp', 'btn_generate_totp'),
|
||||
'submit_buttons',
|
||||
array(
|
||||
'decorators' => array(
|
||||
'FormElements',
|
||||
array('HtmlTag', array('tag' => 'div', 'class' => 'control-group form-controls'))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function onSuccess()
|
||||
{
|
||||
|
||||
|
||||
try {
|
||||
if ($this->getElement('btn_submit') && $this->getElement('btn_submit')->isChecked()) {
|
||||
$this->preferences = new Preferences($this->store ? $this->store->load() : array());
|
||||
$webPreferences = $this->preferences->get('icingaweb');
|
||||
foreach ($this->getValues() as $key => $value) {
|
||||
if ($value === ''
|
||||
|| $value === null
|
||||
|| $value === 'autodetect'
|
||||
) {
|
||||
if (isset($webPreferences[$key])) {
|
||||
unset($webPreferences[$key]);
|
||||
}
|
||||
} else {
|
||||
$webPreferences[$key] = $value;
|
||||
}
|
||||
}
|
||||
$this->preferences->icingaweb = $webPreferences;
|
||||
Session::getSession()->user->setPreferences($this->preferences);
|
||||
$this->save();
|
||||
Notification::success($this->translate('Submitted btn_submit'));
|
||||
|
||||
return true;
|
||||
} elseif ($this->getElement('btn_generate_totp') && $this->getElement('btn_generate_totp')->isChecked()) {
|
||||
Notification::success($this->translate('Submitted btn_generate_totp'));
|
||||
|
||||
return true;
|
||||
} elseif ($this->getElement('btn_renew_totp') && $this->getElement('btn_renew_totp')->isChecked()) {
|
||||
Notification::success($this->translate('Submitted btn_renew_totp'));
|
||||
|
||||
return true;
|
||||
} elseif ($this->getElement('btn_delete_totp') && $this->getElement('btn_delete_totp')->isChecked()) {
|
||||
Notification::info($this->translate('Submitted btn_delete_totp'));
|
||||
|
||||
return false;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Logger::error($e);
|
||||
Notification::error($e->getMessage());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate preferences
|
||||
*
|
||||
* @see Form::onRequest()
|
||||
*/
|
||||
public function onRequest()
|
||||
{
|
||||
$auth = Auth::getInstance();
|
||||
$values = $auth->getUser()->getPreferences()->get('icingaweb');
|
||||
|
||||
if (!isset($values['enabled_2fa'])) {
|
||||
$values['enabled_2fa'] = '0';
|
||||
}
|
||||
|
||||
$this->populate($values);
|
||||
}
|
||||
|
||||
public function isSubmitted()
|
||||
{
|
||||
if (
|
||||
($this->getElement('btn_generate_totp') && $this->getElement('btn_generate_totp')->isChecked())
|
||||
|| ($this->getElement('btn_renew_totp') && $this->getElement('btn_renew_totp')->isChecked())
|
||||
|| ($this->getElement('btn_delete_totp') && $this->getElement('btn_delete_totp')->isChecked())
|
||||
|| ($this->getElement('btn_submit') && $this->getElement('btn_submit')->isChecked())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
// public function isValid($formData)
|
||||
// {
|
||||
//// $valid = parent::isValid($formData);
|
||||
//// if (! $valid) {
|
||||
//// return false;
|
||||
//// }
|
||||
////
|
||||
//// $oldPasswordEl = $this->getElement('old_password');
|
||||
////
|
||||
//// if (! $this->backend->authenticate($this->Auth()->getUser(), $oldPasswordEl->getValue())) {
|
||||
//// $oldPasswordEl->addError($this->translate('Old password is invalid'));
|
||||
//// $this->markAsError();
|
||||
//// return false;
|
||||
//// }
|
||||
////
|
||||
//// return true;
|
||||
// }
|
||||
}
|
@ -573,6 +573,9 @@ class RoleForm extends RepositoryForm
|
||||
'user/password-change' => [
|
||||
'description' => t('Allow password changes in the account preferences')
|
||||
],
|
||||
'user/two-factor-authentication'=> [
|
||||
'description' => t('Allow 2FA configuration in the account preferences')
|
||||
],
|
||||
'user/application/stacktraces' => [
|
||||
'description' => t('Allow to adjust in the preferences whether to show stacktraces')
|
||||
],
|
||||
|
@ -5,6 +5,10 @@
|
||||
<?php if (isset($changePasswordForm)): ?>
|
||||
<h1><?= $this->translate('Account') ?></h1>
|
||||
<?= $changePasswordForm ?>
|
||||
<?php endif ?>
|
||||
<?php if (isset($totpForm)): ?>
|
||||
<h1><?= $this->translate('2FA - TOTP') ?></h1>
|
||||
<?= $totpForm ?>
|
||||
<?php endif ?>
|
||||
<h1><?= $this->translate('Preferences') ?></h1>
|
||||
<?= $form ?>
|
||||
|
207
library/Icinga/Authentication/Totp.php
Normal file
207
library/Icinga/Authentication/Totp.php
Normal file
@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace Icinga\Authentication;
|
||||
|
||||
use Icinga\Clock\PsrClock;
|
||||
use Icinga\Common\Database;
|
||||
use Icinga\Exception\ConfigurationError;
|
||||
use ipl\Orm\Model;
|
||||
use ipl\Orm\Query;
|
||||
use Icinga\Model\Totp as TotpModel;
|
||||
use ipl\Sql\Insert;
|
||||
use ipl\Sql\Update;
|
||||
use ipl\Stdlib\Filter;
|
||||
use OTPHP\TOTP as extTOTP;
|
||||
|
||||
class Totp
|
||||
{
|
||||
use Database {
|
||||
getDb as private getWebDb;
|
||||
}
|
||||
|
||||
protected string $username;
|
||||
protected PsrClock $clock;
|
||||
protected extTOTP $totpObject;
|
||||
private ?string $secret = null;
|
||||
private ?string $temporarySecret = null;
|
||||
|
||||
|
||||
public function __construct(string $username, ?string $secret = null)
|
||||
{
|
||||
$this->username = $username;
|
||||
$this->clock = new PsrClock();
|
||||
$this->temporarySecret = $secret;
|
||||
$this->setTotpObject();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a TOTP secret exists for the current user.
|
||||
*
|
||||
* @return bool Returns true if a TOTP secret exists, false otherwise
|
||||
*/
|
||||
public function userHasSecret(): bool
|
||||
{
|
||||
|
||||
return $this->secret !== null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verifies the provided TOTP code against the user's secret.
|
||||
*
|
||||
* @param string $code The TOTP code to verify
|
||||
* @return bool Returns true if the code is valid, false otherwise
|
||||
*/
|
||||
public function verify(string $code): bool
|
||||
{
|
||||
if ($this->secret === null) {
|
||||
return false;
|
||||
}
|
||||
return $this->totpObject->verify($code);
|
||||
|
||||
}
|
||||
|
||||
public function generateSecret(): self
|
||||
{
|
||||
$this->temporarySecret = $this->totpObject->getSecret();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSecretForUser(): self
|
||||
{
|
||||
if ($this->temporarySecret === null) {
|
||||
throw new ConfigurationError('No temporary secret set to apply to user');
|
||||
}
|
||||
|
||||
if ($this->secret === null) {
|
||||
$this->getWebDb()->prepexec(
|
||||
(new Insert())->into('icingaweb_totp')->values(
|
||||
[
|
||||
'username' => $this->username,
|
||||
'secret' => $this->temporarySecret,
|
||||
'created_at' => $this->clock->now(),
|
||||
]
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$this->getWebDb()->prepexec(
|
||||
(new Update())->table('icingaweb_totp')
|
||||
->set([
|
||||
'secret' => $this->temporarySecret,
|
||||
'updated_at' => $this->clock->now(),
|
||||
])
|
||||
->where(['username' => $this->username])
|
||||
);
|
||||
}
|
||||
$this->secret = $this->temporarySecret;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a query for the TOTP model.
|
||||
* This method is used to fetch TOTP records from the database.
|
||||
*
|
||||
* @return Query|null
|
||||
*/
|
||||
private function getTotpQuery(): ?Query
|
||||
{
|
||||
try {
|
||||
$query = TotpModel::on($this->getWebDb());
|
||||
} catch (ConfigurationError $e) {
|
||||
$query = null;
|
||||
}
|
||||
|
||||
return $query->count() > 0 ? $query : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the TOTP model for the current user.
|
||||
* This method retrieves the TOTP record associated with the username.
|
||||
*
|
||||
* @return TotpModel|null
|
||||
*/
|
||||
private function getTotpModel(): ?TotpModel
|
||||
{
|
||||
$query = $this->getTotpQuery();
|
||||
if ($query === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$totp = $query
|
||||
->filter(Filter::equal('username', $this->username))
|
||||
->first();
|
||||
if ($totp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$totp = $this->ensureIsTotpModel($totp);
|
||||
} catch (ConfigurationError $e) {
|
||||
$totp = null;
|
||||
}
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the provided model is an instance of TotpModel.
|
||||
*
|
||||
* @param Model $totp The model to check
|
||||
* @throws ConfigurationError
|
||||
*/
|
||||
private function ensureIsTotpModel(Model $totp): ?TotpModel
|
||||
{
|
||||
if (!$totp instanceof TotpModel) {
|
||||
throw new ConfigurationError(sprintf(
|
||||
'Expected TotpModel, got %s',
|
||||
get_class($totp)
|
||||
));
|
||||
}
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the TOTP secret for the current user.
|
||||
*
|
||||
* @return string|null The TOTP secret or null if not found
|
||||
*/
|
||||
public function getSecret(): ?string
|
||||
{
|
||||
|
||||
return $this->secret;
|
||||
}
|
||||
|
||||
public function getTemporarySecret(): ?string
|
||||
{
|
||||
return $this->temporarySecret;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets the TOTP object based on the user's secret.
|
||||
* If the secret is not set, a new TOTP object is generated.
|
||||
*/
|
||||
private function setTotpObject(bool $new = false): void
|
||||
{
|
||||
if (isset($this->totpObject)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$new && ($totpModel = $this->getTotpModel()) !== null) {
|
||||
$this->secret = $totpModel->secret;
|
||||
$this->totpObject = extTOTP::createFromSecret($this->secret, $this->clock);
|
||||
} elseif (!$new && $this->temporarySecret !== null) {
|
||||
$this->totpObject = extTOTP::createFromSecret($this->temporarySecret, $this->clock);
|
||||
} else {
|
||||
$this->totpObject = extTOTP::generate($this->clock);
|
||||
}
|
||||
}
|
||||
|
||||
public function getCurrentCode(): string
|
||||
{
|
||||
return $this->totpObject->now();
|
||||
}
|
||||
}
|
13
library/Icinga/Clock/PsrClock.php
Normal file
13
library/Icinga/Clock/PsrClock.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Icinga\Clock;
|
||||
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
class PsrClock implements ClockInterface
|
||||
{
|
||||
public function now(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable();
|
||||
}
|
||||
}
|
38
library/Icinga/Model/Totp.php
Normal file
38
library/Icinga/Model/Totp.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Icinga\Model;
|
||||
|
||||
use ipl\Orm\Model;
|
||||
|
||||
class Totp extends Model
|
||||
{
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getTableName()
|
||||
{
|
||||
return 'icingaweb_totp';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getKeyName()
|
||||
{
|
||||
return 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getColumns()
|
||||
{
|
||||
return [
|
||||
'username',
|
||||
'secret',
|
||||
'ctime',
|
||||
'mtime'
|
||||
];
|
||||
}
|
||||
}
|
@ -99,7 +99,8 @@ class WebWizard extends Wizard implements SetupWizard
|
||||
'icingaweb_user',
|
||||
'icingaweb_user_preference',
|
||||
'icingaweb_rememberme',
|
||||
'icingaweb_schema'
|
||||
'icingaweb_schema',
|
||||
'icingaweb_totp'
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -64,5 +64,13 @@ CREATE TABLE icingaweb_schema (
|
||||
CONSTRAINT idx_icingaweb_schema_version UNIQUE (version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;
|
||||
|
||||
CREATE TABLE `icingaweb_totp`(
|
||||
`username` varchar(254) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`secret` varchar(255) NOT NULL,
|
||||
`ctime` timestamp NULL DEFAULT NULL,
|
||||
`mtime` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`username`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
|
||||
|
||||
INSERT INTO icingaweb_schema (version, timestamp, success)
|
||||
VALUES ('2.12.0', UNIX_TIMESTAMP() * 1000, 'y');
|
||||
|
Loading…
x
Reference in New Issue
Block a user