icingaweb2/application/forms/Account/TwoFactorEnrollmentForm.php
Johannes Rauh 5cd6a2b90d Guard hook method call sites against implementation exceptions
The `TwoFactor` interface methods that contain logic are implemented by module
developers and may contain arbitrary third-party code. Without guards at the
call sites, an uncaught exception from a faulty implementation could crash the
whole application.

Each guard logs the exception with a confidential trace. `isEnrolled()` in
`loadEnrolled()` rethrows so the caller aborts rather than silently continuing.
`verify()` and `assembleEnrollmentFormElements()` show a user-facing message
and call `onError()` to keep the user on the form.

The existing `enroll()` and `unenroll()` catches are also updated: two separate
`Logger::error()` calls become one, and the user-visible messages gain
translation context.
2026-05-27 10:47:21 +02:00

202 lines
6.9 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\Application\Logger;
use Icinga\Authentication\Auth;
use Icinga\Authentication\TwoFactor;
use Icinga\Exception\IcingaException;
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;
use Throwable;
/**
* 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,
'disabledOptions' => [''],
'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)
)
),
'required' => true
]);
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);
try {
$twoFactor->assembleEnrollmentFormElements($configFieldset);
} catch (Throwable $e) {
Logger::error("%s\n%s", $e->getMessage(), IcingaException::getConfidentialTraceAsString($e));
$this->addMessage(
$this->translate('Two-factor method "%s" failed to assemble enrollment form elements: %s'),
$twoFactor->getName(),
$e->getMessage()
);
$this->onError();
return;
}
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());
try {
if (! $twoFactor->enroll($configFieldset)) {
$this->onError();
// Don't redirect in this case, as the user might want to try again.
return;
}
} catch (Throwable $e) {
Logger::error("%s\n%s", $e->getMessage(), IcingaException::getConfidentialTraceAsString($e));
$this->addMessage(
$this->translate('Could not enroll in two-factor method: %s'),
$e->getMessage()
);
$this->onError();
return;
}
break;
case static::SUBMIT_UNENROLL:
try {
$twoFactor->unenroll();
} catch (Throwable $e) {
Logger::error("%s\n%s", $e->getMessage(), IcingaException::getConfidentialTraceAsString($e));
$this->addMessage(
$this->translate('Could not unenroll from two-factor method: %s'),
$e->getMessage()
);
$this->onError();
return;
}
break;
default:
return;
}
// Revoke all remember-me cookies for the current user. On enrollment,
// existing cookies would bypass the new 2FA requirement. On unenrollment,
// cookies issued during the enrolled period were only granted after a
// successful 2FA challenge and should not remain valid.
RememberMe::removeAllByUsername(Auth::getInstance()->getUser()->getUsername());
$this->setRedirectUrl(Url::fromRequest());
}
}