The `TwoFactor` interface methods that contain logic are implemented by module
developers and may contain arbitrary third-party code. Without guards at the
call sites, an uncaught exception from a faulty implementation could crash the
whole application.
Each guard logs the exception with a confidential trace. `isEnrolled()` in
`loadEnrolled()` rethrows so the caller aborts rather than silently continuing.
`verify()` and `assembleEnrollmentFormElements()` show a user-facing message
and call `onError()` to keep the user on the form.
The existing `enroll()` and `unenroll()` catches are also updated: two separate
`Logger::error()` calls become one, and the user-visible messages gain
translation context.
The only caller used `first()` as a presence check (`=== null`). A preceding
commit changed `first()` to throw and migrated that caller to `all() === []`,
leaving `first()` with no callers. Remove it.
Additionally there is no real reason to get the first two-factor method.
Callers want either all registered methods or the enrolled one.
Cookies issued while 2FA was active retain their validity after unenrollment
without this change. Revoking them forces a fresh login and prevents sessions
that were established under the enrolled requirement from persisting in a state
where that requirement no longer applies.
`TwoFactorController` used `TwoFactorHook::first() === null` as a presence
check. This is replaced with `TwoFactorHook::all() === []` so the intent is
explicit and the call is no longer dependent on `first()`'s nullable return.
`first()` itself is updated to throw `RuntimeException` when no method is
registered rather than returning null. Callers that reach `first()` expect
at least one method to be available. A missing registration indicates a
misconfiguration rather than a recoverable condition. This matches the
behavior of `fromName()`.
Previously both methods were declared to throw only `ConfigurationError`
and callers did not catch it. This removes the now-overly-narrow
`@throws ConfigurationError` declarations and wraps both call sites in
`try/catch(Throwable)`, letting the enrollment form log the full
confidential trace and show the exception message inline rather than
leaving the user with an unhandled error page.
The enroll false path is also updated: `Notification::error()` is
replaced with `$this->onError()` to keep error feedback consistent with
the exception path.
Using `static` instead of the interface as the return type lets static
analyzers and IDEs infer that `all`, `first`, `fromName`, and `loadEnrolled`
return instances of the concrete subclass.
The nullable return forced callers to guard against null. Since the method name
comes from a validated form field, an unregistered name indicates an unexpected
runtime condition. Throwing `RuntimeException` makes the contract explicit and
removes the need for null checks at call sites.
The empty placeholder `' - Please choose - '` could be re-selected after a real
method had been picked, resulting in `TwoFactorHook::fromName()` searching for
an empty string. Setting `disabledOptions` to `['']` prevents that by making the
placeholder unselectable once the user has moved past it.
With `required` set, the form will not pass validation when no method is
selected, so `getValue(static::METHOD)` in `onSuccess` can never return null.
The `?? ''` fallback passed to `TwoFactorHook::fromName` is dead code once
form validation runs.
Setting the `icinga-module module-{name}` class on the fieldset before calling
`assembleEnrollmentFormElements` let hook implementations overwrite or clear it
by calling `setAttributes` on the fieldset themselves. Moving the assignment to
after assembly ensures the CSS scoping class is always present regardless of
what the hook does.
The guard on the fieldset name ensures a hook implementation cannot change the
fieldset name during assembly, which would break form submission handling.
Without unique ids, `getCSSPath()` in `loader.js` produces the same path for the
submit buttons of both forms. When the login form is submitted and the container
transitions to the two-factor challenge form, `renderContentToContainer()`
restores focus to that path, incorrectly focusing the challenge form's submit
button instead of the token input.
Unique ids make the paths differ so no match is found and the autofocus class on
the token input takes effect as intended.
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()`.
If the 2FA method a user was challenged for is no longer available when they
submit the token — e.g. the module providing it was disabled mid-session — show
an informative message instead of a fatal error. The message tells the user that
their method is gone, suggests contacting an administrator if unexpected, and
points them to 'Back to login' explaining that no second factor will be required
once the method is disabled.
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)`.
Add `RememberMe::removeAllByUsername()` which deletes all rows from
`icingaweb_rememberme` for a given user. Call it from
`TwoFactorEnrollmentForm::onSuccess()` after a successful enroll so that
pre-enrollment cookies on other devices cannot bypass the newly required
second factor.
Adds a Two-Factor Authentication section to the authentication docs covering:
- enrolling (method selector, credential setup, recovery note)
- logging in (the second-step token page and the Back-to-login button)
- unenrolling, and replacing a compromised credential
Update the class docblock to reflect that `LoginPage` now wraps any form — both
the login form and the 2FA challenge form — rather than just the login form.
`TwoFactorController::configAction()` renders `TwoFactorEnrollmentForm`, which
shows a method-selector dropdown (autosubmit), the method-specific fieldset
contributed by `assembleEnrollmentFormElements()`, and either an "Enroll" or
"Unenroll" submit button depending on current enrollment state.
`AccountController`, `MyDevicesController`, and `NavigationController` each gain
a "Two-Factor Auth" tab.
`authHttp()` (HTTP Basic auth) responds with 403 JSON when the user has a 2FA
credential: the token step cannot be completed over Basic auth, so the request
is rejected outright rather than silently bypassing the second factor.
After successful password authentication, check whether the user is enrolled in
any 2FA method via `TwoFactorHook::loadEnrolled()`. If enrolled, stores the user
in `TwoFactorState`, stashes the `RememberMe` cookie for later (if the checkbox
was checked), and redirects to `authentication/twofactor`. Users without a
registered 2FA credential continue through the existing authentication flow
unchanged.
`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.
`TwoFactorChallengeForm` renders the second login step: a token input, a
"Verify" submit button, and a "Back to login" link button. `onSuccess()` calls
`TwoFactorHook::loadEnrolled()` and `TwoFactor::verify()` to verify the token,
calls `Auth::setAuthenticated()`, persists the `RememberMe` cookie if one was
stashed, completes the challenge, fires `AuthenticationHook::triggerLogin()`,
and redirects.
Add `.btn-back-to-login-link` to `login.less`. A link-styled submit button with
an arrow to the left with an underline on hover.
Stores the challenged `User` and an optional pending `RememberMe` cookie in the
`twofactor` session namespace between the password-auth step and the
token-verify step. `challenge()` writes the user, `completeChallenge()` clears
it, and `isChallenged()` lets the controller decide which page to show.
Abstract base class for `TwoFactor` implementations. Provides static helpers
that the core uses: `all()` returns every registered method sorted
alphabetically, `fromName()` looks one up by its machine-readable name, and
`loadEnrolled()` iterates all methods to find the one a given user is enrolled
in (and wires `$this->user` so `verify()` can access it without a parameter).
Modules extend this class and call `register()` during their initialization.
Defines the contract every 2FA implementation must satisfy:
`getName()` / `getDisplayName()` for identification, `isEnrolled()` to check
whether a user has a stored credential, `verify()` to validate a submitted
token, `enroll()` / `unenroll()` to persist or remove the credential, and
`assembleEnrollmentFormElements()` to contribute method-specific form fields
to the enrollment UI.
This PR replaces the legacy Zend-based login form and authentication
controller with their ipl equivalents, and extracts the login page view
script markup into a reusable widget.
### Convert `LoginForm` to `CompatForm`
`LoginForm` now extends `CompatForm`, replacing
`init()`/`createElements()` with `__construct()`/`assemble()` and Zend
decorator arrays with inline ipl decorator objects. The
`CsrfCounterMeasure` and `FormUid` traits are added.
`getRedirectUrl()` is renamed to `createRedirectUrl()` to avoid
overriding `CompatForm::getRedirectUrl()`, using `hasBeenAssembled`
instead of `$this->created` and `str_contains()` instead of `strpos()`.
`onSuccess()` is rewritten for the CompatForm lifecycle: void return,
explicit `setRedirectUrl()` call, `Icinga::app()->getResponse()` instead
of `$this->getResponse()`, and `addMessage()` instead of `addError()`.
### New `LoginPage` widget to replace view script
Extracts the login page structure (logo, footer, social links,
decorative orbs) from `login.phtml` into a new `LoginPage` widget
extending `HtmlDocument`, so the markup is defined in one place and no
longer lives in a view script. Additional login buttons provided by
`LoginButtonHook` are now grouped in a `div.login-buttons` flex
container, rendered only when buttons are actually present.
### Convert `AuthenticationController` to `CompatController`
`AuthenticationController` now extends `CompatController`, dropping
`login.phtml` in favour of `LoginPage` and `addContent()`. The form is
built with an `ON_SUBMIT` handler for the redirect and an `ON_REQUEST`
handler delegating to `LoginForm::onRequest()`. Uses
`$this->getServerRequest()` instead of `ServerRequest::fromGlobals()`.
### CSS / JS fixes
- `login.less`:
- `height: 100%` -> `100vh` on `#login` (now a grandchild of `#layout`
via `.content`)
- `.login-buttons` styled as a flex column with a row gap and top margin
- per-`li` error styling, including spacing inside `.login-buttons`
- `.icinga-form` width and control-group spacing
- `align-items: center` on `.remember-me-box`
- label `margin-right` reset for the disabled-state description
- `history.js`: `#layout > #login` -> `#layout #login` (direct-child
selector broke with the new nesting)
- `login-orbs.less`: fixes long-standing typo `#orb-notifactions` ->
`#orb-notifications`
Ensure `.errors` inside `.login-buttons` has no extra padding and that
the last error item retains a bottom margin, matching the layout used
for the primary login form errors.
Adjust login.less to match the HTML structure emitted by CompatForm:
- Add width: unset and margin on `.icinga-form` inside `.login-form` so the ipl
form does not overflow its container and control groups have spacing
- Restyle `.errors` at the li level rather than the container level, giving each
error message its own red box with margin between them
- Remove the padding from `.errors`/`.form-errors` that was set at the container
level (now on each li)
- Change `.remember-me-box` align-items from `flex-start` to `center` so the
toggle switch and label sit at the same baseline
Also fix a pre-existing typo in `login-orbs.less`: `#orb-notifactions` ->
`#orb-notifications`
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`)
Extract the login page structure (logo, footer, social links, decorative orbs)
from `login.phtml` into a reusable ipl-html widget. Extending `HtmlDocument`
rather than `BaseHtmlElement` lets it emit `#login` and the seven `.orb` sibling
divs without a wrapping root tag.
Both the login action and the upcoming 2FA challenge action can now share the
same visual scaffolding without duplicating markup and without using view
scripts anymore.
Replace the legacy Zend-based `LoginForm` with an ipl `CompatForm`-based form:
- Add `CsrfCounterMeasure` and `FormUid` traits
- Replace `init()`/`createElements()` with `__construct()´/`assemble()` using
inline ipl decorator objects instead of Zend decorator arrays
- Replace `getRedirectUrl()` with `createRedirectUrl()` because
`getRedirectUrl()` would override `ipl\Html\Form::getRedirectUrl()`
- use `hasBeenAssembled()` instead of `$this->created`
- use `str_contains()` instead of `strpos()`
- Rewrite `onSuccess()` for `CompatForm`:
- change visibility from `public` to `protected`
- void return type
- use `Icinga::app()->getResponse()` instead of `$this->getResponse()`
- use `addMessage()` instead of `addError()`
- call `setRedirectUrl()` explicitly
This tracks the user's interactions by observing the events
`keydown`, `paste` and `change` to detect changes to forms
inside a modal. Upon any change, the modal cannot be closed
anymore by pushing Escape or clicking outside the modal.
Instead, the modal will *wobble* for a short period.
resolves#5307
The native attribute `autofocus` is nowadays the better alternative to
our legacy `.autofocus` implementation. Also this was seldomly used
elsewhere. Except in the director, which [handles it
manually](5fc103ffc3/public/js/module.js (L713))
anyway. It is now gone for good.
In case we ever need something similar, we should re-introduce it but
properly integrate it with the default focus handling. But now it's not
the time and no real need for this.
A small bonus of this change is that error messages are now copyable to
clipboard with a single click.
The related issue is fixed because our default focus preservation now
doesn't kick in because the login form uses the native attribute on the
username field.
fixes#5503
If a form uses the native autofocus, it seems it wants to guide
the user. So the first visit respects the autofocus, so the
element is either still focused upon submit or another one.
Either way, the default focus preservation ensures that whatever
is in focus will retain it if necessary and if not, the content
will probably fundamentally change and no force focus is required.
With the only remaining cases, login and setup, this is true.
Another upcoming case is the 2FA flow, where it is true as well.
It didn't work anyway and is now obsolete given that there's
a `autofocus` attribute widely supported by browsers. The catch
however is, that this only works upon page load. But all still
relevant cases where autofocus is suited are in fact page loads.
In case we ever need something similar, we should re-introduce
it but properly integrate it with the default focus handling.
But now it's not the time and no real need for this.
There are none anymore and the implementation is going
to be removed from the framework until we have a better
idea how or whether to still support autofocusing.
Hopefully users will now always include the first message
in reports and not only the stacktrace
Hopefully users will now always include the first message
in reports and not only the stacktrace……
Besides that this is a destructive operation which shouldn't
get automatic focus… our native way to adhere to WCAG standards
is by focusing the newly rendered container which suffices.