icingaweb2/application/forms/Authentication/TwoFactorChallengeForm.php
Johannes Rauh c225fc0b8a Add TwoFactorChallengeForm and login CSS
`TwoFactorChallengeForm` renders the second login step: a token input, a
"Verify" submit button, and a "Back to login" link button. `onSuccess()` calls
`TwoFactorHook::loadEnrolled()` and `TwoFactor::verify()` to verify the token,
calls `Auth::setAuthenticated()`, persists the `RememberMe` cookie if one was
stashed, completes the challenge, fires `AuthenticationHook::triggerLogin()`,
and redirects.

Add `.btn-back-to-login-link` to `login.less`. A link-styled submit button with
an arrow to the left with an underline on hover.
2026-05-26 15:27:51 +02:00

166 lines
5.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\Http\HttpBadRequestException;
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('hidden', '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.
* 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->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();
$this->setRedirectUrl($this->createRedirectUrl());
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!'));
}
/**
* @return Url
*
* @throws HttpBadRequestException
*/
public function createRedirectUrl(): Url
{
$redirect = null;
if ($this->hasBeenAssembled) {
$redirect = $this->getElement('redirect')->getValue();
}
if (empty($redirect) || str_contains($redirect, 'authentication/logout')) {
$redirect = LoginForm::REDIRECT_URL;
}
$redirectUrl = Url::fromPath($redirect);
if ($redirectUrl->isExternal()) {
throw new HttpBadRequestException('nope');
}
return $redirectUrl;
}
}