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
24 changes: 24 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ jobs:
--health-interval=10s
--health-timeout=5s
--health-retries=5
mailpit:
# Mail catcher for the step-up run's passkey-registration notification assertion. Compose is
# disabled in CI (SPRING_DOCKER_COMPOSE_ENABLED=false), so the compose.dev.yaml Mailpit is
# replaced by this service container; the step-up-e2e profile points spring.mail at localhost:1025.
image: axllent/mailpit:v1.30.7
env:
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
ports:
- 1025:1025
- 8025:8025
options: >-
--health-cmd="/mailpit readyz"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
# The MariaDB service container replaces Spring Boot's Docker Compose integration
SPRING_DOCKER_COMPOSE_ENABLED: "false"
Expand Down Expand Up @@ -91,6 +107,14 @@ jobs:
APP_PROFILES: playwright-test,mfa
run: npx playwright test --project=chromium-mfa

- name: Run E2E tests (step-up enabled)
working-directory: playwright
# `local` activates the dev-login controller (@Profile("local")) the factorless-session case needs;
# application-local.yml is absent in CI so it adds no overrides, and playwright-test pins rpId=localhost.
env:
APP_PROFILES: local,playwright-test,step-up,step-up-e2e
run: npx playwright test --project=chromium-step-up

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
Expand Down
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ repositories {

dependencies {
// DigitalSanctuary Spring User Framework
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.3.1'
// 5.3.3 adds the WebAuthn step-up primitive (SUF-02, library #365) this demo exercises.
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.3.3'

// WebAuthn support (Passkey authentication)
implementation 'org.springframework.security:spring-security-webauthn'
Expand Down
19 changes: 19 additions & 0 deletions compose.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,24 @@ services:
volumes:
- mariadb-data:/var/lib/mysql

# Local mail catcher. bootRun starts it automatically (spring.docker.compose.file: compose.dev.yaml).
# Nothing sends to it unless a profile points spring.mail here: the base config uses real SMTP, and the
# step-up E2E profile (application-step-up-e2e.yml) redirects mail to localhost:1025 so the Playwright
# suite can assert the passkey-registration notification via the web API on 8025.
mailpit:
image: axllent/mailpit:v1.30.7
ports:
- "1025:1025" # SMTP
- "8025:8025" # web UI + REST API
environment:
MP_MAX_MESSAGES: 5000
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 10s
timeout: 5s
retries: 5

volumes:
mariadb-data:
39 changes: 39 additions & 0 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,45 @@ sets the flag true where the flow has to be demonstrable (`application-local.yml
`application-mfa.yml:24`, `application-playwright-test.yml:35`) and leaves it at the secure default
`false` in `prd` (`application-prd.yml:50-52`).

## WebAuthn step-up (SUF-02)

The `step-up` profile (`application-step-up.yml`) turns on the framework's step-up primitive with
`user.security.stepUp.enabled=true`. It registers the built-in `StepUpService`, so on a passkey-only
account the credential-altering operations require a recent WebAuthn assertion first:

- `POST /user/setPassword` returns `401` with `JSONResponse` code `6`.
- passkey delete (`DELETE /user/webauthn/credentials/{id}`) and rename (`PUT .../{id}/label`) return
`401` with `GenericResponse` `error: "step-up-required"`.

Step-up is a freshness requirement on the `FACTOR_WEBAUTHN` authority Spring Security already issues, not
a bespoke ceremony: the client re-runs the ordinary passkey login (the same
`authenticateWithPasskey()` used at `/user/login.html`) while still logged in, which refreshes that factor
on the session, then retries the original call. There is no separate step-up endpoint or token.

The client handling lives in [`step-up.js`](../src/main/resources/static/js/user/step-up.js), wired into
the set-password ([`update-password.js`](../src/main/resources/static/js/user/update-password.js)) and
passkey delete/rename ([`webauthn-manage.js`](../src/main/resources/static/js/user/webauthn-manage.js))
flows. On either `401` shape it shows a modal warning that a passkey check is coming (so the browser's
authenticator dialog is not a surprise), runs the ceremony, and retries once. Because re-running
`/login/webauthn` mid-session triggers Spring Security's authentication success handling — session-id
change (fixation protection) and, with the default session-based repository, CSRF token rotation — the
retry would otherwise fail with a stale token. `step-up.js` fetches the rotated token from `GET /csrf`
([`CsrfController`](../src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java))
and updates the page's `<meta>` tags before retrying. (The normal login path sidesteps this by fully
navigating to a fresh page; step-up deliberately stays put.)

Enabling step-up also gates passkey enrollment (`POST /webauthn/register`) on a recent authentication by
any factor (`enrollmentTtlSeconds`, default `600`), and enables factor merging so the fresh factor merges
onto the session instead of replacing its authorities. Social-login (OAuth-only) accounts have no passkey
and cannot satisfy `WEBAUTHN` step-up; for them `setPassword` falls back to
`allowInitialPasswordSetWithoutStepUp`, exactly as before.

The `chromium-step-up` Playwright project
([`step-up-flow.spec.ts`](../playwright/tests/step-up/step-up-flow.spec.ts)) verifies the enabled path in a
browser (ceremony then retry succeeds) and the negative path (absent `WEBAUTHN` factor returns `401` and
runs no ceremony). Run it with
`APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up`.

## MFA

The `mfa` profile turns on `user.mfa.enabled` (`application-mfa.yml:20`), `false` in the base config
Expand Down
7 changes: 5 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ values for one scenario (local dev, production, tests, and so on). For the full
`local`, `dev`, `prd`, and `docker-keycloak` are base profiles you choose directly, one at a time, the
way the command above chooses `local`. `test` is not chosen by hand: `./gradlew test` applies it
automatically. `playwright-test` is meant to be combined with a base profile rather than run alone
(see its row below). `mfa` and `registration-guard` are opt-in add-ons with no base settings of their
(see its row below). `mfa`, `step-up`, and `registration-guard` are opt-in add-ons with no base settings of their
own; combine one with a base profile by listing both, comma-separated, in `--spring.profiles.active`
(Spring Boot applies later profiles' properties over earlier ones when the same key is set in both).
If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradle:118-123` sets
Expand All @@ -32,11 +32,14 @@ If you omit `--args` entirely, `bootRun` still defaults to `local`: `build.gradl
| `playwright-test` | [`application-playwright-test.yml`](../src/main/resources/application-playwright-test.yml) | Playwright E2E runs; enables the Test API (`TestDataController`, `TestApiSecurityConfig`, localhost-only) | Disables verification/reset emails, points `spring.datasource.*` at the same local MariaDB the `local` profile uses, pins `appUrl` to `http://localhost:8080`, `allowInitialPasswordSetWithoutStepUp: true`, MFA off, and restates `user.security.unprotectedURIs` in full (a list property is replaced wholesale, not merged, so this copy has to match the base list in `application.yml:160`) | Combine with a base profile, e.g. `local,playwright-test` (see [TESTING.md](TESTING.md)) |
| `docker-keycloak` | [`application-docker-keycloak.yml`](../src/main/resources/application-docker-keycloak.yml) (tracked; holds only `${...}` placeholders, nothing to copy) | OIDC login against the bundled Keycloak stack; see [`keycloak/README.md`](../keycloak/README.md) and [AUTHENTICATION.md#keycloak](AUTHENTICATION.md#keycloak) for the full walkthrough | Adds the Keycloak OAuth2 client/provider from `DS_SPRING_USER_KEYCLOAK_*` env vars (deliberately no `issuer-uri`), insecure session cookie | `--spring.profiles.active=docker-keycloak`, normally set for you as `SPRING_PROFILES_ACTIVE` inside `docker-compose-keycloak.yml` |
| `mfa` | [`application-mfa.yml`](../src/main/resources/application-mfa.yml) | Add-on: require PASSWORD + WEBAUTHN | `user.mfa.enabled: true` (base `application.yml:126` has it `false`); once enabled, the framework auto-unprotects the configured MFA entry-point URIs at runtime, including the challenge page, so a partially-authenticated user can reach them; the profile's yml additionally adds the passkey enrollment endpoints `/webauthn/register/options` and `/webauthn/register` to `unprotectedURIs` (line 25) so that user can register their first passkey; `allowInitialPasswordSetWithoutStepUp: true` | Combine with a base profile, e.g. `local,mfa` |
| `step-up` | [`application-step-up.yml`](../src/main/resources/application-step-up.yml) | Add-on: require a recent passkey assertion for credential-altering operations on passkey-only accounts (SUF-02) | `user.security.stepUp.enabled: true` (base default is `false`), `ttlSeconds: 120`, `factors: [WEBAUTHN]`; this registers the framework's built-in `StepUpService`, so `POST /user/setPassword` and passkey delete/rename return `401` until a fresh `WEBAUTHN` factor exists, and passkey enrollment is gated on a recent authentication. See [AUTHENTICATION.md#webauthn-step-up-suf-02](AUTHENTICATION.md#webauthn-step-up-suf-02) | Combine with a base profile, e.g. `local,step-up` |
| `step-up-e2e` | [`application-step-up-e2e.yml`](../src/main/resources/application-step-up-e2e.yml) | **Test-only** override for the `chromium-step-up` Playwright run; never use outside E2E | Shrinks `stepUp.ttlSeconds` to `2` (deterministic factor aging), enables `user.dev.auto-login-enabled` (factorless session via `/dev/login-as`), and points `spring.mail` at the Mailpit catcher in `compose.dev.yaml` (notification assertion). Layer it last, after `step-up` | `local,playwright-test,step-up,step-up-e2e` (see [TESTING.md](TESTING.md)) |
| `registration-guard` | none (no yml; `@Profile("registration-guard")` on [`DomainRegistrationGuard`](../src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java)) | Add-on: domain-restricted registration demo | Activates a `RegistrationGuard` bean that restricts form/passwordless registration to one email domain (`registration.guard.allowed-domain`, default `@example.com`); OAuth2/OIDC registration is unaffected | Combine with a base profile, e.g. `local,registration-guard` |

See [AUTHENTICATION.md](AUTHENTICATION.md) for the mechanics behind `mfa`
([#mfa](AUTHENTICATION.md#mfa)), `docker-keycloak` ([#keycloak](AUTHENTICATION.md#keycloak)),
WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), and `registration-guard`
WebAuthn passkeys ([#passkeys](AUTHENTICATION.md#passkeys)), `step-up`
([#webauthn-step-up-suf-02](AUTHENTICATION.md#webauthn-step-up-suf-02)), and `registration-guard`
([#registration-guard](AUTHENTICATION.md#registration-guard)).

## Getting started locally
Expand Down
23 changes: 21 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,32 @@ disables verification/reset emails (tests fetch tokens via the Test API instead)
"set initial password" flow works without a `StepUpService` bean.

The `chromium`, `firefox`, `webkit`, `Mobile Chrome`, and `Mobile Safari` projects skip specs
tagged `@mfa-enabled` (`grepInvert`); a separate `chromium-mfa` project runs only those specs,
against a server started with the `mfa` profile added:
tagged `@mfa-enabled` and `@step-up-enabled` (`grepInvert`); separate Chromium-only projects run
those, each against a server started with the matching add-on profile. Both use the CDP virtual
authenticator, so they are Chromium-only.

```bash
# MFA flow (@mfa-enabled)
APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa

# WebAuthn step-up / SUF-02 (@step-up-enabled)
APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up
```

The `playwright-test` profile also pins `user.webauthn.rpId=localhost` and
`allowedOrigins=http://localhost:8080`, so the virtual authenticator ceremonies work even when a
developer's `application-local.yml` points WebAuthn at an ngrok host.

The step-up run adds `step-up-e2e` (`application-step-up-e2e.yml`), a test-only override that shrinks
`stepUp.ttlSeconds` to 2 (so a factor can be aged past the window in a few seconds), enables dev login
(`/dev/login-as`, for a deterministic factorless session), and redirects mail to the Mailpit catcher in
`compose.dev.yaml` (published on 1025/8025) so the suite can assert the passkey-registration notification.
`bootRun` starts Mailpit automatically alongside MariaDB. The realistic demo values stay in
`application-step-up.yml` (`ttlSeconds: 120`). The step-up specs run serially
(`test.describe.configure({ mode: 'serial' })`) because concurrent account registration deadlocks in
MariaDB (framework issue devondragon/SpringUserFramework#368). One acceptance case is not covered here:
social-login (OIDC) `setPassword` fallback, which needs the Keycloak stack (tracked separately).

**Test API**:
[`TestDataController`](../src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java)
exposes `/api/test/*` (create/enable/unlock/delete a user, fetch verification and password-reset
Expand Down
20 changes: 15 additions & 5 deletions playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,32 +93,32 @@ export default defineConfig({
projects: [
{
name: 'chromium',
grepInvert: /@mfa-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled/,
use: { ...devices['Desktop Chrome'] },
},

{
name: 'firefox',
grepInvert: /@mfa-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled/,
use: { ...devices['Desktop Firefox'] },
},

{
name: 'webkit',
grepInvert: /@mfa-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled/,
use: { ...devices['Desktop Safari'] },
},

/* Test against mobile viewports */
{
name: 'Mobile Chrome',
grepInvert: /@mfa-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled/,
use: { ...devices['Pixel 5'] },
},

{
name: 'Mobile Safari',
grepInvert: /@mfa-enabled/,
grepInvert: /@mfa-enabled|@step-up-enabled/,
use: { ...devices['iPhone 12'] },
},

Expand All @@ -128,6 +128,16 @@ export default defineConfig({
grep: /@mfa-enabled/,
use: { ...devices['Desktop Chrome'] },
},

/* Step-up (SUF-02) tests: Chromium only (CDP virtual authenticator), step-up-enabled server required.
* Run with:
* APP_PROFILES=local,playwright-test,step-up npx playwright test --project=chromium-step-up
* (the step-up profile must come last so its overrides win). */
{
name: 'chromium-step-up',
grep: /@step-up-enabled/,
use: { ...devices['Desktop Chrome'] },
},
],

/* Run your local dev server before starting the tests */
Expand Down
Loading
Loading