Skip to content

feat: Add MFA challenge UI pages and demo support#60

Merged
devondragon merged 13 commits into
mainfrom
feature/mfa-challenge-ui
Jun 12, 2026
Merged

feat: Add MFA challenge UI pages and demo support#60
devondragon merged 13 commits into
mainfrom
feature/mfa-challenge-ui

Conversation

@devondragon

@devondragon devondragon commented Mar 2, 2026

Copy link
Copy Markdown
Owner

Summary

Companion to SpringUserFramework#268 / PR #272. Closes #59.

  • WebAuthn challenge page (/user/mfa/webauthn-challenge.html) — shown when a partially-authenticated user needs to verify with their passkey after password login
  • MFA status on profile page — extends the auth-methods card to show MFA badges (active, fully authenticated, satisfied/missing factors) via GET /user/mfa/status
  • mfa profile — MFA is opt-in via the new mfa profile (e.g. --spring.profiles.active=local,mfa). It is off by default because a user without a registered passkey cannot satisfy the WEBAUTHN factor and would be locked out of every protected page.
  • Factor mergingMfaSecurityConfig applies @EnableMultiFactorAuthentication so a passkey verification adds the WEBAUTHN factor to the session instead of replacing the PASSWORD factor (without it the MFA flow can never complete)
  • Tests — MockMvc integration tests (redirect-loop regression guard, status envelope contract), config-consistency tests, Playwright challenge-page specs, and a full MFA flow E2E using Chromium's virtual authenticator
  • CI — new tests.yml workflow runs the gradle suite and both Playwright projects on PRs

How MFA works

  1. User logs in with password → session gets the PASSWORD factor authority
  2. Spring Security sees the WEBAUTHN factor is missing → redirects to the challenge page (which must be in unprotectedURIs, or the redirect loops)
  3. User clicks "Verify with Passkey" → completes the WebAuthn assertion
  4. The new authentication is merged with the current one (@EnableMultiFactorAuthentication) → both factors satisfied → fully authenticated

Bugs found and fixed during review

  • Infinite redirect loop: the challenge page was not in unprotectedURIs, so partially-authenticated users were redirected to a page they were not allowed to load, forever
  • Factor replacement: without MFA enabled on the auth filters, the WebAuthn login dropped the PASSWORD factor (satisfiedFactors went [PASSWORD][WEBAUTHN]), so MFA could never complete
  • JSON envelope: the profile badges read status.mfaEnabled etc. from the top level, but the endpoint wraps the payload in JSONResponse ($.data.mfaEnabled) — fully-authenticated users always saw "Additional Factor Required"
  • Test profile: user.mfa.enabled: true leaked into the test profile and broke 4 AdminRoleAccessControlTest tests (302 instead of 200/403)
  • Test API cleanup: deleting a user with registered passkeys hit the user_entities FK; the Test API now publishes UserPreDeleteEvent so the framework cleans up WebAuthn data first

Test plan

  • ./gradlew test — 315 tests, 0 failures (includes new MfaChallengeFlowIntegrationTest + MfaConfigConsistencyTest)
  • npx playwright test --project=chromium — 103 tests pass (MFA-off server)
  • SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa — full passkey MFA flow passes using the CDP virtual authenticator
  • Manual curl flow: password login → 302 to challenge page → challenge page renders (no loop) → /user/mfa/status reports satisfiedFactors=[PASSWORD], missingFactors=[WEBAUTHN]

Follow-up

The factor-merging gap (@EnableMultiFactorAuthentication filter wiring) arguably belongs in the framework's MfaConfiguration rather than in each consuming app — filed as SpringUserFramework#313.

Copilot AI review requested due to automatic review settings March 2, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds demo-app UI support for Spring Security MFA (password + WebAuthn), including a dedicated WebAuthn challenge page, profile-page MFA status badges, and Playwright coverage to validate the new UI/endpoint wiring.

Changes:

  • Added a new /user/mfa/webauthn-challenge.html Thymeleaf page plus module JS to perform WebAuthn assertion and redirect.
  • Extended the profile “Authentication Methods” card to render MFA status badges sourced from GET /user/mfa/status.
  • Added MFA configuration defaults and Playwright tests; bumped ds-spring-user-framework to 4.2.2-SNAPSHOT.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
build.gradle Bumps demo dependency to framework snapshot that includes MFA support.
src/main/resources/application.yml Enables MFA and configures required factors + entry-point URIs.
src/main/resources/application-playwright-test.yml Disables MFA for Playwright profile runs.
src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java Adds route to render the MFA WebAuthn challenge page.
src/main/resources/templates/user/mfa/webauthn-challenge.html New challenge page template shown when WebAuthn factor is missing.
src/main/resources/static/js/user/mfa-webauthn-challenge.js New challenge page logic that calls authenticateWithPasskey() and redirects.
src/main/resources/templates/user/update-user.html Adds an MFA status container section for JS-populated badges.
src/main/resources/static/js/user/webauthn-manage.js Fetches MFA status and renders badges in the auth-methods card.
playwright/tests/mfa/mfa-challenge.spec.ts New E2E checks for challenge page structure and MFA status endpoint behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +277 to +280
if (!response.ok) {
container.classList.add('d-none');
return;
}

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateMfaStatusUI() currently hides the MFA section on any non-OK response, which can mask real server-side errors (500s) and make debugging harder. Consider only hiding on 404 (MFA disabled) and treating other statuses as an error state (log + optional user-visible message).

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +68
// With MFA disabled in playwright-test profile, expect 404
// With MFA enabled, expect 200 with proper response shape
expect([200, 404]).toContain(response.status());

if (response.status() === 200) {
const body = await response.json();
expect(typeof body.mfaEnabled).toBe('boolean');
expect(typeof body.fullyAuthenticated).toBe('boolean');
}

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is non-deterministic for the playwright-test profile: application-playwright-test.yml sets user.mfa.enabled: false, so /user/mfa/status should consistently return 404. Allowing both 200 and 404 weakens the assertion and can hide regressions; consider asserting 404 here and adding a separate test/project/profile where MFA is enabled to assert the 200 + JSON shape case.

Suggested change
// With MFA disabled in playwright-test profile, expect 404
// With MFA enabled, expect 200 with proper response shape
expect([200, 404]).toContain(response.status());
if (response.status() === 200) {
const body = await response.json();
expect(typeof body.mfaEnabled).toBe('boolean');
expect(typeof body.fullyAuthenticated).toBe('boolean');
}
// With MFA disabled in playwright-test profile, this endpoint should return 404
expect(response.status()).toBe(404);
// NOTE: When adding a profile/project with MFA enabled, create a separate test
// that asserts a 200 response and validates the JSON response shape.
// This keeps the playwright-test profile deterministic.

Copilot uses AI. Check for mistakes.
Comment on lines +263 to +266
/**
* Update the MFA Status section in the auth-methods card.
* Silently hides the container if the MFA status endpoint returns 404 (MFA disabled).
*/

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc says the MFA status container is hidden only when the endpoint returns 404, but the current implementation hides it for any non-2xx response. Either update the comment to match the behavior, or change the logic to check response.status === 404 specifically and handle other error statuses differently (e.g., log/show a message).

Copilot uses AI. Check for mistakes.
@devondragon

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. Merging this PR will downgrade Spring Boot from 4.0.4 to 4.0.3 and Selenide from 7.15.0 to 7.14.0. The branch diverged from main before commit 67edb8b which bumped both versions. A rebase onto current main (or manually updating the two version strings) is needed before merge.

id 'java'
id 'org.springframework.boot' version '4.0.3'
id 'io.spring.dependency-management' version '1.1.7'

testImplementation 'com.h2database:h2:2.4.240'
testImplementation 'com.codeborne:selenide:7.14.0'
testImplementation 'io.github.bonigarcia:webdrivermanager:6.3.3'

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Add WebAuthn challenge page for MFA second-factor verification,
MFA status display on the user profile page, and Playwright tests.
Companion to SpringUserFramework#268 / PR #272.

- Add user.mfa config block to application.yml (PASSWORD + WEBAUTHN)
- Create /user/mfa/webauthn-challenge.html template and JS module
- Add controller route in PageController for the challenge page
- Extend auth-methods card on profile page with MFA status badges
- Add Playwright tests for challenge page structure and MFA status endpoint
- Disable MFA in playwright-test profile to keep existing tests unaffected
- Bump ds-spring-user-framework to 4.2.2-SNAPSHOT

Closes #59
- Check for 404 specifically in updateMfaStatusUI() instead of hiding
  on any non-OK response; log a warning for other error statuses
- Update JSDoc to document both 404 and non-OK handling behaviors
- Make MFA status test assertion deterministic (expect 404 when MFA
  is disabled in playwright-test profile, not [200, 404])
- Simplify test assertion to expect 404 when MFA is disabled in playwright-test profile
- Remove unnecessary CSRF header from GET request to /user/mfa/status
- Add noscript fallback message on webauthn challenge page
@devondragon devondragon force-pushed the feature/mfa-challenge-ui branch from 9175efc to 6583b42 Compare March 22, 2026 18:21
… testing

- Add registration-guard profile to Configuration Profiles section
- Update Testing Strategy to reflect Playwright as the primary E2E framework
  with Selenide as legacy
Resolves CLAUDE.md conflict (Selenide tests were migrated to Playwright on
main) and picks up the TestDataController Date/Instant fix required to
compile against framework 4.3.1.
The challenge page (webauthnEntryPointUri) was not in unprotectedURIs, so a
partially-authenticated user redirected there was denied access and redirected
back to the same page forever (ERR_TOO_MANY_REDIRECTS). The framework only
auto-unprotects /user/mfa/status, not the entry point pages.

MFA is now disabled by default and enabled via the new 'mfa' profile: with it
forced on, users (including all new registrations) who have no passkey cannot
satisfy the WEBAUTHN factor and are locked out of every protected page. The
mfa profile also unprotects the passkey enrollment endpoints so partially-
authenticated users can register their first passkey.

The test profile now disables MFA explicitly; with it inherited as enabled,
AdminRoleAccessControlTest failed 4 tests (302 instead of 200/403) because
MockMvc test users carry no FactorGrantedAuthority.

Adds MfaChallengeFlowIntegrationTest (redirect-loop regression guard, status
envelope contract) and MfaConfigConsistencyTest (keeps entry point URIs and
unprotectedURIs in sync).
The cancel/sign-out locator matched both the navbar logout and the page's
cancel button (strict mode violation) — target the accessible name instead,
and drop the discouraged networkidle wait. The unauthenticated status-endpoint
test expected 404, but with MFA disabled the endpoint is simply protected, so
Spring Security 302-redirects to the login page; assert that instead.
Without @EnableMultiFactorAuthentication, a successful WebAuthn assertion
REPLACED the session authentication instead of adding to it: the user went
from satisfiedFactors=[PASSWORD] to [WEBAUTHN], never both, bouncing between
the two factor challenges forever. Spring Security 7 only merges authorities
across logins when mfaEnabled is set on the authentication filters, which the
annotation's BeanPostProcessor does. The framework's user.mfa support
configures the authorization half but not this filter half, so the demo wires
it explicitly (conditional on user.mfa.enabled).
/user/mfa/status returns {success, messages, data: {mfaEnabled, ...}} but the
badge renderer read the fields from the top level. Every field came back
undefined, so a fully-authenticated user was always shown the 'Additional
Factor Required' badge and never the MFA Active / Fully Authenticated ones.
Deleting a user with registered passkeys failed on the user_entities foreign
key. Publish UserPreDeleteEvent so the framework's WebAuthnPreDeleteEventListener
removes the credentials and user entity first, same as the normal deletion flow.
Chromium's CDP WebAuthn virtual authenticator drives the entire flow without
hardware: enroll a passkey, password login, get redirected to the challenge
page, verify with the passkey, confirm both factors are satisfied and the
profile page renders the MFA badges. Runs in a new chromium-mfa Playwright
project (tagged @mfa-enabled) against a server started with the mfa profile;
the default projects exclude the tag since their specs assume MFA is off.
The webServer profiles are now overridable via SPRING_PROFILES.
Until now CI only ran CodeQL and review jobs, so a branch could be green
without compiling or passing a single test. Adds a unit/integration test job
(H2) and a Playwright job with a MariaDB service container that runs the
default chromium project (MFA off) and the chromium-mfa project (MFA on).
Comment on lines +14 to +36
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21

- uses: gradle/actions/setup-gradle@v4

- name: Run tests
run: ./gradlew test

- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: gradle-test-report
path: build/reports/tests/test/

playwright-tests:
Comment on lines +37 to +99
name: Playwright E2E Tests
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:12.2
env:
MARIADB_DATABASE: springuser
MARIADB_USER: springuser
MARIADB_PASSWORD: springuser
MARIADB_ROOT_PASSWORD: rootpassword
ports:
- 3306:3306
options: >-
--health-cmd="healthcheck.sh --connect --innodb_initialized"
--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"
steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21

- uses: gradle/actions/setup-gradle@v4

- name: Build application
run: ./gradlew assemble

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: playwright/package-lock.json

- name: Install Playwright dependencies
working-directory: playwright
run: |
npm ci
npx playwright install --with-deps chromium

- name: Run E2E tests (MFA disabled)
working-directory: playwright
env:
SPRING_PROFILES: playwright-test
run: npx playwright test --project=chromium

- name: Run E2E tests (MFA enabled)
working-directory: playwright
env:
SPRING_PROFILES: playwright-test,mfa
run: npx playwright test --project=chromium-mfa

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright/reports/
Spring Boot's relaxed binding maps the SPRING_PROFILES environment variable
to the invalid 'spring.profiles' property and refuses to start. APP_PROFILES
is only read by the Playwright webServer command.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Add MFA challenge UI pages and demo support

3 participants