Commit graph

6 commits

Author SHA1 Message Date
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
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
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