WIP Add: toggle 2FA control via preferences

This commit is contained in:
Jan Schuppik 2025-07-27 01:59:36 +02:00
parent b8cc14dc35
commit 9522d1457a
10 changed files with 592 additions and 1 deletions

View File

@ -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)) {

View 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')
// )
// );
// }
}
}

View 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;
// }
}

View File

@ -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')
],

View File

@ -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 ?>

View 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();
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Icinga\Clock;
use Psr\Clock\ClockInterface;
class PsrClock implements ClockInterface
{
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}

View 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'
];
}
}

View File

@ -99,7 +99,8 @@ class WebWizard extends Wizard implements SetupWizard
'icingaweb_user',
'icingaweb_user_preference',
'icingaweb_rememberme',
'icingaweb_schema'
'icingaweb_schema',
'icingaweb_totp'
);
/**

View File

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