Commit graph

14339 commits

Author SHA1 Message Date
Johannes Rauh
3c199b725b Use Generator to yield two-factor methods 2026-05-27 10:47:21 +02:00
Johannes Rauh
5cd6a2b90d Guard hook method call sites against implementation exceptions
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
df4348081f Deduplicate remember-me revocation and redirect in onSuccess()
Both the enroll and unenroll paths ended with the same two statements.
Moving them after the switch eliminates the duplication.
2026-05-27 10:47:21 +02:00
Johannes Rauh
927a6f6bd4 Drop TwoFactorHook::first()
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
524cf5c286 Revoke remember-me cookies on unenrollment
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
238ce10e6b Make TwoFactorHook::first() throw and migrate its only caller
`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()`.
2026-05-27 10:47:21 +02:00
Johannes Rauh
dd1f570629 Catch and surface exceptions from enroll() and unenroll()
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
84b1806d40 Narrow return types from TwoFactor to static in TwoFactorHook
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
6ea4040d58 Fix {@see static::$user} reference in TwoFactorHook method docs 2026-05-27 10:47:21 +02:00
Johannes Rauh
575bcf9da2 Make TwoFactorHook::fromName() throw on unknown name
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
240141a412 Remove success notifications 2026-05-27 10:47:21 +02:00
Johannes Rauh
efed65fb09 Disable the placeholder option in the 2FA method select
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
40a251d413 Require 2FA method selection and drop null fallback in onSuccess
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
f7a38cb276 Apply fieldset module class after assembleEnrollmentFormElements
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
db36a68c4e Early return if no method is chosen
Instead of searching for a method named `''`.
2026-05-27 10:47:21 +02:00
Johannes Rauh
4d91eda501 Add module class to 2FA config fieldset in TwoFactorEnrollmentForm
The `icinga-module module-{name}` class pair lets the providing module scope
its CSS to its own fieldset without affecting other form elements.
2026-05-27 10:47:21 +02:00
Johannes Rauh
b99ec0df93 Add unique form ids to fix incorrect focus after login form submission
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.
2026-05-27 10:47:21 +02:00
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
Johannes Rauh
5284553a74 Handle unavailable 2FA method in TwoFactorChallengeForm
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
8f52c7639e Deduplicate redirect logic into LoginRedirect element
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)`.
2026-05-27 10:47:21 +02:00
Johannes Rauh
b1ced8769a Revoke remember-me cookies on 2FA enrollment
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
7aa6a979b6 Document 2FA enrollment and login flow
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
2026-05-27 10:47:21 +02:00
Johannes Rauh
05ad1a000b Update LoginPage docblock
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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
1b90b6c14f Add enrollment UI and two-factor tab to account navigation
`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.
2026-05-27 10:47:21 +02:00
Johannes Rauh
9068e00987 Reject API requests if 2FA enrolled
`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.
2026-05-26 15:27:51 +02:00
Johannes Rauh
ad268ab0fd Add 2FA challenge flow to LoginForm
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.
2026-05-26 15:27:51 +02:00
Johannes Rauh
c9a60e38b0 Add AuthenticationController::twofactorAction()
`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.
2026-05-26 15:27:51 +02:00
Johannes Rauh
c225fc0b8a Add TwoFactorChallengeForm and login CSS
`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.
2026-05-26 15:27:51 +02:00
Johannes Rauh
4ec86a774c Add TwoFactorState to centralize 2FA session handling
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.
2026-05-26 15:27:42 +02:00
Johannes Rauh
5528992790 Add TwoFactorHook
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.
2026-05-26 14:36:51 +02:00
Johannes Rauh
70ba207a34 Add TwoFactor interface
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.
2026-05-26 14:36:51 +02:00
Johannes Meyer
21ef5900ab modal.js: Do not wobble if ESC has been handled otherwise
Some checks failed
L10n Update / update (push) Has been cancelled
CI / PHP (push) Has been cancelled
2026-05-21 17:03:00 +02:00
jrauh01
40114d90e4
Fix navigation path in authentication configuration docs (#5508)
Some checks failed
L10n Update / update (push) Has been cancelled
CI / PHP (push) Has been cancelled
`"Authentication"` was renamed to `"Access Control Backends"` in the UI.
2026-05-20 09:03:27 +02:00
Eric Lippmann
0b266cd831
Modernize login (#5500)
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`
2026-05-20 09:02:30 +02:00
Johannes Rauh
059073a5db Add rel="noopener noreferrer" to external links in LoginPage
Prevents the linked page from accessing `window.opener` and stops the
referring URL from being sent in the `Referer` header.
2026-05-18 12:14:13 +02:00
Johannes Rauh
8dff2712a1 Use Form::ON_SUBMIT instead of ON_SUCCESS for login buttons 2026-05-18 12:14:13 +02:00
Johannes Rauh
b6fd551413 Fix error message spacing inside login-buttons container
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.
2026-05-18 12:14:13 +02:00
Johannes Rauh
77d4c618ab Fix login page CSS for ipl CompatForm rendering
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`
2026-05-18 12:14:13 +02:00
Johannes Rauh
650ea2b597 Migrate AuthenticationController to CompatController
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`)
2026-05-18 12:14:13 +02:00
Johannes Rauh
1dd503e9af Add LoginPage widget to centralize login page and modernize to ipl
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.
2026-05-18 12:14:13 +02:00
Johannes Rauh
fe282a304e Rewrite authentication login form to ipl CompatForm
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
2026-05-18 12:14:07 +02:00
Johannes Meyer
845792b364 modal.js: Prevent unintentional closing
Some checks failed
L10n Update / update (push) Has been cancelled
CI / PHP (push) Has been cancelled
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
2026-05-12 15:56:57 +02:00
Johannes Meyer
ca4a561d63
Fix focus on login password (#5504)
Some checks failed
L10n Update / update (push) Has been cancelled
CI / PHP (push) Has been cancelled
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
2026-05-11 14:25:23 +02:00
Johannes Meyer
688593311e history.js: Do not try to move errors to the right 2026-05-11 14:23:19 +02:00
Johannes Meyer
441d580f16 js: Do not force focus in forms that seem to apply it automatically
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.
2026-05-11 14:23:19 +02:00
Johannes Meyer
e8f8033d02 js: Drop autofocus behavior
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.
2026-05-11 14:23:19 +02:00
Johannes Meyer
876f065f31 form.js: Do not ignore autofocus elements
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.
2026-05-11 14:23:19 +02:00
Johannes Meyer
132d19d98e FilterEditor: Don't replace autosubmit with autofocus
Didn't notice that was working as intended now, but it makes
zero sense anyway as both functions are distinct and either
cannot replace the other -.-
2026-05-11 14:23:19 +02:00
Johannes Meyer
ca1c5625e1 error.phtml: Allow to copy errors to clipboard
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……
2026-05-11 14:23:19 +02:00
Johannes Meyer
0488bebd8c ConfirmRemovalForm: Drop autofocus entirely
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.
2026-05-08 11:45:22 +02:00