icingaweb2/library/Icinga/Authentication/TwoFactorState.php

104 lines
2.7 KiB
PHP
Raw Permalink Normal View History

<?php
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later
namespace Icinga\Authentication;
use Icinga\User;
use Icinga\Web\Session;
use Icinga\Web\Session\SessionNamespace;
/**
* In-session state for a pending two-factor authentication challenge
*/
class TwoFactorState
{
/** @var string Session namespace name to isolate all 2FA state */
protected const SESSION_NAMESPACE = 'twofactor';
/** @var string Session key storing the challenged User */
protected const SESSION_CHALLENGED_USER = 'challenged_user';
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-08 04:49:16 -04:00
/** @var string Session key storing the raw remember-me cookie */
protected const SESSION_REMEMBER_ME_COOKIE_DATA = 'remember_me_cookie_data';
/** @var SessionNamespace Session namespace scoping the challenge state */
protected SessionNamespace $session;
/**
* Create a new TwoFactorState
*/
public function __construct()
{
$this->session = Session::getSession()->getNamespace(static::SESSION_NAMESPACE);
}
/**
* Store the user for whom two-factor verification was challenged
*
* @param User $user
*
* @return void
*/
public function challenge(User $user): void
{
$this->session->set(static::SESSION_CHALLENGED_USER, $user);
}
/**
* Clear all challenge state from the session
*
* Call after successful verification.
*
* @return void
*/
public function completeChallenge(): void
{
$this->session->delete(static::SESSION_CHALLENGED_USER);
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-08 04:49:16 -04:00
$this->session->delete(static::SESSION_REMEMBER_ME_COOKIE_DATA);
}
/**
* Check whether a two-factor challenge is pending
*
* @return bool
*/
public function isChallenged(): bool
{
return $this->getChallengedUser() !== null;
}
/**
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-08 04:49:16 -04:00
* Set the remember-me cookie value to issue after a successful challenge
*
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-08 04:49:16 -04:00
* @param string $cookieData
*
* @return void
*/
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-08 04:49:16 -04:00
public function setRememberMeCookieData(string $cookieData): void
{
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-08 04:49:16 -04:00
$this->session->set(static::SESSION_REMEMBER_ME_COOKIE_DATA, $cookieData);
}
/**
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-08 04:49:16 -04:00
* Get the stored remember-me cookie value
*
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-08 04:49:16 -04:00
* @return ?string null if no value was set
*/
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-08 04:49:16 -04:00
public function getRememberMeCookieData(): ?string
{
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-08 04:49:16 -04:00
return $this->session->get(static::SESSION_REMEMBER_ME_COOKIE_DATA);
}
/**
* Get the user for whom two-factor verification was challenged
*
* @return ?User null if no challenge is active
*/
public function getChallengedUser(): ?User
{
return $this->session->get(static::SESSION_CHALLENGED_USER);
}
}