icingaweb2/application/forms/Authentication/TwoFactorChallengeForm.php

159 lines
5.8 KiB
PHP
Raw Normal View History

<?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\Web\Form\Element\LoginRedirect;
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;
/**
* 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');
}
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;
}
if ($twoFactorMethod->verify($this->getValue(static::TOKEN))) {
Logger::info(
'User "%s" passed two-factor verification using method "%s"',
$user->getUsername(),
$twoFactorMethod->getName()
);
Auth::getInstance()->setAuthenticated($user);
$response = Icinga::app()->getResponse();
if ($rememberMe = $twoFactorState->getRememberMeCookie()) {
try {
$response->setCookie($rememberMe->getCookie());
$rememberMe->persist();
} 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!'));
}
}