mirror of
https://github.com/Icinga/icingaweb2.git
synced 2026-06-15 11:29:30 -04:00
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.
177 lines
6.6 KiB
PHP
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!'));
|
|
}
|
|
}
|