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()`.
Add `LoginRedirect` that extends `HiddenElement` with a single `getUrl()` method
that encapsulates the three-step redirect resolution used in both login and
two-factor challenge form: fall back to `LoginForm::REDIRECT_URL` when the value
is empty or points to the logout action, then reject external URLs with a 400.
`LoginForm` and `TwoFactorChallengeForm` both replace their plain `'hidden'`
element with `LoginRedirect` and drop their identical `createRedirectUrl()`
methods in favor of `$this->getElement('redirect')->getUrl()`.
`AuthenticationController::loginAction()` had a pre-assembly call to
`$form->createRedirectUrl()` for the already-authenticated path. At that point
`handleRequest()` has not yet been called, so the form is not assembled and the
`redirect` element does not exist — calling `getElement()` would throw. That
path is also only reached when no `redirect` query param is present (the param
is handled explicitly on the line above), so the call always returned the
fallback URL anyway. It is replaced with a direct
`Url::fromPath(LoginForm::REDIRECT_URL)`.
`loginAction()` now redirects immediately when a challenge is already active, so
hitting the login URL mid-flow sends the user to the token page instead of
showing the login form.
`twofactorAction()` renders `TwoFactorChallengeForm` inside `LoginPage`. On a
cancel press it purges the session and returns to login.
`withRedirect()` is a small helper that propagates the `redirect` query
parameter across both steps so the original destination is not lost.
Convert `AuthenticationController` from the legacy Zend Controller to
`CompatController`, dropping `login.phtml` in favour of the new `LoginPage`
widget and `addContent()`. The view variable assignments are replaced by
`setTitle()` and `addContent(new LoginPage(...))`. Improve `httpBadRequest()`
message for external redirect attempts.
In `CompatController`, `$this->controls` is the tab bar area rendered
above the page content. When no tabs are added it still emits an empty
`<div class="controls">` wrapper. Setting `$this->view->compact = true`
suppresses that wrapper entirely, keeping the login page markup clean.
Handle the redirect on success in an `ON_SUBMIT` event handler. Delegate
the external-backend-only check to `LoginForm::onRequest()` via `ON_REQUEST`.
Use `$this->getServerRequest()` instead of `ServerRequest::fromGlobals()`.
Two structural fixes required by the changed DOM nesting:
- `login.less`: height `100%` -> `100vh` (`#login` is now inside `.content`
which has no explicit height, so percentage inheritance breaks)
- `history.js`: `#layout > #login` -> `#layout #login` (direct-child selector
breaks because `#login` is now a grandchild of `#layout` through `.content`)
Introduces `LoginButtonHook`, a new hook for rendering additional buttons
below the login form. Extend this class to display custom buttons on the
Icinga Web login page — useful for alternative authentication flows such
as SSO. Register your implementation by calling
`YourLoginButtons::register()` during module initialization.
We've used the standard layout before which caused a automatic login.
Automatic because the browser saw our js/css <link> tags and accessed
the routes which in turn logged in the user, but only if there's a
enabled module which's configuration.php (or run.php) accesses the
Auth singleton. The stripped down layout provides its own js/css so
there's no need for our full-blown resources.
fixes#3583