// 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!')); } }