// 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\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()); $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'))], iterator_to_array(TwoFactorHook::yieldMethods()) ), '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()); } }