diff --git a/application/clicommands/TwofactorCommand.php b/application/clicommands/TwofactorCommand.php new file mode 100644 index 0000000000..3dc754e607 --- /dev/null +++ b/application/clicommands/TwofactorCommand.php @@ -0,0 +1,116 @@ +getDb()->select( + (new Select()) + ->from('icingaweb_2fa') + ->columns(['username', 'ctime']) + )->fetchAll(); + + if (empty($rows)) { + echo "Currently there are no users that have 2FA enabled.\n"; + + return; + } + + printf("%-20s %-20s\n", 'USER', 'ENABLED 2FA'); + + foreach ($rows as $row) { + printf( + "%-20s %-20s\n", + $row->username, + DateTime::createFromFormat('U.u', $row->ctime / 1000)->format('Y-m-d H:i:s') + ); + } + + echo "\n"; + } + + /** + * Disable 2FA for a specific user + * + * This command disables 2FA for a specific user. It asks for confirmation before deleting the secret. The deletion + * cannot be undone. + * + * USAGE + * + * icingacli twofactor disable [] + * + * OPTIONS + * + * --force Disable 2FA for user without confirmation + */ + public function disableAction(): void + { + $user = $this->params->shift(); + + if (! $user) { + fwrite(STDERR, "User must be provided!\n"); + $this->showUsage('disable'); + + exit(1); + } + + if (! $this->params->shift('force')) { + $input = readline(sprintf( + "Are you sure you want to disable 2FA for user '%s'? This cannot be undone! [y/N] ", + $user + )); + + if (! $input || ! in_array(strtolower(trim($input)), ['y', 'yes'])) { + echo "No changes made.\n"; + + return; + } + } + + try { + $delete = $this->getDb()->prepexec( + (new Delete()) + ->from('icingaweb_2fa') + ->where(['LOWER(username) = ?' => strtolower($user)]) + ); + } catch (Throwable $e) { + fprintf( + STDERR, + "%s: Failed to disable 2FA for '%s': %s\n", + $this->screen->colorize('ERROR', 'red'), + $user, + $e->getMessage() + ); + + exit(1); + } + + if ($delete->rowCount() < 1) { + printf("The user '%s' doesn't have 2FA enabled.\n", $user); + + return; + } + + printf("Successfully disabled 2FA for user '%s'.\n", $user); + } +} diff --git a/application/controllers/AccountController.php b/application/controllers/AccountController.php index 40c60a4086..6b2c0681f5 100644 --- a/application/controllers/AccountController.php +++ b/application/controllers/AccountController.php @@ -6,7 +6,10 @@ namespace Icinga\Controllers; use Icinga\Application\Config; +use Icinga\Application\Hook; +use Icinga\Application\Hook\TwoFactorHook; use Icinga\Authentication\User\UserBackend; +use Icinga\Common\Database; use Icinga\Data\ConfigObject; use Icinga\Exception\ConfigurationError; use Icinga\Forms\Account\ChangePasswordForm; @@ -19,6 +22,8 @@ */ class AccountController extends Controller { + use Database; + /** * {@inheritdoc} */ @@ -43,6 +48,13 @@ public function init() 'url' => 'my-devices' ) ); + if (Hook::has(TwoFactorHook::NAME)) { + $this->getTabs()->add('two-factor', [ + 'title' => $this->translate('Configure two-factor authentication'), + 'label' => $this->translate('Two-Factor Auth'), + 'url' => 'two-factor/config', + ]); + } } /** diff --git a/application/controllers/AuthenticationController.php b/application/controllers/AuthenticationController.php index 3f97644c10..56ed7e6c9e 100644 --- a/application/controllers/AuthenticationController.php +++ b/application/controllers/AuthenticationController.php @@ -5,7 +5,6 @@ namespace Icinga\Controllers; -use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\ClassLoader; use Icinga\Application\Hook\AuthenticationHook; use Icinga\Application\Hook\LoginButtonHook; @@ -13,20 +12,28 @@ use Icinga\Application\Logger; use Icinga\Authentication\LoginButton; use Icinga\Authentication\LoginButtonForm; +use Icinga\Authentication\Auth; +use Icinga\Authentication\TwoFactorState; +use Icinga\Authentication\User\ExternalBackend; use Icinga\Common\Database; use Icinga\Exception\AuthenticationException; use Icinga\Forms\Authentication\LoginForm; -use Icinga\Web\Controller; +use Icinga\Forms\Authentication\TwoFactorChallengeForm; use Icinga\Web\Helper\CookieHelper; use Icinga\Web\RememberMe; +use Icinga\Web\Session; use Icinga\Web\Url; +use Icinga\Web\Widget\LoginPage; +use ipl\Html\HtmlDocument; +use ipl\Html\Contract\Form; +use ipl\Web\Compat\CompatController; use RuntimeException; use Throwable; /** * Application wide controller for authentication */ -class AuthenticationController extends Controller +class AuthenticationController extends CompatController { use Database; @@ -45,11 +52,40 @@ class AuthenticationController extends Controller */ public function loginAction() { + $twoFactorState = new TwoFactorState(); + if ($twoFactorState->isChallenged()) { + $this->redirectNow($this->withRedirect('authentication/twofactor')); + } + $icinga = Icinga::app(); 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_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(); + } + }); if (RememberMe::hasCookie() && $this->hasDb()) { $authenticated = false; @@ -84,11 +120,13 @@ public function loginAction() $this->httpBadRequest('nope'); } } else { - $redirectUrl = $form->getRedirectUrl(); + $redirectUrl = $form->createRedirectUrl(); } $this->redirectNow($redirectUrl); } + + $request = $this->getServerRequest(); if (! $requiresSetup) { $cookies = new CookieHelper($this->getRequest()); if (! $cookies->isSupported()) { @@ -99,11 +137,10 @@ public function loginAction() ->sendResponse(); exit; } - $form->handleRequest(); + $form->handleRequest($request); } $loginButtons = []; - $request = ServerRequest::fromGlobals(); foreach (LoginButtonHook::all() as $class => $hook) { try { @@ -125,11 +162,11 @@ public function loginAction() continue; } } + $this->setTitle($this->translate('Icinga Web 2 Login')); - $this->view->form = $form; - $this->view->loginButtons = $loginButtons; - $this->view->defaultTitle = $this->translate('Icinga Web 2 Login'); - $this->view->requiresSetup = $requiresSetup; + // Suppress the rendering of an empty tab bar + $this->controls = new HtmlDocument(); + $this->addContent(new LoginPage($form, $loginButtons, $requiresSetup)); } /** @@ -158,4 +195,46 @@ public function logoutAction() $this->redirectToLogin(); } } + + public function twofactorAction(): void + { + $twoFactorState = new TwoFactorState(); + if (! $twoFactorState->isChallenged()) { + $this->redirectToLogin(); + } + + $form = (new TwoFactorChallengeForm()) + ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->on(Form::ON_SUBMIT, function (TwoFactorChallengeForm $form) { + if ($redirectUrl = $form->getRedirectUrl()) { + $this->redirectNow($redirectUrl); + } + }) + ->on(Form::ON_SENT, function (TwoFactorChallengeForm $form) { + $isCsrfValid = $form->getElement('CSRFToken')->isValid(); + $isCancelPressed = + $form->getPressedSubmitElement()?->getName() === TwoFactorChallengeForm::SUBMIT_CANCEL; + + if ($isCsrfValid && $isCancelPressed) { + Session::getSession()->purge(); + $this->redirectNow($this->withRedirect('authentication/login')); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->setTitle($this->translate('Icinga Web 2 Two-Factor Auth')); + + // Suppress the rendering of an empty tab bar + $this->controls = new HtmlDocument(); + $this->addContent(new LoginPage($form)); + } + + protected function withRedirect(string $path): Url + { + $url = Url::fromPath($path); + if ($redirect = $this->params->get('redirect')) { + $url->setParam('redirect', $redirect); + } + return $url; + } } diff --git a/application/controllers/MyDevicesController.php b/application/controllers/MyDevicesController.php index dc01558d7d..afba644a77 100644 --- a/application/controllers/MyDevicesController.php +++ b/application/controllers/MyDevicesController.php @@ -5,6 +5,8 @@ namespace Icinga\Controllers; +use Icinga\Application\Hook; +use Icinga\Application\Hook\TwoFactorHook; use Icinga\Common\Database; use Icinga\Web\Notification; use Icinga\Web\RememberMe; @@ -47,6 +49,14 @@ public function init() 'url' => 'my-devices' ) )->activate('devices'); + + if (Hook::has(TwoFactorHook::NAME)) { + $this->getTabs()->add('two-factor', [ + 'title' => $this->translate('Configure two-factor authentication'), + 'label' => $this->translate('Two-Factor Auth'), + 'url' => 'two-factor/config', + ]); + } } public function indexAction() diff --git a/application/controllers/NavigationController.php b/application/controllers/NavigationController.php index 66b3b1d548..76f6e78b3d 100644 --- a/application/controllers/NavigationController.php +++ b/application/controllers/NavigationController.php @@ -7,6 +7,8 @@ use Exception; use Icinga\Application\Config; +use Icinga\Application\Hook; +use Icinga\Application\Hook\TwoFactorHook; use Icinga\Exception\NotFoundError; use Icinga\Data\DataArray\ArrayDatasource; use Icinga\Data\Filter\FilterMatchCaseInsensitive; @@ -156,6 +158,13 @@ public function indexAction() 'url' => 'my-devices' ) ); + if (Hook::has(TwoFactorHook::NAME)) { + $this->getTabs()->add('two-factor', [ + 'title' => $this->translate('Configure two-factor authentication'), + 'label' => $this->translate('Two-Factor Auth'), + 'url' => 'two-factor/config', + ]); + } $this->setupSortControl( array( 'type' => $this->translate('Type'), diff --git a/application/controllers/TwoFactorController.php b/application/controllers/TwoFactorController.php new file mode 100644 index 0000000000..c6df781acc --- /dev/null +++ b/application/controllers/TwoFactorController.php @@ -0,0 +1,60 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Controllers; + +use Icinga\Application\Hook\TwoFactorHook; +use Icinga\Forms\Account\TwoFactorEnrollmentForm; +use ipl\Html\Contract\Form; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\Web\Compat\CompatController; + +class TwoFactorController extends CompatController +{ + public function configAction(): void + { + $this->getTabs()->activate('two-factor'); + $this->addContent(HtmlElement::create('h1', null, Text::create('Two-Factor Authentication'))); + + $enrolledMethodName = TwoFactorHook::loadEnrolled()?->getName(); + + $chooseMethodForm = (new TwoFactorEnrollmentForm($enrolledMethodName !== null)) + ->populate([TwoFactorEnrollmentForm::METHOD => $enrolledMethodName]) + ->on(Form::ON_SUBMIT, function (TwoFactorEnrollmentForm $form) { + if ($redirectUrl = $form->getRedirectUrl()) { + $this->redirectNow($redirectUrl); + } + }) + ->handleRequest($this->getServerRequest()); + + $this->addContent($chooseMethodForm); + } + + public function init(): void + { + $this->getTabs() + ->add('account', [ + 'title' => $this->translate('Update your account'), + 'label' => $this->translate('My Account'), + 'url' => 'account' + ]) + ->add('navigation', [ + 'title' => $this->translate('List and configure your own navigation items'), + 'label' => $this->translate('Navigation'), + 'url' => 'navigation' + ]) + ->add('devices', [ + 'title' => $this->translate('List of devices you are logged in'), + 'label' => $this->translate('My Devices'), + 'url' => 'my-devices' + ]) + ->add('two-factor', [ + 'title' => $this->translate('Configure two-factor authentication'), + 'label' => $this->translate('Two-Factor Auth'), + 'url' => 'two-factor/config' + ]); + } +} diff --git a/application/forms/Account/TwoFactorEnrollmentForm.php b/application/forms/Account/TwoFactorEnrollmentForm.php new file mode 100644 index 0000000000..d989ed3639 --- /dev/null +++ b/application/forms/Account/TwoFactorEnrollmentForm.php @@ -0,0 +1,113 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Forms\Account; + +use Icinga\Application\Hook\TwoFactorHook; +use Icinga\Authentication\TwoFactor; +use Icinga\Web\Notification; +use Icinga\Web\Session; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Web\Common\CsrfCounterMeasure; +use ipl\Web\Common\FormUid; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; + +class TwoFactorEnrollmentForm extends CompatForm +{ + use CsrfCounterMeasure; + use FormUid; + + public const METHOD = 'twofactor_method'; + + /** @var string The submit button to enroll into a 2FA method */ + protected const SUBMIT_ENROLL = 'submit_twofactor_enroll'; + + /** @var string The submit button to unenroll from a 2FA method */ + protected const SUBMIT_UNENROLL = 'submit_twofactor_unenroll'; + + public function __construct( + /** @var bool Whether the user is already enrolled into a 2FA method */ + protected bool $isEnrolled + ) { + $this->setAttribute('name', 'form_twofactor_enrollment'); + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement($this->createUidElement()); + + $this->addElement('select', static::METHOD, [ + 'label' => '2FA Method', + 'class' => 'autosubmit', + 'disabled' => $this->isEnrolled, + 'ignored' => true, + 'options' => array_merge( + ['' => sprintf(' - %s - ', $this->translate('Please choose'))], + array_combine( + array_map(fn(TwoFactor $method) => $method->getName(), TwoFactorHook::all()), + array_map(fn(TwoFactor $method) => $method->getDisplayName(), TwoFactorHook::all()) + ) + ) + ]); + + if ($this->isEnrolled) { + $this->addElement('submit', static::SUBMIT_UNENROLL, [ + 'label' => $this->translate('Unenroll'), + 'data-progress-label' => $this->translate('Unenrolling') + ]); + + return; + } + + $twoFactor = TwoFactorHook::fromName($this->getPopulatedValue(static::METHOD) ?? ''); + + if (! $twoFactor) { + return; + } + + $configFieldset = new FieldsetElement($twoFactor->getName()); + $this->addElement($configFieldset); + $twoFactor->assembleEnrollmentFormElements($configFieldset); + + $this->addElement('submit', static::SUBMIT_ENROLL, [ + 'label' => $this->translate('Enroll'), + 'data-progress-label' => $this->translate('Enrolling') + ]); + } + + protected function onSuccess(): void + { + $twoFactor = TwoFactorHook::fromName($this->getValue(static::METHOD) ?? ''); + + switch ($this->getPressedSubmitElement()?->getName()) { + case static::SUBMIT_ENROLL: + /** @var FieldsetElement $configFieldset */ + $configFieldset = $this->getElement($twoFactor->getName()); + if (! $twoFactor->enroll($configFieldset)) { + Notification::error($this->translate('The verification failed. Please try again.')); + + // Don't redirect in this case, as the user might want to try again. + return; + } + Notification::success(sprintf( + $this->translate("Successfully enrolled in 2FA method '%s'."), + $twoFactor->getDisplayName() + )); + $this->setRedirectUrl(Url::fromRequest()); + + break; + case static::SUBMIT_UNENROLL: + $twoFactor->unenroll(); + Notification::success(sprintf( + $this->translate("Successfully unenrolled from 2FA method '%s'."), + $twoFactor->getDisplayName() + )); + $this->setRedirectUrl(Url::fromRequest()); + break; + } + } +} diff --git a/application/forms/Authentication/LoginForm.php b/application/forms/Authentication/LoginForm.php index 68dc9084ca..4fab6f3863 100644 --- a/application/forms/Authentication/LoginForm.php +++ b/application/forms/Authentication/LoginForm.php @@ -8,153 +8,142 @@ use Exception; use Icinga\Application\Config; 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\User\ExternalBackend; +use Icinga\Authentication\TwoFactorState; use Icinga\Common\Database; use Icinga\Exception\Http\HttpBadRequestException; use Icinga\User; -use Icinga\Web\Form; use Icinga\Web\RememberMe; +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']] - ]; - - /** - * {@inheritdoc} - */ - public function init() + 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} - */ - public function createElements(array $formData) + protected function assemble(): void { - $this->addElement( - 'text', - 'username', - array( - 'autocapitalize' => 'off', - 'autocomplete' => 'username', - 'class' => false === isset($formData['username']) ? 'autofocus' : '', - 'placeholder' => $this->translate('Username'), - 'required' => true - ) - ); - $this->addElement( - 'password', - 'password', - array( - 'required' => true, - 'autocomplete' => 'current-password', - 'placeholder' => $this->translate('Password'), - 'class' => isset($formData['username']) ? 'autofocus' : '' - ) - ); - $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']] + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement($this->createUidElement()); + + $this->addElement('text', 'username', [ + 'required' => true, + 'autocomplete' => 'username', + 'autocapitalize' => 'off', + 'class' => $this->getPopulatedValue('username') === null ? 'autofocus' : '', + 'placeholder' => $this->translate('Username'), + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'ControlGroup' => [ + 'name' => 'HtmlTag', + 'options' => ['tag' => 'div', 'class' => 'control-group'] ] ] - ); - 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( - 'hidden', - 'redirect', - array( - 'value' => Url::fromRequest()->getParam('redirect') - ) - ); - } - - /** - * {@inheritdoc} - */ - public function getRedirectUrl() - { - $redirect = null; - if ($this->created) { - $redirect = $this->getElement('redirect')->getValue(); - } - - if (empty($redirect) || strpos($redirect, 'authentication/logout') !== false) { - $redirect = static::REDIRECT_URL; - } + ]); + + $this->addElement('password', 'password', [ + 'required' => true, + 'autocomplete' => 'current-password', + 'class' => $this->getPopulatedValue('username') !== null ? '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'), + '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'] + ] + ] + ]); - $redirectUrl = Url::fromPath($redirect); - if ($redirectUrl->isExternal()) { - throw new HttpBadRequestException('nope'); - } + $this->addElement('submit', 'submit_login', [ + 'label' => $this->translate('Login'), + 'data-progress-label' => $this->translate('Logging in'), + ]); - return $redirectUrl; + $this->addElement('hidden', 'redirect', ['value' => Url::fromRequest()->getParam('redirect')]); } - /** - * {@inheritdoc} - */ - public function onSuccess() + protected function onSuccess(): void { $auth = Auth::getInstance(); $authChain = $auth->getAuthChain(); $authChain->setSkipExternalBackends(true); - $user = new User($this->getElement('username')->getValue()); + $username = $this->getElement('username')->getValue(); + $user = new User($username); + $twoFactorMethod = TwoFactorHook::loadEnrolled($user); + $user->setTwoFactorEnabled($twoFactorMethod !== null); 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()) { + $twoFactorState = new TwoFactorState(); + $twoFactorState->challenge($user); + Logger::info( + 'User "%s" has been challenged for two-factor verification using method "%s"', + $user->getUsername(), + $twoFactorMethod->getName() + ); + + if ($this->getElement('rememberme')->isChecked()) { + $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); + $twoFactorState->setRememberMeCookie($rememberMe); + } + + $redirectUrl = Url::fromPath('authentication/twofactor'); + if ($redirect = Url::fromRequest()->getParam('redirect')) { + $redirectUrl->setParam('redirect', $redirect); + } + $this->setRedirectUrl($redirectUrl); + + return; + } + $auth->setAuthenticated($user); + $response = Icinga::app()->getResponse(); if ($this->getElement('rememberme')->isChecked()) { try { $rememberMe = RememberMe::fromCredentials($user->getUsername(), $password); - $this->getResponse()->setCookie($rememberMe->getCookie()); + $response->setCookie($rememberMe->getCookie()); $rememberMe->persist(); } catch (Exception $e) { Logger::error('Failed to let user "%s" stay logged in: %s', $user->getUsername(), $e); @@ -163,54 +152,69 @@ public function onSuccess() // Call provided AuthenticationHook(s) after successful login AuthenticationHook::triggerLogin($user); - $this->getResponse()->setRerenderLayout(true); - return true; + + $response->setRerenderLayout(); + $this->setRedirectUrl($this->createRedirectUrl()); + + return; } switch ($authChain->getError()) { case $authChain::EEMPTY: - $this->addError($this->translate( + $this->addMessage($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( + $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->addError($this->translate( + $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 + // Move to default default: - $this->getElement('password')->addError($this->translate('Incorrect username or password')); - break; + $this->getElement('password')->addMessage($this->translate('Incorrect username or password')); } - return false; + + // Display the messages that were added to form or form elements + $this->onError(); + } + + // Expose protected method onError() to use it in event listener callbacks + public function onError(): void + { + parent::onError(); } /** - * {@inheritdoc} + * @return ?string + * + * @throws HttpBadRequestException */ - public function onRequest() + public function createRedirectUrl(): ?string { - $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; - } + $redirect = null; + if ($this->hasBeenAssembled) { + $redirect = $this->getElement('redirect')->getValue(); } - 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.' - )); + + if (empty($redirect) || str_contains($redirect, 'authentication/logout')) { + $redirect = static::REDIRECT_URL; } + + $redirectUrl = Url::fromPath($redirect); + if ($redirectUrl->isExternal()) { + throw new HttpBadRequestException('nope'); + } + + return $redirectUrl; } } diff --git a/application/forms/Authentication/TwoFactorChallengeForm.php b/application/forms/Authentication/TwoFactorChallengeForm.php new file mode 100644 index 0000000000..884205089a --- /dev/null +++ b/application/forms/Authentication/TwoFactorChallengeForm.php @@ -0,0 +1,150 @@ +setAttribute('name', 'form_twofactor_challenge'); + } + + protected function assemble(): void + { + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + $this->addElement($this->createUidElement()); + + $this->addElement('text', static::TOKEN, [ + 'required' => true, + 'class' => 'autofocus content-centered', + 'placeholder' => $this->translate('Please enter your 2FA token'), + 'autocomplete' => 'off', + 'decorators' => [ + 'RenderElement' => new RenderElementDecorator(), + 'Errors' => ['name' => 'Errors', 'options' => ['class' => 'errors']] + ] + ]); + + $this->addElement('submit', static::SUBMIT_VERIFY, [ + 'data-progress-label' => $this->translate('Verifying'), + 'label' => $this->translate('Verify') + ]); + + $this->addElement('submitButton', static::SUBMIT_CANCEL, [ + 'ignore' => true, + 'formnovalidate' => true, + 'class' => 'btn-back-to-login-link', + 'label' => [ + new Icon('arrow-left'), + HtmlElement::create('p', Attributes::create(), Text::create($this->translate('Back to login'))) + ] + ]); + + $this->addElement('hidden', 'redirect', ['value' => Url::fromRequest()->getParam('redirect')]); + } + + protected function onSuccess(): void + { + $twoFactorState = new TwoFactorState(); + $user = $twoFactorState->getChallengedUser(); + $twoFactorMethod = TwoFactorHook::loadEnrolled($user); +// TODO message is not shown | Is the check really needed? talk with eric +// if ($twoFactorMethod === null) { +// $this->addMessage($this->translate('No two-factor method is enabled')); +// +// return; +// } + if ($twoFactorMethod->verify($this->getValue(static::TOKEN))) { + Logger::info( + 'User "%s" passed two-factor verification using method "%s"', + $user->getUsername(), + $twoFactorMethod->getName() + ); + $user->setTwoFactorSuccessful(); + 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 ?string + * + * @throws HttpBadRequestException + */ + public function createRedirectUrl(): ?string + { + $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; + } +} diff --git a/application/views/scripts/authentication/login.phtml b/application/views/scripts/authentication/login.phtml deleted file mode 100644 index bf538d337d..0000000000 --- a/application/views/scripts/authentication/login.phtml +++ /dev/null @@ -1,63 +0,0 @@ -
- - -
-
img('img/orb-analytics.png'); ?>
-
img('img/orb-automation.png'); ?>
-
img('img/orb-cloud.png'); ?>
-
img('img/orb-icinga.png'); ?>
-
img('img/orb-infrastructure.png'); ?>
-
img('img/orb-metrics.png'); ?>
-
img('img/orb-notifications.png'); ?>
diff --git a/doc/05-Authentication.md b/doc/05-Authentication.md index 22772b5a1e..364959a38c 100644 --- a/doc/05-Authentication.md +++ b/doc/05-Authentication.md @@ -8,7 +8,7 @@ or if users are spread over multiple places. ## Configuration -Navigate into **Configuration > Application > Authentication **. +Navigate into **Configuration > Application > Access Control Backends**. Authentication methods are configured in the `/etc/icingaweb2/authentication.ini` file. @@ -291,3 +291,48 @@ asks that backend to authenticate the user with the sAMAccountName "jdoe". When the user "jdoe@icinga.com" logs in, Icinga Web 2 walks through all configured authentication backends until it finds one which is responsible for that user -- e.g. a MariaDB or MySQL backend (SQL database backends aren't domain-aware). Then Icinga Web 2 asks that backend to authenticate the user with the username "jdoe@icinga.com". + +## Two-Factor Authentication + +You can secure your user accounts by two-factor authentication (2FA) using time-based one-time passwords (TOTP). If you +set up an authenticator app it will generate a 6 digit one-time token every 30 seconds based on the shared secret and +the current time. + +### Enabling 2FA + +Enable 2FA in your account settings. Scan the QR code with your authenticator app or enter the secret manually. Make +sure to store the QR code or secret on a different device on which the authenticator app is installed. If you lose +access to the authenticator you can set up a new one without an administrator disabling the old one via the CLI. Then +enter the token from your authenticator app to verify that it has the correct secret. + +If you log in now with username and password, you will be prompted to "enter your 2FA token". This is the token +generated by the authenticator app. If the token is valid, you will be logged in and redirected. + +### Disabling 2FA + +Disable 2FA by simply clicking the "Disable 2FA" button in your account settings. This will remove the stored secret +completely. + +### Replacing 2FA + +If you need to replace your 2FA, e.g. because your secret has been compromised, simply disable it and reenable it in +your account settings. You will need to verify the new secret. Don't forget to store the QR code or the secret for +recovery. + +### CLI Commands + +The following CLI commands are available: + +```bash +icingacli twofactor list +``` + +List all users with 2FA enabled and when they enabled it. + +```bash +icingacli twofactor disable [] +``` + +Disable 2FA for a specific user. Do this if a user has lost access to the device on which the authenticator app is +installed and hasn't saved the secret to a different device. Make sure to disable it for the correct user, as the +currently stored secret will be removed completely. diff --git a/library/Icinga/Application/Hook/TwoFactorHook.php b/library/Icinga/Application/Hook/TwoFactorHook.php new file mode 100644 index 0000000000..2fb27cf053 --- /dev/null +++ b/library/Icinga/Application/Hook/TwoFactorHook.php @@ -0,0 +1,121 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Application\Hook; + +use Icinga\Application\Hook; +use Icinga\Authentication\Auth; +use Icinga\Authentication\TwoFactor; +use Icinga\User; + +abstract class TwoFactorHook implements TwoFactor +{ + public const NAME = 'TwoFactor'; + + /** @var ?User The user this instance was loaded for, set by {@link loadEnrolled()} */ + protected ?User $user = null; + + /** + * Set the user this instance is acting for + * + * Called automatically by {@link loadEnrolled()}. Implementations can read $this->user + * in methods like verify() where no user parameter is available. + * + * @param ?User $user + */ + public function setUser(?User $user): void + { + $this->user = $user; + } + + /** + * Register the class as a two-factor hook implementation + * + * Call this on your implementation during module initialization to make Icinga Web aware of your hook. + */ + public static function register(): void + { + Hook::register(static::NAME, static::class, static::class, true); + } + + /** + * Get all registered implementations sorted alphabetically by their name + * + * @return TwoFactor[] + */ + public static function all(): array + { + $twoFactorMethods = []; + foreach (Hook::all(static::NAME) as $method) { + $twoFactorMethods[$method->getName()] = $method; + } + ksort($twoFactorMethods, SORT_STRING | SORT_FLAG_CASE); + + return array_values($twoFactorMethods); + } + + /** + * Get the alphabetically first implementation, or null if none are registered + * + * @return ?TwoFactor + */ + public static function first(): ?TwoFactor + { + return static::all()[0] ?? null; + } + + /** + * Get the implementation with the given name, or null if no such implementation is registered + * + * @param string $name The value returned by {@link TwoFactor::getName()} of the desired implementation + * + * @return ?TwoFactor + */ + public static function fromName(string $name): ?TwoFactor + { + foreach (static::all() as $method) { + if ($method->getName() === $name) { + return $method; + } + } + + return null; + } + + /** + * Get the implementation a user is enrolled in, or null if they are not enrolled + * + * If $user is null, the currently authenticated user is used. + * + * @param ?User $user The user to check for + * + * @return ?TwoFactor + */ + public static function loadEnrolled(?User $user = null): ?TwoFactor + { + foreach (static::all() as $method) { + if ($method->isEnrolled($user)) { + $method->setUser($user); + + return $method; + } + } + + return null; + } + + /** + * Get the username for the user this instance is acting for + * + * Returns the username from {@link $user} when set (login/verification flow), or falls back + * to the session's authenticated user (enrollment flow). Returns null if neither is available. + * + * @return ?string + */ + protected function getUsername(): ?string + { + return $this->user?->getUsername() ?? Auth::getInstance()->getUser()?->getUsername(); + } +} diff --git a/library/Icinga/Authentication/Auth.php b/library/Icinga/Authentication/Auth.php index 50339988cb..e584693173 100644 --- a/library/Icinga/Authentication/Auth.php +++ b/library/Icinga/Authentication/Auth.php @@ -9,10 +9,12 @@ use Icinga\Application\Config; use Icinga\Application\Hook\AuditHook; use Icinga\Application\Hook\AuthenticationHook; +use Icinga\Application\Hook\TwoFactorHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\User\ExternalBackend; use Icinga\Authentication\UserGroup\UserGroupBackend; +use Icinga\Common\Database; use Icinga\Data\ConfigObject; use Icinga\Exception\IcingaException; use Icinga\Exception\NotReadableError; @@ -24,6 +26,8 @@ class Auth { + use Database; + /** * Singleton instance * @@ -91,12 +95,19 @@ public function getAuthChain() public function isAuthenticated() { if ($this->user !== null) { + if ($this->user->getTwoFactorEnabled() && ! $this->user->getTwoFactorSuccessful()) { + return false; + } return true; } $this->authenticateFromSession(); if ($this->user === null && ! $this->authExternal()) { return false; } + + // 2fa check from must happen here, to apply the 2fa challenge for external users as well + // but the session authentication would also get the 2fa challenge + return true; } @@ -132,7 +143,10 @@ public function setAuthenticated(User $user, $persist = true) $this->persistCurrentUser(); } - AuditHook::logActivity('login', 'User logged in'); + // Don't log if 2FA is enabled and hasn't been successful yet + if (! $user->getTwoFactorEnabled() || $user->getTwoFactorSuccessful()) { + AuditHook::logActivity('login', 'User logged in'); + } } /** @@ -293,6 +307,16 @@ public function authHttp() } $password = $credentials[1]; if ($this->getAuthChain()->setSkipExternalBackends(true)->authenticate($user, $password)) { + if (TwoFactorHook::loadEnrolled($user) !== null) { + Logger::warning( + 'API request rejected for user "%s": two-factor authentication cannot be completed via the API', + $user->getUsername() + ); + $this->getResponse()->json() + ->setHttpResponseCode(403) + ->setErrorMessage('Two-factor authentication is required and cannot be completed via the API') + ->sendResponse(); + } $this->setAuthenticated($user, false); $user->setIsHttpUser(true); return true; diff --git a/library/Icinga/Authentication/PsrClock.php b/library/Icinga/Authentication/PsrClock.php new file mode 100644 index 0000000000..7863e69d5f --- /dev/null +++ b/library/Icinga/Authentication/PsrClock.php @@ -0,0 +1,16 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Authentication; + +use Icinga\Exception\ConfigurationError; +use Icinga\Forms\Account\TwoFactorEnrollmentForm; +use Icinga\User; +use ipl\Html\FormElement\FieldsetElement; + +interface TwoFactor +{ + /** + * Get the unique machine-readable identifier for this 2FA method + * + * Used as a stable key to look up the implementation by name, e.g. via {@link TwoFactorHook::fromName()}. + * + * @return string A lowercase, identifier, e.g. 'totp' + */ + public function getName(): string; + + /** + * Get the human-readable name for this 2FA method shown in the UI + * + * @return string E.g. 'TOTP' + */ + public function getDisplayName(): string; + + /** + * Get whether a user is enrolled in this 2FA method + * + * If $user is null, the currently authenticated user will be used. Implementations typically + * query the database for a stored credential (secret, key, ...). Returns false if the lookup + * fails, so callers can treat an unavailable backend the same as "not yet enrolled". + * + * @param ?User $user The user to check for + * + * @return bool + */ + public function isEnrolled(?User $user = null): bool; + + /** + * Verify a 2FA token provided by the user + * + * Called both during login (to gate access) and during enrollment (to confirm + * the credential works before {@link enroll()} is called). + * + * @param string $token The raw token string entered by the user, e.g. a 6-digit TOTP code + * + * @return bool true if the token is valid for the current user, false otherwise + */ + public function verify(string $token): bool; + + /** + * Verify the submitted credential and persist it for the currently authenticated user + * + * Called from {@link TwoFactorEnrollmentForm::onSuccess()} when the enroll button is + * pressed. Read the method-specific values from $fieldset, verify that the credential + * works, and store it on success. + * + * @param FieldsetElement $fieldset The method-specific fieldset containing the submitted + * credential elements + * + * @return bool true if the credential was verified and stored, false if verification failed + * + * @throws ConfigurationError If the credential cannot be persisted + */ + public function enroll(FieldsetElement $fieldset): bool; + + /** + * Remove the stored credential for the currently authenticated user + * + * Called from {@link TwoFactorEnrollmentForm::onSuccess()} when the unenroll button is + * pressed. After this call {@link isEnrolled()} will return false for the same user. + * + * @throws ConfigurationError If the credential cannot be removed + */ + public function unenroll(): void; + + /** + * Add the method-specific fieldset to the enrollment form + * + * Called from {@link TwoFactorEnrollmentForm::assemble()}. + * + * @param FieldsetElement $fieldset The method-specific fieldset to add elements to + */ + public function assembleEnrollmentFormElements(FieldsetElement $fieldset): void; +} diff --git a/library/Icinga/Authentication/TwoFactorState.php b/library/Icinga/Authentication/TwoFactorState.php new file mode 100644 index 0000000000..743d0cc72b --- /dev/null +++ b/library/Icinga/Authentication/TwoFactorState.php @@ -0,0 +1,55 @@ +session = Session::getSession()->getNamespace(static::SESSION_NAMESPACE); + } + + public function challenge(User $user): void + { + $this->session->set(static::SESSION_CHALLENGED_USER, $user); + } + + public function completeChallenge(): void + { + $this->session->delete(static::SESSION_CHALLENGED_USER); + $this->session->delete(static::SESSION_REMEMBER_ME_COOKIE); + } + + public function isChallenged(): bool + { + return $this->getChallengedUser() !== null; + } + + public function setRememberMeCookie(RememberMe $cookie): void + { + $this->session->set(static::SESSION_REMEMBER_ME_COOKIE, $cookie); + } + + public function getRememberMeCookie(): ?RememberMe + { + return $this->session->get(static::SESSION_REMEMBER_ME_COOKIE); + } + + public function getChallengedUser(): ?User + { + return $this->session->get(static::SESSION_CHALLENGED_USER); + } +} diff --git a/library/Icinga/User.php b/library/Icinga/User.php index 56a0ce453a..ec726b6071 100644 --- a/library/Icinga/User.php +++ b/library/Icinga/User.php @@ -6,13 +6,13 @@ namespace Icinga; use DateTimeZone; -use Icinga\Authentication\AdmissionLoader; -use InvalidArgumentException; use Icinga\Application\Config; +use Icinga\Authentication\AdmissionLoader; use Icinga\Authentication\Role; use Icinga\Exception\ProgrammingError; use Icinga\User\Preferences; use Icinga\Web\Navigation\Navigation; +use InvalidArgumentException; /** * This class represents an authorized user @@ -124,6 +124,20 @@ class User */ protected $isHttpUser = false; + /** + * Whether the user has 2FA enabled (secret is stored in the database) + * + * @var bool + */ + protected bool $twoFactorEnabled = false; + + /** + * Whether the user has successfully completed 2FA + * + * @var bool + */ + protected bool $twoFactorSuccessful = false; + /** * Creates a user object given the provided information * @@ -648,4 +662,52 @@ public function getNavigation($type) return $navigation; } + + /** + * Get whether the user has 2FA enabled + * + * @return bool + */ + public function getTwoFactorEnabled(): bool + { + return $this->twoFactorEnabled; + } + + /** + * Set whether the user has 2FA enabled + * + * @param bool $enabled + * + * @return $this + */ + public function setTwoFactorEnabled(bool $enabled = true): static + { + $this->twoFactorEnabled = $enabled; + + return $this; + } + + /** + * Get whether the user has successfully completed 2FA + * + * @return bool + */ + public function getTwoFactorSuccessful(): bool + { + return $this->twoFactorSuccessful; + } + + /** + * Set whether the user has successfully completed 2FA + * + * @param bool $successful + * + * @return $this + */ + public function setTwoFactorSuccessful(bool $successful = true): static + { + $this->twoFactorSuccessful = $successful; + + return $this; + } } diff --git a/library/Icinga/User/Preferences/PreferencesStore.php b/library/Icinga/User/Preferences/PreferencesStore.php index 19e7ac5934..650fc32c42 100644 --- a/library/Icinga/User/Preferences/PreferencesStore.php +++ b/library/Icinga/User/Preferences/PreferencesStore.php @@ -284,8 +284,9 @@ protected function update(array $preferences, string $section): void } } catch (Exception $e) { throw new NotWritableError( - 'Cannot update preferences for user %s in database', + 'Cannot update preferences for user %s in database: %s', $this->getUser()->getUsername(), + $e->getMessage(), $e ); } diff --git a/library/Icinga/Web/RememberMe.php b/library/Icinga/Web/RememberMe.php index a7eb3bcdb8..f955e02135 100644 --- a/library/Icinga/Web/RememberMe.php +++ b/library/Icinga/Web/RememberMe.php @@ -6,9 +6,10 @@ namespace Icinga\Web; use Icinga\Application\Config; +use Icinga\Application\Hook\TwoFactorHook; use Icinga\Authentication\Auth; -use Icinga\Crypt\AesCrypt; use Icinga\Common\Database; +use Icinga\Crypt\AesCrypt; use Icinga\User; use ipl\Sql\Expression; use ipl\Sql\Select; @@ -238,6 +239,7 @@ public function authenticate() $authChain = $auth->getAuthChain(); $authChain->setSkipExternalBackends(true); $user = new User($this->username); + $user->setTwoFactorEnabled(TwoFactorHook::loadEnrolled($user) !== null); if (! $user->hasDomain()) { $user->setDomain(Config::app()->get('authentication', 'default_domain')); } @@ -248,6 +250,7 @@ public function authenticate() ); if ($authenticated) { + $user->setTwoFactorSuccessful(); $auth->setAuthenticated($user); } diff --git a/library/Icinga/Web/Widget/LoginPage.php b/library/Icinga/Web/Widget/LoginPage.php new file mode 100644 index 0000000000..cc98c55dce --- /dev/null +++ b/library/Icinga/Web/Widget/LoginPage.php @@ -0,0 +1,245 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Web\Widget; + +use Icinga\Authentication\LoginButtonForm; +use ipl\Html\Attributes; +use ipl\Html\Html; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Web\Compat\CompatForm; +use ipl\Web\Url; + +/** + * Login page — logo, footer, social links, and decorative orbs + * + * Wraps any form content in the standard login page structure. Used for + * both the login form and the 2FA challenge form so the visual chrome is + * defined in one place. + * + * Extends `HtmlDocument` rather than `BaseHtmlElement` because the login + * page emits `#login` and seven `.orb` sibling divs at the top level — + * there is no single root tag. + */ +class LoginPage extends HtmlDocument +{ + use Translation; + + protected CompatForm $form; + + /** @var LoginButtonForm[] */ + protected array $loginButtons; + + /** @var bool Whether to show the setup-wizard configuration note */ + protected bool $requiresSetup; + + /** + * @param CompatForm $form Primary form to render in the centre of the login box + * @param LoginButtonForm[] $loginButtons Additional login button forms to render below the primary form + * @param bool $requiresSetup When true, show the setup-wizard config note above the form + */ + public function __construct(CompatForm $form, array $loginButtons = [], bool $requiresSetup = false) + { + $this->form = $form; + $this->loginButtons = $loginButtons; + $this->requiresSetup = $requiresSetup; + + $login = HtmlElement::create( + 'div', + Attributes::create(['id' => 'login']), + [$this->assembleLoginForm(), $this->assembleSocialLinks()] + ); + + $this->addHtml($login); + + foreach ($this->assembleOrbs() as $orb) { + $this->addHtml($orb); + } + } + + /** + * Assemble the centred login box containing the logo, form content, and footer + * + * @return HtmlElement + */ + protected function assembleLoginForm(): HtmlElement + { + $accessibilityNotice = HtmlElement::create( + 'div', + Attributes::create(['role' => 'status', 'class' => 'sr-only']), + Text::create($this->translate( + 'Welcome to Icinga Web 2. For users of the screen reader Jaws full and expectant compliant' + . ' accessibility is possible only with use of the Firefox browser. VoiceOver on Mac OS X is' + . ' tested on Chrome, Safari and Firefox.' + )) + ); + + $logo = HtmlElement::create( + 'div', + Attributes::create(['class' => 'logo-wrapper']), + HtmlElement::create('div', Attributes::create(['id' => 'icinga-logo', 'aria-hidden' => 'true'])) + ); + + $inner = HtmlElement::create( + 'div', + Attributes::create(['class' => 'login-form', 'data-base-target' => 'layout']) + ); + + $inner->addHtml($accessibilityNotice, $logo); + + if ($this->requiresSetup) { + $inner->addHtml($this->assembleSetupNote()); + } + + $inner->addHtml($this->form); + + foreach ($this->loginButtons as $button) { + $inner->addHtml($button); + } + + $inner->addHtml($this->assembleFooter()); + + return $inner; + } + + /** + * Assemble the setup-wizard configuration note shown when no authentication method is configured + * + * @return HtmlElement + */ + protected function assembleSetupNote(): HtmlElement + { + $setupNote = $this->translate( + 'It appears that you did not configure Icinga Web 2 yet so it\'s not possible to log in' + . ' without any defined authentication method. Please define an authentication method by' + . ' following the instructions in the %s or by using our %s.', + ' or by using our ' + ); + + $docLink = HtmlElement::create( + 'a', + Attributes::create([ + 'href' => 'https://icinga.com/docs/icinga-web-2/latest/doc/05-Authentication/#authentication', + 'title' => $this->translate('Icinga Web 2 Documentation') + ]), + Text::create($this->translate('documentation')) + ); + + $setupLink = HtmlElement::create( + 'a', + Attributes::create([ + 'href' => Url::fromPath('setup')->getAbsoluteUrl(), + 'title' => $this->translate('Icinga Web 2 Setup-Wizard') + ]), + Text::create($this->translate('web-based setup-wizard')) + ); + + return HtmlElement::create( + 'p', + Attributes::create(['class' => 'config-note']), + Html::sprintf($setupNote, $docLink, $setupLink) + ); + } + + /** + * Assemble the footer containing the copyright notice and the icinga.com link + * + * @return HtmlElement + */ + protected function assembleFooter(): HtmlElement + { + $copyright = HtmlElement::create('p', null, Text::create('Icinga Web 2 © 2013-' . date('Y'))); + + $icingaLink = HtmlElement::create( + 'a', + Attributes::create(['href' => 'https://icinga.com']), + Text::create('icinga.com') + ); + + return HtmlElement::create('div', Attributes::create(['id' => 'login-footer']), [$copyright, $icingaLink]); + } + + /** + * Assemble the social links list rendered in the bottom-right corner of the page + * + * @return HtmlElement + */ + protected function assembleSocialLinks(): HtmlElement + { + $facebook = HtmlElement::create( + 'li', + null, + HtmlElement::create( + 'a', + Attributes::create([ + 'href' => 'https://www.facebook.com/icinga', + 'target' => '_blank', + 'title' => $this->translate('Icinga on Facebook'), + 'aria-label' => $this->translate('Icinga on Facebook') + ]), + HtmlElement::create('i', Attributes::create([ + 'class' => 'icon-facebook-squared', + 'aria-hidden' => 'true' + ])) + ) + ); + + $github = HtmlElement::create( + 'li', + null, + HtmlElement::create( + 'a', + Attributes::create([ + 'href' => 'https://github.com/Icinga', + 'target' => '_blank', + 'title' => $this->translate('Icinga on GitHub'), + 'aria-label' => $this->translate('Icinga on GitHub'), + ]), + HtmlElement::create('i', Attributes::create([ + 'class' => 'icon-github-circled', + 'aria-hidden' => 'true' + ])) + ) + ); + + return HtmlElement::create('ul', Attributes::create(['id' => 'social']), [$facebook, $github]); + } + + /** + * Assemble the decorative orb elements positioned around the background + * + * @return HtmlElement[] + */ + protected function assembleOrbs(): array + { + $orbs = [ + 'orb-analytics' => 'orb-analytics.png', + 'orb-automation' => 'orb-automation.png', + 'orb-cloud' => 'orb-cloud.png', + 'orb-icinga' => 'orb-icinga.png', + 'orb-infrastructure' => 'orb-infrastructure.png', + 'orb-metrics' => 'orb-metrics.png', + 'orb-notifications' => 'orb-notifications.png' + ]; + + $elements = []; + foreach ($orbs as $id => $file) { + $elements[] = HtmlElement::create( + 'div', + Attributes::create(['id' => $id, 'class' => 'orb']), + HtmlElement::create('img', Attributes::create([ + 'src' => Url::fromPath('img/' . $file)->getAbsoluteUrl(), + 'alt' => '', + 'aria-hidden' => 'true' + ])) + ); + } + + return $elements; + } +} diff --git a/library/Icinga/Web/Widget/TwoFactorWarning.php b/library/Icinga/Web/Widget/TwoFactorWarning.php new file mode 100644 index 0000000000..b6e19dec81 --- /dev/null +++ b/library/Icinga/Web/Widget/TwoFactorWarning.php @@ -0,0 +1,41 @@ + 'two-factor-warning']; + + /** + * @param string $timezone The schedule timezone + */ + public function __construct() + { + } + + public function assemble(): void + { + $this->addHtml(new Icon('warning')); + $this->addHtml(new HtmlElement( + 'p', + null, + new Text('Make sure to save the QR code or the secret for recovery purposes!') + )); + } +} diff --git a/public/css/icinga/forms.less b/public/css/icinga/forms.less index b85cc22ff0..60f1c25c4a 100644 --- a/public/css/icinga/forms.less +++ b/public/css/icinga/forms.less @@ -187,7 +187,8 @@ form.icinga-form { input[type="file"], .control-group > fieldset, textarea, - select { + select, + .control-group > .fake-form-element { flex: 1 1 auto; width: 0; } diff --git a/public/css/icinga/login-orbs.less b/public/css/icinga/login-orbs.less index ac5fec739b..7a71ad26a0 100644 --- a/public/css/icinga/login-orbs.less +++ b/public/css/icinga/login-orbs.less @@ -59,14 +59,14 @@ opacity: .4; } -#orb-notifactions { +#orb-notifications { top: 7%; right: 46%; width: 10%; margin: -5%; } -#orb-notifactions img { +#orb-notifications img { opacity: .5; } diff --git a/public/css/icinga/login.less b/public/css/icinga/login.less index 702a2050a9..8b4ba02060 100644 --- a/public/css/icinga/login.less +++ b/public/css/icinga/login.less @@ -4,7 +4,7 @@ // Login page styles #login { - height: 100%; + height: 100vh; background-color: @menu-bg-color; background-image: url(../img/icingaweb2-background-orbs.jpg); background-repeat: no-repeat; @@ -21,6 +21,14 @@ padding: 2em 6em; background-color: @login-box-background; .box-shadow(0, 0, 1em, 1em, @login-box-background); + + .icinga-form { + width: unset; + + .control-group { + margin: 1em 0; + } + } } #icinga-logo { @@ -40,12 +48,21 @@ .errors, .form-errors { list-style-type: none; - padding: 0.5em; } .errors { - background-color: @color-critical; - color: white; + margin: 0; + + li { + background-color: @color-critical; + color: white; + margin: 1em 0 1em 0; + padding: .5em; + + &:last-child { + margin-bottom: 0; + } + } } .form-errors { @@ -92,6 +109,26 @@ &:hover { background-color: @icinga-secondary-dark; } + + &.btn-back-to-login-link { + width: fit-content; + height: fit-content; + margin: 0 auto; + padding: 0; + background: none; + + p { + margin: 0; + } + + &:hover { + opacity: 0.8; + + p { + text-decoration: underline; + } + } + } } .config-note { @@ -109,7 +146,7 @@ .remember-me-box { display: flex; - align-items: flex-start; + align-items: center; .toggle-switch { margin-right: 1em; diff --git a/public/js/icinga/history.js b/public/js/icinga/history.js index 952873a0e6..0b45118d93 100644 --- a/public/js/icinga/history.js +++ b/public/js/icinga/history.js @@ -230,7 +230,7 @@ applyLocationBar: function (onload = false) { let col2State = this.getCol2State(); - if (onload && document.querySelector('#layout > #login')) { + if (onload && document.querySelector('#layout #login')) { // The user landed on the login let redirectInput = document.querySelector('#login form input[name=redirect]'); redirectInput.value = redirectInput.value + col2State;