From 9522d1457a0dd2800cc9bcecf1e71114be2efaa9 Mon Sep 17 00:00:00 2001 From: Jan Schuppik Date: Sun, 27 Jul 2025 01:59:36 +0200 Subject: [PATCH] WIP Add: toggle 2FA control via preferences --- application/controllers/AccountController.php | 23 ++ application/controllers/TryoutController.php | 60 +++++ application/forms/Account/TotpForm.php | 234 ++++++++++++++++++ application/forms/Security/RoleForm.php | 3 + application/views/scripts/account/index.phtml | 4 + library/Icinga/Authentication/Totp.php | 207 ++++++++++++++++ library/Icinga/Clock/PsrClock.php | 13 + library/Icinga/Model/Totp.php | 38 +++ modules/setup/library/Setup/WebWizard.php | 3 +- schema/mysql.schema.sql | 8 + 10 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 application/controllers/TryoutController.php create mode 100644 application/forms/Account/TotpForm.php create mode 100644 library/Icinga/Authentication/Totp.php create mode 100644 library/Icinga/Clock/PsrClock.php create mode 100644 library/Icinga/Model/Totp.php diff --git a/application/controllers/AccountController.php b/application/controllers/AccountController.php index f172cfeca..38f5abfd4 100644 --- a/application/controllers/AccountController.php +++ b/application/controllers/AccountController.php @@ -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)) { diff --git a/application/controllers/TryoutController.php b/application/controllers/TryoutController.php new file mode 100644 index 000000000..33245bea1 --- /dev/null +++ b/application/controllers/TryoutController.php @@ -0,0 +1,60 @@ +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') +// ) +// ); +// } + } +} diff --git a/application/forms/Account/TotpForm.php b/application/forms/Account/TotpForm.php new file mode 100644 index 000000000..6a5ed1da5 --- /dev/null +++ b/application/forms/Account/TotpForm.php @@ -0,0 +1,234 @@ +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; +// } +} diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 58387f7aa..4ab126373 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -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') ], diff --git a/application/views/scripts/account/index.phtml b/application/views/scripts/account/index.phtml index efc2bcbf6..c48b721fe 100644 --- a/application/views/scripts/account/index.phtml +++ b/application/views/scripts/account/index.phtml @@ -5,6 +5,10 @@

translate('Account') ?>

+ + +

translate('2FA - TOTP') ?>

+

translate('Preferences') ?>

diff --git a/library/Icinga/Authentication/Totp.php b/library/Icinga/Authentication/Totp.php new file mode 100644 index 000000000..8dd679215 --- /dev/null +++ b/library/Icinga/Authentication/Totp.php @@ -0,0 +1,207 @@ +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(); + } +} diff --git a/library/Icinga/Clock/PsrClock.php b/library/Icinga/Clock/PsrClock.php new file mode 100644 index 000000000..8d0c28aef --- /dev/null +++ b/library/Icinga/Clock/PsrClock.php @@ -0,0 +1,13 @@ +