icingaweb2/application/forms/Account/TwoFactorEnrollmentForm.php
Johannes Rauh f7a38cb276 Apply fieldset module class after assembleEnrollmentFormElements
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.
2026-05-27 10:47:21 +02:00

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