icingaweb2/application/controllers/AuthenticationController.php
Johannes Rauh 215d2ec108 Avoid storing full remember-me cookie in session during 2FA challenge
Previously the `RememberMe` object (containing the AES-encrypted password and
the decryption key) was serialized directly into the PHP session while waiting
for the user to complete the 2FA challenge. Because PHP sessions are written
to disk in plaintext, this exposed the key and ciphertext in the same place,
sufficient to recover the user's password.

Fix by splitting the secret across the session and the database, mirroring the
design of the normal (non-2FA) remember-me flow:

- Call `persist()` at login time so the AES key goes to the database
  immediately, never touching the session.
- Store only the cookie value string (ciphertext + IV) in the session.
  The ciphertext is not exploitable without the key.
- After a successful 2FA challenge, reconstruct the `RememberMe` object via a
  new `RememberMe::fromCookieData()` factory that does a DB lookup by IV and
  restores the canonical expiry from the database row.
- Only then send the browser cookie, so the cookie never reaches the browser
  unless the second factor was verified.

Canceled challenges remove the created DB row, while abandoned challenges
leave an orphaned DB row which is cleaned up by the existing
`RememberMe::removeExpired()` mechanism.

`RememberMe::fromCookieData()` sets `$expiresAt` from the database row so
the browser cookie issued after 2FA inherits the expiry stored at login time
rather than receiving a fresh 30-day window computed at challenge-completion
time. The renewal path in `AuthenticationController::loginAction()` is
unaffected, because `renew()` constructs a new object via `fromCredentials()`.
2026-05-27 10:47:21 +02:00

231 lines
8.4 KiB
PHP

<?php
// SPDX-FileCopyrightText: 2018 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later
namespace Icinga\Controllers;
use Icinga\Application\ClassLoader;
use Icinga\Application\Hook\AuthenticationHook;
use Icinga\Application\Hook\LoginButtonHook;
use Icinga\Application\Icinga;
use Icinga\Application\Logger;
use Icinga\Authentication\LoginButton;
use Icinga\Authentication\LoginButtonForm;
use Icinga\Authentication\TwoFactorState;
use Icinga\Common\Database;
use Icinga\Exception\AuthenticationException;
use Icinga\Forms\Authentication\LoginForm;
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\Contract\Form;
use ipl\Web\Compat\CompatController;
use Psr\Http\Message\ServerRequestInterface;
use RuntimeException;
use Throwable;
/**
* Application wide controller for authentication
*/
class AuthenticationController extends CompatController
{
use Database;
/**
* {@inheritdoc}
*/
protected $requiresAuthentication = false;
/**
* {@inheritdoc}
*/
protected $innerLayout = 'inline';
/**
* Log into the application
*/
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())
->setAction(Url::fromRequest()->getAbsoluteUrl())
->on(Form::ON_SUBMIT, function (LoginForm $form) {
if ($redirectUrl = $form->getRedirectUrl()) {
$this->redirectNow($redirectUrl);
}
})
->on(Form::ON_REQUEST, function (ServerRequestInterface $_, LoginForm $form) {
$form->onRequest();
});
if (RememberMe::hasCookie() && $this->hasDb()) {
$authenticated = false;
try {
$rememberMeOld = RememberMe::fromCookie();
$authenticated = $rememberMeOld->authenticate();
if ($authenticated) {
$rememberMe = $rememberMeOld->renew();
$this->getResponse()->setCookie($rememberMe->getCookie());
$rememberMe->persist($rememberMeOld->getAesCrypt()->getIV());
}
} catch (RuntimeException $e) {
Logger::error("Can't authenticate user via remember me cookie: %s", $e->getMessage());
} catch (AuthenticationException $e) {
Logger::error($e);
}
if (! $authenticated) {
$this->getResponse()->setCookie(RememberMe::forget());
}
}
if ($this->Auth()->isAuthenticated()) {
// Call provided AuthenticationHook(s) when login action is called
// but icinga web user is already authenticated
AuthenticationHook::triggerLogin($this->Auth()->getUser());
$redirect = $this->params->get('redirect');
if ($redirect) {
$redirectUrl = Url::fromPath($redirect, [], $this->getRequest());
if ($redirectUrl->isExternal()) {
$this->httpBadRequest('Redirect to an external host is not allowed');
}
} else {
$redirectUrl = Url::fromPath(LoginForm::REDIRECT_URL);
}
$this->redirectNow($redirectUrl);
}
$request = $this->getServerRequest();
if (! $requiresSetup) {
$cookies = new CookieHelper($this->getRequest());
if (! $cookies->isSupported()) {
$this
->getResponse()
->setBody("Cookies must be enabled to run this application.\n")
->setHttpResponseCode(403)
->sendResponse();
exit;
}
$form->handleRequest($request);
}
$loginButtons = [];
foreach (LoginButtonHook::all() as $class => $hook) {
try {
foreach ($hook->getButtons() as $index => $button) {
assert($button instanceof LoginButton);
$loginButtons[] = (new LoginButtonForm(
sha1("$class!$index"),
$button,
ClassLoader::classBelongsToModule($class) ? ClassLoader::extractModuleName($class) : null
))
->on(Form::ON_SUBMIT, function () use ($button): void {
($button->onClick)();
})
->handleRequest($request);
}
} catch (Throwable $e) {
Logger::error('Failed to execute login button hook: %s', $e);
continue;
}
}
// Suppress the rendering of controls bar
$this->view->compact = true;
$this->setTitle($this->translate('Icinga Web 2 Login'));
$this->addContent(new LoginPage($form, $loginButtons, $requiresSetup));
}
/**
* Log out the current user
*/
public function logoutAction()
{
$auth = $this->Auth();
if (! $auth->isAuthenticated()) {
$this->redirectToLogin();
}
// Get info whether the user is externally authenticated before removing authorization which destroys the
// session and the user object
$isExternalUser = $auth->getUser()->isExternalUser();
// Call provided AuthenticationHook(s) when logout action is called
AuthenticationHook::triggerLogout($auth->getUser());
$auth->removeAuthorization();
if ($isExternalUser) {
$this->view->layout()->setLayout('external-logout');
$this->getResponse()->setHttpResponseCode(401);
} else {
if (RememberMe::hasCookie() && $this->hasDb()) {
$this->getResponse()->setCookie(RememberMe::forget());
}
$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) {
// ON_SENT because cancel is not the primary submit button and never triggers
// ON_SUBMIT. CSRF is checked manually. Without it a forged request could
// destroy the session and drop the 2FA challenge.
$csrfValid = $form->getElement('CSRFToken')->isValid();
$cancelPressed =
$form->getPressedSubmitElement()?->getName() === TwoFactorChallengeForm::SUBMIT_CANCEL;
if ($csrfValid && $cancelPressed) {
// The login flow may have persisted a remember-me record before issuing the
// challenge. Remove it now since the challenge was canceled and the cookie
// was never delivered to the browser.
RememberMe::removeAllByUsername((new TwoFactorState())->getChallengedUser()->getUsername());
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 controls bar
$this->view->compact = true;
$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;
}
}