icingaweb2/application/forms/Authentication/TwoFactorChallengeForm.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

177 lines
6.6 KiB
PHP

<?php
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later
namespace Icinga\Forms\Authentication;
use Exception;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Application\Hook\TwoFactorHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\Auth;
use Icinga\Authentication\TwoFactorState;
use Icinga\Exception\IcingaException;
use Icinga\Web\Form\Element\LoginRedirect;
use Icinga\Web\RememberMe;
use Icinga\Web\Session;
use Icinga\Web\Url;
use ipl\Html\Attributes;
use ipl\Html\FormDecoration\ErrorsDecorator;
use ipl\Html\FormDecoration\HtmlTagDecorator;
use ipl\Html\FormDecoration\RenderElementDecorator;
use ipl\Html\HtmlElement;
use ipl\Html\Text;
use ipl\Web\Common\CsrfCounterMeasure;
use ipl\Web\Common\FormUid;
use ipl\Web\Compat\CompatForm;
use ipl\Web\Widget\Icon;
use Throwable;
/**
* Form for the two-factor authentication challenge
*/
class TwoFactorChallengeForm extends CompatForm
{
use CsrfCounterMeasure;
use FormUid;
/** @var string Name of the token text input in the verification form */
public const TOKEN = 'twofactor_token';
/** @var string Name of the verify submit button in the verification form */
public const SUBMIT_VERIFY = 'submit_twofactor_verify';
/** @var string Name of the cancel submit button in the verification form */
public const SUBMIT_CANCEL = 'submit_twofactor_cancel';
/**
* Create a new TwoFactorChallengeForm
*/
public function __construct()
{
$this->setAttribute('name', 'form_twofactor_challenge');
// Gives this form a unique CSS path so that loader.js does not incorrectly restore focus to
// this form's submit button after the login form submits and transitions to this view.
$this->setAttribute('id', 'twofactor-challenge-form-focus-anchor');
}
protected function assemble(): void
{
$this->addCsrfCounterMeasure(Session::getSession()->getId());
$this->addElement($this->createUidElement());
$this->addElement('text', static::TOKEN, [
'autocomplete' => 'off',
'autofocus' => '',
'class' => 'content-centered',
'decorators' => [
'RenderElement' => new RenderElementDecorator(),
'Errors' => (new ErrorsDecorator())->setClass('errors'),
'ControlGroup' => (new HtmlTagDecorator())->setTag('div')->setClass('control-group')
],
'placeholder' => $this->translate('Please enter your 2FA token'),
'required' => true
]);
$this->addElement('submit', static::SUBMIT_VERIFY, [
'data-progress-label' => $this->translate('Verifying'),
'label' => $this->translate('Verify')
]);
$this->addElement('submitButton', static::SUBMIT_CANCEL, [
'class' => 'btn-back-to-login-link',
'formnovalidate' => true,
'label' => [
new Icon('arrow-left'),
HtmlElement::create('span', Attributes::create(), Text::create($this->translate('Back to login')))
]
]);
$this->addElement(new LoginRedirect('redirect', ['value' => Url::fromRequest()->getParam('redirect')]));
}
/**
* Verify the submitted two-factor token and complete login on success
*
* Retrieves the challenged user from the session and the enrolled 2FA method.
* If the enrolled method is no longer available, adds an error message and
* calls {@see onError()}. On a valid token, authenticates the user, optionally
* issues the remember-me cookie stored in the challenge state, clears the
* pending challenge from the session, triggers registered
* {@see AuthenticationHook}s, and sets the post-login redirect URL. On an
* invalid token, adds a validation error to the token field.
*
* @return void
*/
protected function onSuccess(): void
{
$twoFactorState = new TwoFactorState();
$user = $twoFactorState->getChallengedUser();
$twoFactorMethod = TwoFactorHook::loadEnrolled($user);
if ($twoFactorMethod === null) {
// This can happen when another user disables the module that provides the 2FA method,
// while the current user is still on the 2FA challenge form.
$this->addMessage($this->translate(
'Your two-factor authentication method is no longer available.'
. ' If this is unexpected, contact your administrator.'
. ' Otherwise use \'Back to login\' — you will not be prompted for a second factor.'
));
$this->onError();
return;
}
try {
$verified = $twoFactorMethod->verify($this->getValue(static::TOKEN));
} catch (Throwable $e) {
Logger::error("%s\n%s", $e->getMessage(), IcingaException::getConfidentialTraceAsString($e));
$this->addMessage(
$this->translate('Two-factor verification is currently unavailable: %s'),
$e->getMessage()
);
$this->onError();
return;
}
if ($verified) {
Logger::info(
'User "%s" passed two-factor verification using method "%s"',
$user->getUsername(),
$twoFactorMethod->getName()
);
Auth::getInstance()->setAuthenticated($user);
$response = Icinga::app()->getResponse();
if ($cookieData = $twoFactorState->getRememberMeCookieData()) {
try {
$rememberMe = RememberMe::fromCookieData($cookieData);
$response->setCookie($rememberMe->getCookie());
} catch (Exception $e) {
Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e);
}
}
$twoFactorState->completeChallenge();
// Call provided AuthenticationHook(s) after successful login
AuthenticationHook::triggerLogin($user);
$response->setRerenderLayout();
/** @var LoginRedirect $redirect */
$redirect = $this->getElement('redirect');
$this->setRedirectUrl($redirect->getUrl());
return;
}
Logger::warning(
'Two-factor verification failed for user "%s" using method "%s"',
$user->getUsername(),
$twoFactorMethod->getName()
);
$this->getElement(static::TOKEN)->addMessage($this->translate('Token is invalid!'));
}
}