Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,44 @@ user:

**Client contract**: these endpoints consume JSON bodies, so the CAPTCHA token must be sent in the `X-Captcha-Token` request header (preferred) or the `cf-turnstile-response` query parameter — it cannot be added as a form field. Rejections return `HTTP 403` with a `JSONResponse` body (`code: 8`), customizable via the `message.captcha.validation-failed` message key. The site key is exposed to MVC pages as the `captchaSiteKey` model attribute. See the README's [CAPTCHA Protection](README.md#captcha-protection-cloudflare-turnstile) section for the full client-side contract, fail-closed semantics, and scope notes (login is not covered).

### Passwordless Initial Password Step-Up (SUF-02)
### Step-Up Re-Authentication (SUF-02)

`POST /user/setPassword` adds an *initial* password to a passwordless (passkey-only) account. Because there is no current credential to verify, the endpoint is gated:
Credential-altering operations on a passwordless (passkey-only) account have no current credential to verify: `POST /user/setPassword` adds an *initial* password, and passkey delete/rename change how the account authenticates. Step-up requires a recent proof of presence before those proceed.

- **Step-Up Service (`com.digitalsanctuary.spring.user.security.StepUpService` SPI)**: If your application provides a `StepUpService` bean, it is **required** — `setPassword` proceeds only when `isStepUpSatisfied(user, "set-password", request)` returns `true`; otherwise it returns `HTTP 401`. Implement it to require a fresh WebAuthn assertion, TOTP, or recent-auth proof.
- **Allow Without Step-Up (`user.security.allowInitialPasswordSetWithoutStepUp`)**: When no `StepUpService` bean is present, `setPassword` is **disabled** (`HTTP 403`) unless this is `true`, which restores the previous session-only behavior. Default: `false`.
**How it works.** Spring Security records how a user authenticated as a factor authority carrying an issue time. Step-up requires one of the configured factors to have been issued within a short window. The user refreshes it by re-running that login ceremony while already logged in — for `WEBAUTHN`, the ordinary passkey assertion at `/login/webauthn`. There is no separate step-up endpoint, challenge store, or token, and the client reuses its existing login ceremony.

```yaml
user:
security:
stepUp:
enabled: false # default
ttlSeconds: 120 # how recently the factor must have been issued
factors: [WEBAUTHN] # any one is sufficient
```

- **Enabled (`user.security.stepUp.enabled`)**: Registers the framework's built-in `StepUpService`. Default: `false`, in which case nothing below applies and behavior is unchanged.
- **TTL (`user.security.stepUp.ttlSeconds`)**: How recently the factor must have been issued. Default: `120`. Keep it short: within the window, one ceremony authorizes any credential-altering operation on that session.
- **Factors (`user.security.stepUp.factors`)**: Any one satisfies step-up. Valid values: `WEBAUTHN`, `PASSWORD`, `OTT`, `AUTHORIZATION_CODE`, `SAML_RESPONSE`, `CAS`, `X509`, `BEARER`. Default: `WEBAUTHN`, the only factor whose refresh reliably proves presence — re-running an OAuth2 login typically completes with no user interaction while the identity-provider session is alive. Naming a factor your deployment never issues makes the gated operations permanently unavailable. Startup fails on an unknown name.

**Client contract.** A gated operation with no sufficiently recent factor returns `HTTP 401`: `setPassword` with `JSONResponse` code `6`, passkey delete/rename with error code `step-up-required`. The client re-runs its passkey login ceremony and retries the original call.

- **Enrollment window (`user.security.stepUp.enrollmentTtlSeconds`)**: How recently the user must have authenticated, by *any* means, to register a new passkey at `POST /webauthn/register`. Default: `600`. Applies only while step-up is enabled.

Enrolling a passkey is what turns a stolen session into durable access: the credential outlives a password change, since session invalidation ends sessions rather than credentials, and asserting with it refreshes `FACTOR_WEBAUTHN`. Without this gate an attacker holding only a session cookie could enroll their own authenticator and use it to satisfy step-up, so the rest of the feature would protect nothing. This mirrors GitHub's sudo mode, which asks for a password before adding a security key.

Any authentication factor counts, deliberately not the `factors` list above: with the default `[WEBAUTHN]`, requiring a configured factor would demand a passkey in order to register a first passkey. The window is longer than `ttlSeconds` because enrollment normally follows a login, a look around the settings page, and a decision, whereas a step-up ceremony immediately precedes its operation.

**Residual risk:** within this window of a genuine login, an attacker sharing the session can still enroll. The window bounds the exposure rather than eliminating it, which is the same trade-off sudo mode makes. A `PasskeyRegistration` audit event is recorded for every enrollment.

**Enabling step-up also enables factor merging** (`setMfaEnabled(true)` on authentication processing filters), without which re-authenticating replaces the session's authorities instead of merging them. If your application registers its own `AbstractAuthenticationProcessingFilter`, see the warning in `MfaFilterMergingConfiguration`.

**Accounts with no passkey** (OAuth-only, for example) cannot satisfy `WEBAUTHN` step-up. For them `setPassword` remains governed by `allowInitialPasswordSetWithoutStepUp` below, exactly as before.

**Reserved authority names.** Do not name a role or privilege `FACTOR_*` in `user.roles-and-privileges`. Spring Security uses that prefix for factor authorities, and a plain authority with such a name is indistinguishable from a real factor by name: it satisfies MFA enforcement without the factor ever being completed, and it shadows the genuine factor in a step-up freshness check. Startup fails when such a name is configured while MFA or step-up is enabled, and logs an error otherwise.

**Custom implementations.** A `StepUpService` bean supplied by your application takes precedence over the built-in one, whatever `enabled` is set to. Implement the SPI to require TOTP, a hardware token, or any other proof.

- **Allow Without Step-Up (`user.security.allowInitialPasswordSetWithoutStepUp`)**: When no `StepUpService` bean is present at all, `setPassword` is **disabled** (`HTTP 403`) unless this is `true`, which restores the previous session-only behavior. Default: `false`.

### Token Security

Expand Down
23 changes: 21 additions & 2 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ Affected endpoints (all require `user.webauthn.enabled=true` except where noted)

**Action required:** Update any client that calls the three endpoints above so that it collects the user's current password and sends it in the request body. `DELETE /user/webauthn/credentials/{id}` and `DELETE /user/webauthn/password`, which previously had no request body, now accept (and for password-holding accounts require) a JSON body carrying `currentPassword`. Existing IDOR/ownership checks and last-credential lockout protection are unchanged.

**Passwordless (passkey-only) accounts — residual risk:** For accounts with no password set, there is no current credential to verify, and this library does not yet implement a WebAuthn step-up assertion (a feasible recent-authentication signal does not currently exist in the framework). As a result:
**Passwordless (passkey-only) accounts — residual risk:** For accounts with no password set, there is no current credential to verify. The framework now ships a step-up mechanism (see *Built-in step-up* below), but it is off by default, so unless you enable `user.security.stepUp.enabled` the following still applies:
- Deleting or renaming a passkey on a passwordless account remains a session-only operation (last-credential lockout protection and ownership checks still apply).
- Setting an *initial* password via `POST /user/setPassword` cannot require a current password (there is none). **As of the SUF-02 hardening this endpoint is disabled by default** — it returns `HTTP 403` unless you either provide a step-up service (below) or explicitly opt into the previous behavior. It still rejects accounts that already have a password.

Expand All @@ -223,7 +223,26 @@ Affected endpoints (all require `user.webauthn.enabled=true` except where noted)

**Action required if your application lets passwordless users set an initial password:** provide a `StepUpService` bean (recommended), or set `user.security.allowInitialPasswordSetWithoutStepUp=true`. Otherwise `POST /user/setPassword` returns `403`.

Implementing a *full* WebAuthn step-up assertion (challenge/response bound to user/session/action/RP ID/origin/expiry) is tracked as separate feature work; the `StepUpService` SPI is the interim mechanism so applications can enforce their own step-up now.
**Built-in step-up (this release).** The framework now ships a `StepUpService` of its own, off by default. Set `user.security.stepUp.enabled=true` and it requires one of `user.security.stepUp.factors` to have been issued within `user.security.stepUp.ttlSeconds` (default `120`). The user refreshes a factor by re-running that login ceremony while already logged in — for `WEBAUTHN`, the ordinary passkey assertion — so there is no new endpoint and the client reuses its existing ceremony. A `StepUpService` bean you supply still takes precedence.

**Passkey enrollment is gated when step-up is enabled.** `POST /webauthn/register` now requires an authentication factor issued within `user.security.stepUp.enrollmentTtlSeconds` (default `600`). Without it the feature protected nothing: an attacker holding a session cookie could enroll their own passkey and assert with it to satisfy every other gate. Any factor counts, not the configured `factors` list, so a first passkey can still be registered after an ordinary password or social login.

To make that possible, login flows that previously left no factor now stamp one: OIDC logins (`OidcAuthorizationCodeAuthenticationProvider` stamps none, unlike the password and plain-OAuth2 providers), the email-verification link (`FACTOR_OTT`), and post-registration auto-login (`FACTOR_PASSWORD`). Dev login still stamps nothing, so passkey enrollment is unavailable under `user.dev.auto-login-enabled` while step-up is on. `UserService.authWithoutPassword(User)` is unchanged for existing callers; a new overload takes the factor.

**`StepUpService` gained a default method.** `canSatisfyStepUp(User)` reports whether a user could satisfy step-up at all, as opposed to whether they have. It defaults to `true`, so existing implementations compile and behave exactly as before. Override it when your mechanism depends on a credential some accounts lack: an OAuth2/OIDC account with no passkey can never produce a WebAuthn factor, and callers treat `false` as "step-up does not apply" and fall back to their configured default rather than rejecting an operation the user could never unlock. The built-in service overrides it, so with `user.security.stepUp.enabled=true` a social-login account with no passkey keeps the `allowInitialPasswordSetWithoutStepUp` behavior instead of receiving a permanent `HTTP 401` on `POST /user/setPassword`. The same applies to passkey delete and rename, which fall back to their pre-feature behavior for accounts that cannot satisfy the configured factors.

If you unit-test against a mocked `StepUpService`, note that Mockito returns `false` for the unstubbed default, which makes callers skip step-up. Stub `canSatisfyStepUp(...)` to `true` in tests that are about whether step-up was *satisfied*. Real implementations inherit `true` and are unaffected.

Startup now fails when `user.security.stepUp.factors` includes `WEBAUTHN` while `user.webauthn.enabled=false`, matching the existing check on `user.mfa.factors`: no account could produce that factor, so step-up would silently never apply. A `PASSWORD` factor logs a warning for the same reason, since passwordless and social-login accounts cannot satisfy it.

Enabling it also gates passkey **delete and rename** on passwordless accounts, which previously required nothing beyond a session. Accounts with a password keep the current-password path unchanged. Rejections return `HTTP 401`, with error code `step-up-required` on the passkey endpoints.

Two things to check before enabling it:

- **Reserved authority names.** A role or privilege named `FACTOR_*` in `user.roles.roles-and-privileges` collides with Spring Security's factor authorities. Such a name satisfies MFA enforcement without the factor ever being completed, and shadows the genuine factor in a step-up freshness check. Startup now **fails** when one is present while MFA or step-up is enabled, and logs an error otherwise. The check covers both the configured names and the `role`/`privilege` tables, because `RolePrivilegeSetupService` never deletes, so a name removed from configuration survives as a row and is still granted. Rename the configured entries *and* delete the persisted rows.
- **Factor merging.** Enabling step-up turns on `setMfaEnabled(true)` for authentication processing filters, as `user.mfa.enabled=true` already did. If your application registers its own `AbstractAuthenticationProcessingFilter`, see the warning on `MfaFilterMergingConfiguration`.

See CONFIG.md for the full configuration.

### Database schema: unique role/privilege names

Expand Down
13 changes: 9 additions & 4 deletions src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.digitalsanctuary.spring.user.registration.RegistrationDeniedException;
import com.digitalsanctuary.spring.user.registration.RegistrationGuard;
import com.digitalsanctuary.spring.user.security.StepUpService;
import com.digitalsanctuary.spring.user.service.AuthWithoutPasswordFactor;
import com.digitalsanctuary.spring.user.security.UserSecurityConfigProperties;
import com.digitalsanctuary.spring.user.service.DSUserDetails;
import com.digitalsanctuary.spring.user.service.LoginAttemptService;
Expand Down Expand Up @@ -367,7 +368,7 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs
// report a "failed attempt" to the lockout counter below — letting any authenticated (or session-hijacking)
// caller lock the account out of EVERY authentication method by hitting this endpoint repeatedly. Reject up
// front, before the lockout logic and without touching the counter, and point the user at the set-password
// flow. Mirrors WebAuthnManagementAPI.requireCurrentPasswordIfSet and the symmetric guard in setPassword().
// flow. Mirrors WebAuthnManagementAPI.requireCredentialProof and the symmetric guard in setPassword().
if (!userService.hasPassword(user)) {
logAuditEvent("PasswordUpdate", "Failure", "No password set", user, request);
return buildErrorResponse(messages.getMessage("message.update-password.no-password", null,
Expand All @@ -376,7 +377,7 @@ public ResponseEntity<JSONResponse> updatePassword(@AuthenticationPrincipal DSUs

// Verifying the current password is an authentication surface, so it participates in the same brute-force
// lockout as login: reject a locked account up front (HTTP 423) so a session-holding actor cannot make
// unlimited old-password guesses here. Mirrors WebAuthnManagementAPI.requireCurrentPasswordIfSet.
// unlimited old-password guesses here. Mirrors WebAuthnManagementAPI.requireCredentialProof.
if (loginAttemptService.isLocked(user.getEmail())) {
logAuditEvent("PasswordUpdate", "Failure", "Account locked", user, request);
return buildErrorResponse(messages.getMessage("message.update-password.account-locked", null,
Expand Down Expand Up @@ -570,7 +571,10 @@ public ResponseEntity<JSONResponse> setPassword(@AuthenticationPrincipal DSUserD
// supplies a StepUpService, require it to pass; otherwise the endpoint is disabled by default. Set
// user.security.allowInitialPasswordSetWithoutStepUp=true to explicitly keep the session-only behavior.
final StepUpService stepUpService = stepUpServiceProvider.getIfAvailable();
if (stepUpService != null) {
// canSatisfyStepUp() separates "has not proven presence" from "could never prove it". A social-login
// account with no passkey falls in the second group, where denying is a dead end rather than a prompt,
// so step-up does not apply and allowInitialPasswordSetWithoutStepUp governs as it did before.
if (stepUpService != null && stepUpService.canSatisfyStepUp(user, "set-password")) {
if (!stepUpService.isStepUpSatisfied(user, "set-password", request)) {
logAuditEvent("SetPassword", "Failure", "Step-up verification failed", user, request);
return buildErrorResponse(messages.getMessage("message.set-password.step-up-required", null,
Expand Down Expand Up @@ -639,7 +643,8 @@ private void validateAuthenticatedUser(DSUserDetails userDetails) {
* @return the URI to redirect to after registration
*/
private String handleAutoLogin(User user) {
userService.authWithoutPassword(user);
// The password was submitted in the very request that produced this registration.
userService.authWithoutPassword(user, AuthWithoutPasswordFactor.REGISTRATION);
return userSecurityConfig.getRegistrationSuccessUri();
}

Expand Down
Loading
Loading