diff --git a/application/controllers/AccountController.php b/application/controllers/AccountController.php index f172cfeca7..954ecb72ab 100644 --- a/application/controllers/AccountController.php +++ b/application/controllers/AccountController.php @@ -3,20 +3,27 @@ namespace Icinga\Controllers; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Config; +use Icinga\Authentication\TwoFactorTotp; use Icinga\Authentication\User\UserBackend; +use Icinga\Common\Database; use Icinga\Data\ConfigObject; use Icinga\Exception\ConfigurationError; use Icinga\Forms\Account\ChangePasswordForm; +use Icinga\Forms\Account\TwoFactorConfigForm; use Icinga\Forms\PreferenceForm; use Icinga\User\Preferences\PreferencesStore; use Icinga\Web\Controller; +use ipl\Html\Contract\Form; /** * My Account */ class AccountController extends Controller { + use Database; + /** * {@inheritdoc} */ @@ -67,6 +74,25 @@ public function indexAction() } } + if ($user->can('user/two-factor-authentication')) { + $twoFactor = TwoFactorTotp::loadFromDb($this->getDb(), $user->getUsername()); + if ($twoFactor === null) { + $twoFactor = TwoFactorTotp::generate($user->getUsername()); + } + + $twoFactorForm = new TwoFactorConfigForm(); + $twoFactorForm->setUser($user); + $twoFactorForm->setTwoFactor($twoFactor); + $twoFactorForm->on(Form::ON_SUBMIT, function (TwoFactorConfigForm $form) { + if ($redirectUrl = $form->getRedirectUrl()) { + $this->redirectNow($redirectUrl); + } + }); + $twoFactorForm->handleRequest(ServerRequest::fromGlobals()); + + $this->view->twoFactorForm = $twoFactorForm; + } + $form = new PreferenceForm(); $form->setPreferences($user->getPreferences()); if (isset($config->config_resource)) { diff --git a/application/controllers/AuthenticationController.php b/application/controllers/AuthenticationController.php index 752f8453c2..78d63eaaeb 100644 --- a/application/controllers/AuthenticationController.php +++ b/application/controllers/AuthenticationController.php @@ -3,16 +3,21 @@ namespace Icinga\Controllers; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Hook\AuthenticationHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Authentication\User\ExternalBackend; use Icinga\Common\Database; use Icinga\Exception\AuthenticationException; use Icinga\Forms\Authentication\LoginForm; use Icinga\Web\Controller; use Icinga\Web\Helper\CookieHelper; use Icinga\Web\RememberMe; +use Icinga\Web\Session; use Icinga\Web\Url; +use ipl\Html\Contract\Form; use RuntimeException; /** @@ -41,7 +46,42 @@ public function loginAction() if (($requiresSetup = $icinga->requiresSetup()) && $icinga->setupTokenExists()) { $this->redirectNow(Url::fromPath('setup')); } - $form = new LoginForm(); + + $form = (new LoginForm()) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(Form::ON_SUBMIT, function (LoginForm $form) { + if ($redirectUrl = $form->getRedirectUrl()) { + $this->redirectNow($redirectUrl); + } + }) + ->on(Form::ON_SENT, function (LoginForm $form) { + if ( + $form->getElement('CSRFToken')->isValid() + && $form->getPressedSubmitElement()?->getName() === $form::SUBMIT_CANCEL_2FA + ) { + Session::getSession()->purge(); + $this->redirectNow(Url::fromRequest()); + } + }) + ->on(Form::ON_REQUEST, function ($request, LoginForm $form) { + $auth = Auth::getInstance(); + $onlyExternal = true; + // TODO(el): This may be set on the auth chain once iterated. See Auth::authExternal(). + foreach ($auth->getAuthChain() as $backend) { + if (! $backend instanceof ExternalBackend) { + $onlyExternal = false; + } + } + if ($onlyExternal) { + $form->addMessage($this->translate( + 'You\'re currently not authenticated using any of the web server\'s authentication' + . 'mechanisms. Make sure you\'ll configure such, otherwise you\'ll not be able to login.' + )); + $form->onError(); + } + }); + + $skip2fa = false; if (RememberMe::hasCookie() && $this->hasDb()) { $authenticated = false; @@ -52,6 +92,7 @@ public function loginAction() $rememberMe = $rememberMeOld->renew(); $this->getResponse()->setCookie($rememberMe->getCookie()); $rememberMe->persist($rememberMeOld->getAesCrypt()->getIV()); + $skip2fa = true; } } catch (RuntimeException $e) { Logger::error("Can't authenticate user via remember me cookie: %s", $e->getMessage()); @@ -64,7 +105,7 @@ public function loginAction() } } - if ($this->Auth()->isAuthenticated()) { + if ($this->Auth()->isAuthenticated($skip2fa)) { // Call provided AuthenticationHook(s) when login action is called // but icinga web user is already authenticated AuthenticationHook::triggerLogin($this->Auth()->getUser()); @@ -76,7 +117,7 @@ public function loginAction() $this->httpBadRequest('nope'); } } else { - $redirectUrl = $form->getRedirectUrl(); + $redirectUrl = $form->createRedirectUrl(); } $this->redirectNow($redirectUrl); @@ -91,9 +132,10 @@ public function loginAction() ->sendResponse(); exit; } - $form->handleRequest(); + $form->handleRequest(ServerRequest::fromGlobals()); } $this->view->form = $form; + $this->view->cancel2faForm = $cancel2faForm ?? null; $this->view->defaultTitle = $this->translate('Icinga Web 2 Login'); $this->view->requiresSetup = $requiresSetup; } diff --git a/application/forms/Account/TwoFactorConfigForm.php b/application/forms/Account/TwoFactorConfigForm.php new file mode 100644 index 0000000000..9c85c3b8d2 --- /dev/null +++ b/application/forms/Account/TwoFactorConfigForm.php @@ -0,0 +1,192 @@ +setAttribute('name', 'form_config_2fa'); + } + + /** + * Set the user to work with + * + * @param User $user The user to work with + * + * @return $this + */ + public function setUser(User $user): static + { + $this->user = $user; + + return $this; + } + + /** + * Set the TwoFactorTotp instance to work with + * + * @param TwoFactorTotp $twoFactor + * + * @return $this + */ + public function setTwoFactor(TwoFactorTotp $twoFactor): static + { + $this->twoFactor = $twoFactor; + + return $this; + } + + protected function assemble(): void + { + $this->addElement($this->createUidElement()); + + if (TwoFactorTotp::hasDbSecret($this->getDb(), $this->user->getUsername())) { + $this->addElement( + 'submit', + static::SUBMIT_DISABLE, + [ + 'label' => $this->translate('Disable 2FA'), + 'data-progress-label' => $this->translate('Disabling') + ] + ); + } else { + $this->addElement( + 'checkbox', + 'enabled_2fa', + [ + 'class' => 'autosubmit', + 'label' => $this->translate('Enable 2FA (TOTP)'), + 'description' => $this->translate( + 'This option allows you to enable or to disable the two factor authentication via TOTP.' + ), + ] + ); + + if ($this->getPopulatedValue('enabled_2fa') === 'y') { + // Keep the secret after form submission, otherwise every form submission would generate a new secret. + // This would result in the following: + // - Users would have to scan a new QR code every time the verification fails. + // - Token verification would fail every time because the secret would have changed. + if ($secret = $this->getPopulatedValue('2fa_totp_secret')) { + $this->twoFactor = TwoFactorTotp::createFromSecret($secret, $this->user->getUsername()); + } + + $this->addHtml(new FakeFormElement( + HtmlElement::create('img', Attributes::create([ + 'class' => 'two-factor-totp-qr-code', + 'src' => $this->twoFactor->createQRCode() + ])), + $this->translate('QR Code'), + $this->translate('Use your authenticator app to scan the QR code.') + )); + + $manualAuthUrl = HtmlElement::create( + 'div', + Attributes::create(['class' => 'two-factor-totp-auth-url']), + new Text($this->twoFactor->getTotpAuthUrl()), + ); + CopyToClipboard::attachTo($manualAuthUrl); + $this->addHtml(new FakeFormElement( + $manualAuthUrl, + $this->translate('Manual Auth URL'), + $this->translate('If you have no camera to scan the QR code you can enter the auth URL manually.') + )); + + $this->addElement( + 'text', + '2fa_verification_token', + [ + 'required' => true, + 'label' => $this->translate('Verification Token'), + 'description' => $this->translate( + 'Please enter the token from your authenticator app to verify your setup.' + ), + 'validators' => [new TotpTokenValidator()] + ] + ); + + $this->addElement( + 'submit', + static::SUBMIT_VERIFY, + [ + 'label' => $this->translate('Verify 2FA TOTP Secret'), + 'data-progress-label' => $this->translate('Verifying') + ] + ); + } + } + + $this->addElement( + 'hidden', + '2fa_totp_secret', + [ + 'value' => $this->twoFactor->getSecret() + ] + ); + } + + protected function onSuccess(): void + { + $twoFactor = TwoFactorTotp::createFromSecret($this->getValue('2fa_totp_secret'), $this->user->getUsername()); + + switch ($this->getPressedSubmitElement()?->getName()) { + case static::SUBMIT_VERIFY: + $token = $this->getValue('2fa_verification_token'); + if ($token && $twoFactor->verify($token)) { + $twoFactor->saveToDb(); + Notification::success($this->translate('2FA via TOTP has been configured successfully.')); + } else { + Notification::error($this->translate('The verification token is invalid. Please try again.')); + + // Don't redirect in this case, as the user might want to try again. + return; + } + + break; + case static::SUBMIT_DISABLE: + $twoFactor->removeFromDb(); + Notification::success($this->translate('2FA TOTP secret has been removed.')); + + break; + } + + $this->setRedirectUrl(Url::fromRequest()); + } +} diff --git a/application/forms/Authentication/Challenge2FAForm.php b/application/forms/Authentication/Challenge2FAForm.php new file mode 100644 index 0000000000..520a6798c2 --- /dev/null +++ b/application/forms/Authentication/Challenge2FAForm.php @@ -0,0 +1,133 @@ +addAttributes(Attributes::create(['name' => '2fa_challenge_form'])); + } + + /** + * Return the current Response + * + * @return Response + */ + protected function getResponse(): Response + { + return Icinga::app()->getFrontController()->getResponse(); + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement($this->createUidElement()); + + $this->addElement( + 'text', + 'token', + [ + 'required' => true, + 'class' => 'autofocus content-centered', + 'placeholder' => $this->translate('Please enter your 2FA token'), + 'autocomplete' => 'off', + 'autocapitalize' => 'off', + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'Errors' => ['name' => 'Errors', 'options' => ['class' => 'errors']] + ], + 'validators' => [new TotpTokenValidator()] + ] + ); + + $this->addElement( + 'submit', + self::SUBMIT_VERIFY, + [ + 'data-progress-label' => $this->translate('Verifying'), + 'label' => $this->translate('Verify'), + ] + ); + + $this->addElement( + 'submit', + self::SUBMIT_CANCEL, + [ + 'ignore' => true, + 'formnovalidate' => true, + 'class' => 'btn-cancel', + 'label' => $this->translate('Cancel'), + 'data-progress-label' => $this->translate('Canceling') + ] + ); + + $this->addElement( + 'hidden', + 'redirect', + [ + 'value' => Url::fromRequest()->getParam('redirect') + ] + ); + } + + protected function onSuccess(): void + { + $user = Auth::getInstance()->getUser(); + $twoFactor = TwoFactorTotp::loadFromDb($this->getDb(), $user->getUsername()); + if ($this->getElement('token') && $twoFactor->verify($this->getValue('token'))) { + $auth = Auth::getInstance(); + $user = $auth->getUser(); + $user->setTwoFactorSuccessful(); + + Session::getSession()->delete('2fa_must_challenge_token'); + + $auth->setAuthenticated($user); + + if ($rememberMe = Session::getSession()->get('2fa_remember_me_cookie')) { + try { + $this->getResponse()->setCookie($rememberMe->getCookie()); + $rememberMe->persist(); + } catch (Exception $e) { + Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); + } + } + + // Call provided AuthenticationHook(s) after successful login + AuthenticationHook::triggerLogin($user); + + $this->getResponse()->setRerenderLayout(true); + + $this->setRedirectUrl(Url::fromRequest()); + } + + $this->getElement('token')->addMessage($this->translate('Token is invalid!')); + } +} diff --git a/application/forms/Authentication/LoginForm.php b/application/forms/Authentication/LoginForm.php index 87b32ab3c6..74386c4fb2 100644 --- a/application/forms/Authentication/LoginForm.php +++ b/application/forms/Authentication/LoginForm.php @@ -6,122 +6,212 @@ use Exception; use Icinga\Application\Config; use Icinga\Application\Hook\AuthenticationHook; +use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; -use Icinga\Authentication\User\ExternalBackend; +use Icinga\Authentication\TwoFactorTotp; use Icinga\Common\Database; use Icinga\Exception\Http\HttpBadRequestException; use Icinga\User; -use Icinga\Web\Form; +use Icinga\Web\Form\Validator\TotpTokenValidator; use Icinga\Web\RememberMe; +use Icinga\Web\Response; +use Icinga\Web\Session; use Icinga\Web\Url; +use ipl\Html\FormDecoration\LabelDecorator; +use ipl\Html\FormDecoration\RenderElementDecorator; +use ipl\Web\Common\CsrfCounterMeasure; +use ipl\Web\Common\FormUid; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Compat\FormDecorator\CheckboxDecorator; /** * Form for user authentication */ -class LoginForm extends Form +class LoginForm extends CompatForm { + use CsrfCounterMeasure; use Database; + use FormUid; - const DEFAULT_CLASSES = 'icinga-controls'; - - /** - * Redirect URL - */ + /** @var string Redirect URL */ const REDIRECT_URL = 'dashboard'; - public static $defaultElementDecorators = [ - ['ViewHelper', ['separator' => '']], - ['Help', []], - ['Errors', ['separator' => '']], - ['HtmlTag', ['tag' => 'div', 'class' => 'control-group']] - ]; + /** @var string */ + const SUBMIT_LOGIN = 'btn_submit_login'; - /** - * {@inheritdoc} - */ - public function init() + /** @var string */ + const SUBMIT_VERIFY_2FA = 'btn_submit_verify_2fa'; + + /** @var string */ + const SUBMIT_CANCEL_2FA = 'btn_submit_cancel_2fa'; + + public function __construct() { - $this->setRequiredCue(null); - $this->setName('form_login'); - $this->setSubmitLabel($this->translate('Login')); - $this->setProgressLabel($this->translate('Logging in')); + $this->setAttribute('name', 'form_login'); } /** - * {@inheritdoc} + * Return the current Response + * + * @return Response */ - public function createElements(array $formData) + protected function getResponse(): Response + { + return Icinga::app()->getFrontController()->getResponse(); + } + + /** @return void */ + public function assembleLoginElements(): void { $this->addElement( 'text', 'username', - array( - 'autocapitalize' => 'off', - 'autocomplete' => 'username', - 'class' => false === isset($formData['username']) ? 'autofocus' : '', - 'placeholder' => $this->translate('Username'), - 'required' => true - ) + [ + 'required' => true, + 'autocomplete' => 'username', + 'autocapitalize' => 'off', + 'class' => ! isset($formData['username']) ? 'autofocus' : '', + 'placeholder' => $this->translate('Username'), + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'ControlGroup' => [ + 'name' => 'HtmlTag', + 'options' => ['tag' => 'div', 'class' => 'control-group'] + ] + ] + ] ); + $this->addElement( 'password', 'password', - array( - 'required' => true, - 'autocomplete' => 'current-password', - 'placeholder' => $this->translate('Password'), - 'class' => isset($formData['username']) ? 'autofocus' : '' - ) + [ + 'required' => true, + 'autocomplete' => 'current-password', + 'class' => isset($formData['username']) ? 'autofocus' : '', + 'placeholder' => $this->translate('Password'), + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'Errors' => ['name' => 'Errors', 'options' => ['class' => 'errors']], + 'ControlGroup' => [ + 'name' => 'HtmlTag', + 'options' => ['tag' => 'div', 'class' => 'control-group'] + ] + ] + ] ); + $this->addElement( 'checkbox', 'rememberme', [ - 'label' => $this->translate('Stay logged in'), - 'decorators' => [ - ['ViewHelper', ['separator' => '']], - ['Label', [ - 'tag' => 'span', - 'separator' => '', - 'class' => 'control-label', - 'placement' => 'APPEND' - ]], - ['Help', []], - ['Errors', ['separator' => '']], - ['HtmlTag', ['tag' => 'div', 'class' => 'control-group remember-me-box']] + 'label' => $this->translate('Stay logged in'), + 'disabled' => ! RememberMe::isSupported(), + 'decorators' => [ + 'Checkbox' => new CheckboxDecorator(), + 'RenderElement' => new RenderElementDecorator(), + 'Label' => new LabelDecorator(), + 'ControlGroup' => [ + 'name' => 'HtmlTag', + 'options' => ['tag' => 'div', 'class' => 'control-group remember-me-box'] + ] ] ] ); - if (! RememberMe::isSupported()) { - $this->getElement('rememberme') - ->setAttrib('disabled', true) - ->setDescription($this->translate( - 'Staying logged in requires a database configuration backend' - . ' and an appropriate OpenSSL encryption method' - )); + + $this->addElement( + 'submit', + static::SUBMIT_LOGIN, + [ + 'label' => $this->translate('Login'), + 'data-progress-label' => $this->translate('Logging in'), + ] + ); + } + + /** @return void */ + public function assembleTwoFactorElements(): void + { + $this->addElement( + 'text', + 'token', + [ + 'required' => true, + 'class' => 'autofocus content-centered', + 'placeholder' => $this->translate('Please enter your 2FA token'), + 'autocomplete' => 'off', + 'autocapitalize' => 'off', + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'Errors' => ['name' => 'Errors', 'options' => ['class' => 'errors']] + ], + 'validators' => [new TotpTokenValidator()] + ] + ); + + $this->addElement( + 'submit', + static::SUBMIT_VERIFY_2FA, + [ + 'data-progress-label' => $this->translate('Verifying'), + 'label' => $this->translate('Verify'), + ] + ); + + $this->addElement( + 'submit', + static::SUBMIT_CANCEL_2FA, + [ + 'ignore' => true, + 'formnovalidate' => true, + 'class' => 'btn-cancel', + 'label' => $this->translate('Cancel'), + 'data-progress-label' => $this->translate('Canceling') + ] + ); + + $this->addElement( + 'hidden', + 'redirect', + [ + 'value' => Url::fromRequest()->getParam('redirect') + ] + ); + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement($this->createUidElement()); + + if (Session::getSession()->get('2fa_must_challenge_token', false)) { + $this->assembleTwoFactorElements(); + } else { + $this->assembleLoginElements(); } $this->addElement( 'hidden', 'redirect', - array( + [ 'value' => Url::fromRequest()->getParam('redirect') - ) + ] ); } /** - * {@inheritdoc} + * @return string|null + * @throws HttpBadRequestException */ - public function getRedirectUrl() + public function createRedirectUrl(): string|null { $redirect = null; - if ($this->created) { + if ($this->hasBeenAssembled) { $redirect = $this->getElement('redirect')->getValue(); } - if (empty($redirect) || strpos($redirect, 'authentication/logout') !== false) { + if (empty($redirect) || str_contains($redirect, 'authentication/logout')) { $redirect = static::REDIRECT_URL; } @@ -133,82 +223,123 @@ public function getRedirectUrl() return $redirectUrl; } - /** - * {@inheritdoc} - */ - public function onSuccess() + protected function onSuccess(): void { - $auth = Auth::getInstance(); - $authChain = $auth->getAuthChain(); - $authChain->setSkipExternalBackends(true); - $user = new User($this->getElement('username')->getValue()); - if (! $user->hasDomain()) { - $user->setDomain(Config::app()->get('authentication', 'default_domain')); - } - $password = $this->getElement('password')->getValue(); - $authenticated = $authChain->authenticate($user, $password); - if ($authenticated) { - $auth->setAuthenticated($user); - if ($this->getElement('rememberme')->isChecked()) { - try { - $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); - $this->getResponse()->setCookie($rememberMe->getCookie()); - $rememberMe->persist(); - } catch (Exception $e) { - Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); + switch ($this->getPressedSubmitElement()?->getName()) + { + case static::SUBMIT_LOGIN: + $auth = Auth::getInstance(); + $authChain = $auth->getAuthChain(); + $authChain->setSkipExternalBackends(true); + $user = new User($this->getElement('username')->getValue()); + if (! $user->hasDomain()) { + $user->setDomain(Config::app()->get('authentication', 'default_domain')); + } + $password = $this->getElement('password')->getValue(); + $authenticated = $authChain->authenticate($user, $password); + if ($authenticated) { + if (! $user->getTwoFactorEnabled()) { + $auth->setAuthenticated($user); + } else { + $session = Session::getSession(); + $session->set('2fa_must_challenge_token', true); + $session->set('2fa_temporary_user', $user); + + if ($this->getElement('rememberme')->isChecked()) { + $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); + $session->set('2fa_remember_me_cookie', $rememberMe); + } + + $this->setRedirectUrl(Url::fromRequest()); + + return; + } + + if ($this->getElement('rememberme')->isChecked()) { + try { + $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); + $this->getResponse()->setCookie($rememberMe->getCookie()); + $rememberMe->persist(); + } catch (Exception $e) { + Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); + } + } + + // Call provided AuthenticationHook(s) after successful login + AuthenticationHook::triggerLogin($user); + + $this->getResponse()->setRerenderLayout(true); + $this->setRedirectUrl($this->createRedirectUrl()); + + return; + } + switch ($authChain->getError()) { + case $authChain::EEMPTY: + $this->addMessage($this->translate( + 'No authentication methods available.' + . ' Did you create authentication.ini when setting up Icinga Web 2?' + )); + + break; + case $authChain::EFAIL: + $this->addMessage($this->translate( + 'All configured authentication methods failed.' + . ' Please check the system log or Icinga Web 2 log for more information.' + )); + + break; + /** @noinspection PhpMissingBreakStatementInspection */ + case $authChain::ENOTALL: + $this->addMessage($this->translate( + 'Please note that not all authentication methods were available.' + . ' Check the system log or Icinga Web 2 log for more information.' + )); + // Move to default + default: + $this->getElement('password')->addMessage($this->translate('Incorrect username or password')); } - } - // Call provided AuthenticationHook(s) after successful login - AuthenticationHook::triggerLogin($user); - $this->getResponse()->setRerenderLayout(true); - return true; - } - switch ($authChain->getError()) { - case $authChain::EEMPTY: - $this->addError($this->translate( - 'No authentication methods available.' - . ' Did you create authentication.ini when setting up Icinga Web 2?' - )); - break; - case $authChain::EFAIL: - $this->addError($this->translate( - 'All configured authentication methods failed.' - . ' Please check the system log or Icinga Web 2 log for more information.' - )); - break; - /** @noinspection PhpMissingBreakStatementInspection */ - case $authChain::ENOTALL: - $this->addError($this->translate( - 'Please note that not all authentication methods were available.' - . ' Check the system log or Icinga Web 2 log for more information.' - )); - // Move to default - default: - $this->getElement('password')->addError($this->translate('Incorrect username or password')); break; + + case static::SUBMIT_VERIFY_2FA: + $session = Session::getSession(); + /** @var User $user */ + $user = $session->get('2fa_temporary_user'); + $twoFactor = TwoFactorTotp::loadFromDb($this->getDb(), $user->getUsername()); + if ($this->getElement('token') && $twoFactor->verify($this->getValue('token'))) { + $user->setTwoFactorSuccessful(); + $session->delete('2fa_must_challenge_token'); + Auth::getInstance()->setAuthenticated($user); + + if ($rememberMe = $session->get('2fa_remember_me_cookie')) { + try { + $this->getResponse()->setCookie($rememberMe->getCookie()); + $rememberMe->persist(); + } catch (Exception $e) { + Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); + } + } + + // Call provided AuthenticationHook(s) after successful login + AuthenticationHook::triggerLogin($user); + + $this->getResponse()->setRerenderLayout(true); + + $this->setRedirectUrl(Url::fromRequest()); + + return; + } + + $this->getElement('token')->addMessage($this->translate('Token is invalid!')); } - return false; + + // Display the messages that were added to form or form elements + $this->onError(); } - /** - * {@inheritdoc} - */ - public function onRequest() + // Expose protected method onError() to use it in event listener callbacks + public function onError(): void { - $auth = Auth::getInstance(); - $onlyExternal = true; - // TODO(el): This may be set on the auth chain once iterated. See Auth::authExternal(). - foreach ($auth->getAuthChain() as $backend) { - if (! $backend instanceof ExternalBackend) { - $onlyExternal = false; - } - } - if ($onlyExternal) { - $this->addError($this->translate( - 'You\'re currently not authenticated using any of the web server\'s authentication mechanisms.' - . ' Make sure you\'ll configure such, otherwise you\'ll not be able to login.' - )); - } + parent::onError(); } } diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 58387f7aa5..4ab1263734 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -573,6 +573,9 @@ public static function collectProvidedPrivileges() 'user/password-change' => [ 'description' => t('Allow password changes in the account preferences') ], + 'user/two-factor-authentication'=> [ + 'description' => t('Allow 2FA configuration in the account preferences') + ], 'user/application/stacktraces' => [ 'description' => t('Allow to adjust in the preferences whether to show stacktraces') ], diff --git a/application/views/scripts/account/index.phtml b/application/views/scripts/account/index.phtml index efc2bcbf6d..58e32ca322 100644 --- a/application/views/scripts/account/index.phtml +++ b/application/views/scripts/account/index.phtml @@ -5,6 +5,10 @@