mirror of
https://github.com/Icinga/icingaweb2.git
synced 2026-06-13 18:40:34 -04:00
Setting the `icinga-module module-{name}` class on the fieldset before calling
`assembleEnrollmentFormElements` let hook implementations overwrite or clear it
by calling `setAttributes` on the fieldset themselves. Moving the assignment to
after assembly ensures the CSS scoping class is always present regardless of
what the hook does.
The guard on the fieldset name ensures a hook implementation cannot change the
fieldset name during assembly, which would break form submission handling.
166 lines
5.8 KiB
PHP
166 lines
5.8 KiB
PHP
<?php
|
|
|
|
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
namespace Icinga\Forms\Account;
|
|
|
|
use Icinga\Application\ClassLoader;
|
|
use Icinga\Application\Hook\TwoFactorHook;
|
|
use Icinga\Authentication\Auth;
|
|
use Icinga\Authentication\TwoFactor;
|
|
use Icinga\Web\Notification;
|
|
use Icinga\Web\RememberMe;
|
|
use Icinga\Web\Session;
|
|
use ipl\Html\Attributes;
|
|
use ipl\Html\FormElement\FieldsetElement;
|
|
use ipl\Web\Common\CsrfCounterMeasure;
|
|
use ipl\Web\Common\FormUid;
|
|
use ipl\Web\Compat\CompatForm;
|
|
use ipl\Web\Url;
|
|
use LogicException;
|
|
|
|
/**
|
|
* Form for enrolling in and unenrolling from a two-factor authentication method
|
|
*/
|
|
class TwoFactorEnrollmentForm extends CompatForm
|
|
{
|
|
use CsrfCounterMeasure;
|
|
use FormUid;
|
|
|
|
/** @var string Form field name for the selected 2FA method */
|
|
public const METHOD = 'twofactor_method';
|
|
|
|
/** @var string The submit button to enroll into a 2FA method */
|
|
protected const SUBMIT_ENROLL = 'submit_twofactor_enroll';
|
|
|
|
/** @var string The submit button to unenroll from a 2FA method */
|
|
protected const SUBMIT_UNENROLL = 'submit_twofactor_unenroll';
|
|
|
|
/** @var bool Whether the user is already enrolled in a 2FA method */
|
|
protected bool $enrolled = false;
|
|
|
|
/**
|
|
* Create a new TwoFactorEnrollmentForm
|
|
*/
|
|
public function __construct()
|
|
{
|
|
$this->setAttribute('name', 'form_twofactor_enrollment');
|
|
}
|
|
|
|
/**
|
|
* Set whether the user is already enrolled in a 2FA method
|
|
*
|
|
* Must be called before the form is assembled.
|
|
*
|
|
* @param bool $enrolled Whether the user is enrolled
|
|
*
|
|
* @return $this
|
|
*
|
|
* @throws LogicException If called after the form has been assembled
|
|
*/
|
|
public function setEnrolled(bool $enrolled): static
|
|
{
|
|
if ($this->hasBeenAssembled) {
|
|
throw new LogicException('setEnrolled() must be called before the form is assembled');
|
|
}
|
|
|
|
$this->enrolled = $enrolled;
|
|
|
|
return $this;
|
|
}
|
|
|
|
protected function assemble(): void
|
|
{
|
|
$this->addCsrfCounterMeasure(Session::getSession()->getId());
|
|
$this->addElement($this->createUidElement());
|
|
|
|
$methods = TwoFactorHook::all();
|
|
|
|
$this->addElement('select', static::METHOD, [
|
|
'label' => $this->translate('2FA Method'),
|
|
'class' => 'autosubmit',
|
|
'disabled' => $this->enrolled,
|
|
'options' => array_merge(
|
|
['' => sprintf(' - %s - ', $this->translate('Please choose'))],
|
|
array_combine(
|
|
array_map(fn(TwoFactor $method) => $method->getName(), $methods),
|
|
array_map(fn(TwoFactor $method) => $method->getDisplayName(), $methods)
|
|
)
|
|
)
|
|
]);
|
|
|
|
if ($this->enrolled) {
|
|
$this->addElement('submit', static::SUBMIT_UNENROLL, [
|
|
'label' => $this->translate('Unenroll'),
|
|
'data-progress-label' => $this->translate('Unenrolling')
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$method = $this->getPopulatedValue(static::METHOD);
|
|
|
|
if ($method === null) {
|
|
return;
|
|
}
|
|
|
|
$twoFactor = TwoFactorHook::fromName($method);
|
|
|
|
$configFieldset = new FieldsetElement($twoFactor->getName());
|
|
$this->addElement($configFieldset);
|
|
$twoFactor->assembleEnrollmentFormElements($configFieldset);
|
|
if ($configFieldset->getName() !== $twoFactor->getName()) {
|
|
throw new LogicException(sprintf(
|
|
'%s::assembleEnrollmentFormElements() must not rename the fieldset. The name "%s" is used'
|
|
. 'as the element key in onSuccess() to retrieve the fieldset, but it was changed to "%s".',
|
|
get_class($twoFactor),
|
|
$twoFactor->getName(),
|
|
$configFieldset->getName()
|
|
));
|
|
}
|
|
$configFieldset->addAttributes(Attributes::create([
|
|
'class' => 'icinga-module module-' . ClassLoader::extractModuleName(get_class($twoFactor))
|
|
]));
|
|
|
|
$this->addElement('submit', static::SUBMIT_ENROLL, [
|
|
'label' => $this->translate('Enroll'),
|
|
'data-progress-label' => $this->translate('Enrolling')
|
|
]);
|
|
}
|
|
|
|
protected function onSuccess(): void
|
|
{
|
|
$twoFactor = TwoFactorHook::fromName($this->getValue(static::METHOD) ?? '');
|
|
|
|
switch ($this->getPressedSubmitElement()?->getName()) {
|
|
case static::SUBMIT_ENROLL:
|
|
/** @var FieldsetElement $configFieldset */
|
|
$configFieldset = $this->getElement($twoFactor->getName());
|
|
if (! $twoFactor->enroll($configFieldset)) {
|
|
Notification::error($this->translate('The verification failed. Please try again.'));
|
|
|
|
// Don't redirect in this case, as the user might want to try again.
|
|
return;
|
|
}
|
|
// Delete all remember-me cookies for the current user to avoid using other
|
|
// sessions without a successful 2FA challenge.
|
|
RememberMe::removeAllByUsername(Auth::getInstance()->getUser()->getUsername());
|
|
Notification::success(sprintf(
|
|
$this->translate("Successfully enrolled in 2FA method '%s'."),
|
|
$twoFactor->getDisplayName()
|
|
));
|
|
$this->setRedirectUrl(Url::fromRequest());
|
|
|
|
break;
|
|
case static::SUBMIT_UNENROLL:
|
|
$twoFactor->unenroll();
|
|
Notification::success(sprintf(
|
|
$this->translate("Successfully unenrolled from 2FA method '%s'."),
|
|
$twoFactor->getDisplayName()
|
|
));
|
|
$this->setRedirectUrl(Url::fromRequest());
|
|
break;
|
|
}
|
|
}
|
|
}
|