From 2e803e0fae1f663c9f59546e05cfe18e2a68d4ce Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Mar 2026 11:01:35 -0700 Subject: [PATCH 01/12] feat: add MFA challenge UI pages and demo support 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 --- playwright/tests/mfa/mfa-challenge.spec.ts | 82 +++++++++++++++++++ .../demo/controller/PageController.java | 9 ++ .../resources/application-playwright-test.yml | 2 + src/main/resources/application.yml | 8 ++ .../static/js/user/mfa-webauthn-challenge.js | 59 +++++++++++++ .../static/js/user/webauthn-manage.js | 68 +++++++++++++++ .../user/mfa/webauthn-challenge.html | 49 +++++++++++ .../resources/templates/user/update-user.html | 7 ++ 8 files changed, 284 insertions(+) create mode 100644 playwright/tests/mfa/mfa-challenge.spec.ts create mode 100644 src/main/resources/static/js/user/mfa-webauthn-challenge.js create mode 100644 src/main/resources/templates/user/mfa/webauthn-challenge.html diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts new file mode 100644 index 0000000..f94bc64 --- /dev/null +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures'; + +test.describe('MFA', () => { + test.describe('Challenge Page', () => { + test('should render the MFA WebAuthn challenge page structure', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + // Login first so we have a session (page requires auth when MFA is disabled) + const user = generateTestUser('mfa-page'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + + // Navigate to the challenge page + await page.goto('/user/mfa/webauthn-challenge.html'); + await page.waitForLoadState('domcontentloaded'); + + // Verify page structure + await expect(page.locator('.card-header')).toContainText('Additional Verification Required'); + await expect(page.locator('#verifyPasskeyBtn')).toBeVisible(); + }); + + test('should have a cancel/sign out option', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('mfa-cancel'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + + await page.goto('/user/mfa/webauthn-challenge.html'); + await page.waitForLoadState('domcontentloaded'); + + // Verify cancel/sign out button is present (inside the logout form) + await page.waitForLoadState('networkidle'); + await expect( + page.locator('form[action*="logout"] button[type="submit"]') + ).toBeVisible(); + }); + }); + + test.describe('MFA Status Endpoint', () => { + test('should handle MFA status request for authenticated user', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('mfa-status'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + + // Call the MFA status endpoint + const response = await page.request.get('/user/mfa/status'); + + // 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'); + } + }); + + test('should require authentication for MFA status endpoint', async ({ page }) => { + // Call without authentication + const response = await page.request.get('/user/mfa/status', { + maxRedirects: 0, + }); + + // Should not return 200 for unauthenticated request + // Expect redirect to login (302/303) or error (401/403) or 404 (MFA disabled) + expect([302, 303, 401, 403, 404]).toContain(response.status()); + }); + }); +}); diff --git a/src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java b/src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java index 13ec813..2a6f3c0 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java @@ -51,5 +51,14 @@ public String terms() { return "terms"; } + /** + * MFA WebAuthn Challenge Page. + * + * @return the path to the MFA WebAuthn challenge page + */ + @GetMapping("/user/mfa/webauthn-challenge.html") + public String mfaWebAuthnChallenge() { + return "user/mfa/webauthn-challenge"; + } } diff --git a/src/main/resources/application-playwright-test.yml b/src/main/resources/application-playwright-test.yml index c2c86dc..0fd2873 100644 --- a/src/main/resources/application-playwright-test.yml +++ b/src/main/resources/application-playwright-test.yml @@ -19,6 +19,8 @@ spring: # Enable test API endpoints by adding them to unprotected URIs user: + mfa: + enabled: false registration: # Disable email sending since tests use Test API for token retrieval sendVerificationEmail: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e5d5538..f273d97 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -111,6 +111,14 @@ user: rpName: Spring User Framework Demo allowedOrigins: http://localhost:8080 + mfa: + enabled: true + factors: + - PASSWORD + - WEBAUTHN + passwordEntryPointUri: /user/login.html + webauthnEntryPointUri: /user/mfa/webauthn-challenge.html + audit: logFilePath: /opt/app/logs/user-audit.log # The path to the audit log file. flushOnWrite: false # If true, the audit log will be flushed to disk after every write (less performant). If false, the audit log will be flushed to disk every 10 seconds (more performant). diff --git a/src/main/resources/static/js/user/mfa-webauthn-challenge.js b/src/main/resources/static/js/user/mfa-webauthn-challenge.js new file mode 100644 index 0000000..6d0dc5d --- /dev/null +++ b/src/main/resources/static/js/user/mfa-webauthn-challenge.js @@ -0,0 +1,59 @@ +/** + * MFA WebAuthn challenge page — prompts the user to verify with their passkey + * after initial password authentication when MFA is enabled. + */ +import { showMessage } from '/js/shared.js'; +import { isWebAuthnSupported } from '/js/user/webauthn-utils.js'; +import { authenticateWithPasskey } from '/js/user/webauthn-authenticate.js'; + +const BUTTON_LABEL = 'Verify with Passkey'; +const BUTTON_ICON_CLASS = 'bi bi-key me-2'; + +function setButtonReady(btn) { + btn.textContent = ''; + const icon = document.createElement('i'); + icon.className = BUTTON_ICON_CLASS; + btn.appendChild(icon); + btn.appendChild(document.createTextNode(' ' + BUTTON_LABEL)); +} + +function setButtonLoading(btn) { + btn.textContent = ''; + const spinner = document.createElement('span'); + spinner.className = 'spinner-border spinner-border-sm me-2'; + btn.appendChild(spinner); + btn.appendChild(document.createTextNode(' Verifying...')); +} + +document.addEventListener('DOMContentLoaded', () => { + const verifyBtn = document.getElementById('verifyPasskeyBtn'); + const errorEl = document.getElementById('challengeError'); + + if (!verifyBtn) return; + + if (!isWebAuthnSupported()) { + verifyBtn.disabled = true; + showMessage(errorEl, + 'Your browser does not support passkeys. Please use a different browser or contact support.', + 'alert-danger'); + return; + } + + verifyBtn.addEventListener('click', async () => { + verifyBtn.disabled = true; + setButtonLoading(verifyBtn); + errorEl.classList.add('d-none'); + + try { + const redirectUrl = await authenticateWithPasskey(); + window.location.href = redirectUrl; + } catch (error) { + console.error('MFA WebAuthn challenge failed:', error); + showMessage(errorEl, + 'Verification failed. Please try again or cancel and sign out.', + 'alert-danger'); + verifyBtn.disabled = false; + setButtonReady(verifyBtn); + } + }); +}); diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index febc403..331509f 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -260,6 +260,71 @@ async function handleRegisterPasskey() { } } +/** + * Update the MFA Status section in the auth-methods card. + * Silently hides the container if the MFA status endpoint returns 404 (MFA disabled). + */ +async function updateMfaStatusUI() { + const container = document.getElementById('mfaStatusContainer'); + const badgesEl = document.getElementById('mfaStatusBadges'); + if (!container || !badgesEl) return; + + try { + const response = await fetch('/user/mfa/status', { + headers: { [csrfHeader]: csrfToken } + }); + + if (!response.ok) { + container.classList.add('d-none'); + return; + } + + const status = await response.json(); + container.classList.remove('d-none'); + + // Build MFA badges using safe DOM methods + badgesEl.textContent = ''; + + if (status.mfaEnabled) { + badgesEl.appendChild(createBadge('MFA Active', 'bg-primary', 'bi-shield-lock')); + } + + if (status.fullyAuthenticated) { + badgesEl.appendChild(createBadge('Fully Authenticated', 'bg-success', 'bi-shield-check')); + } else { + badgesEl.appendChild(createBadge('Additional Factor Required', 'bg-warning text-dark', 'bi-shield-exclamation')); + } + + if (Array.isArray(status.satisfiedFactors)) { + status.satisfiedFactors.forEach(factor => { + badgesEl.appendChild(createBadge(factor, 'bg-secondary', 'bi-check-circle')); + }); + } + + if (Array.isArray(status.missingFactors) && status.missingFactors.length > 0) { + status.missingFactors.forEach(factor => { + badgesEl.appendChild(createBadge(factor + ' (pending)', 'bg-danger', 'bi-x-circle')); + }); + } + } catch (error) { + console.error('Failed to fetch MFA status:', error); + container.classList.add('d-none'); + } +} + +/** + * Create a Bootstrap badge span element with an icon. + */ +function createBadge(text, bgClass, iconClass) { + const badge = document.createElement('span'); + badge.className = `badge ${bgClass} me-2`; + const icon = document.createElement('i'); + icon.className = `bi ${iconClass} me-1`; + badge.appendChild(icon); + badge.appendChild(document.createTextNode(text)); + return badge; +} + /** * Update the Authentication Methods UI card with current state. */ @@ -304,6 +369,9 @@ async function updateAuthMethodsUI() { if (changePasswordLink) { changePasswordLink.textContent = auth.hasPassword ? 'Change Password' : 'Set a Password'; } + + // Update MFA status section + await updateMfaStatusUI(); } catch (error) { console.error('Failed to update auth methods UI:', error); const section = document.getElementById('auth-methods-section'); diff --git a/src/main/resources/templates/user/mfa/webauthn-challenge.html b/src/main/resources/templates/user/mfa/webauthn-challenge.html new file mode 100644 index 0000000..8d0eafb --- /dev/null +++ b/src/main/resources/templates/user/mfa/webauthn-challenge.html @@ -0,0 +1,49 @@ + + + + + Verify Your Identity + + + +
+
+
+
+
+
+
+
Additional Verification Required
+
+
+

+ Your account requires an additional verification step. + Please verify your identity using your passkey. +

+ + + + + + +
+
+ +
+
+
+
+
+
+
+
+ + +
+ + + diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index dbf9eef..be1d2b5 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -64,6 +64,13 @@
Authentication Methods Set a Password + + +
+
+
Multi-Factor Authentication
+
+
From f618d70092003c1319145408a3c625f0ff14a013 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Mar 2026 11:43:25 -0700 Subject: [PATCH 02/12] fix: address PR review findings for MFA challenge UI - 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]) --- playwright/tests/mfa/mfa-challenge.spec.ts | 12 +++--------- src/main/resources/static/js/user/webauthn-manage.js | 9 ++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts index f94bc64..9791971 100644 --- a/playwright/tests/mfa/mfa-challenge.spec.ts +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -57,15 +57,9 @@ test.describe('MFA', () => { // Call the MFA status endpoint const response = await page.request.get('/user/mfa/status'); - // 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'); - } + // MFA is disabled in playwright-test profile, so endpoint returns 404. + // A separate MFA-enabled test profile would be needed to test the 200 case. + expect(response.status()).toBe(404); }); test('should require authentication for MFA status endpoint', async ({ page }) => { diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 331509f..9202fc2 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -262,7 +262,8 @@ async function handleRegisterPasskey() { /** * Update the MFA Status section in the auth-methods card. - * Silently hides the container if the MFA status endpoint returns 404 (MFA disabled). + * Hides the container if the MFA status endpoint returns 404 (MFA disabled). + * Logs a warning for other non-OK responses. */ async function updateMfaStatusUI() { const container = document.getElementById('mfaStatusContainer'); @@ -274,7 +275,13 @@ async function updateMfaStatusUI() { headers: { [csrfHeader]: csrfToken } }); + if (response.status === 404) { + // MFA feature disabled — silently hide + container.classList.add('d-none'); + return; + } if (!response.ok) { + console.warn('MFA status endpoint returned', response.status); container.classList.add('d-none'); return; } From 6583b4287b81674c781d739442ceaf25129fdd75 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 11:00:30 -0600 Subject: [PATCH 03/12] fix: MFA challenge UI improvements - 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 --- playwright/tests/mfa/mfa-challenge.spec.ts | 5 ++--- src/main/resources/static/js/user/webauthn-manage.js | 4 +--- .../resources/templates/user/mfa/webauthn-challenge.html | 8 ++++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts index 9791971..8efd928 100644 --- a/playwright/tests/mfa/mfa-challenge.spec.ts +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -68,9 +68,8 @@ test.describe('MFA', () => { maxRedirects: 0, }); - // Should not return 200 for unauthenticated request - // Expect redirect to login (302/303) or error (401/403) or 404 (MFA disabled) - expect([302, 303, 401, 403, 404]).toContain(response.status()); + // MFA is disabled in playwright-test profile, so endpoint returns 404 + expect(response.status()).toBe(404); }); }); }); diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 9202fc2..c902917 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -271,9 +271,7 @@ async function updateMfaStatusUI() { if (!container || !badgesEl) return; try { - const response = await fetch('/user/mfa/status', { - headers: { [csrfHeader]: csrfToken } - }); + const response = await fetch('/user/mfa/status'); if (response.status === 404) { // MFA feature disabled — silently hide diff --git a/src/main/resources/templates/user/mfa/webauthn-challenge.html b/src/main/resources/templates/user/mfa/webauthn-challenge.html index 8d0eafb..3726d71 100644 --- a/src/main/resources/templates/user/mfa/webauthn-challenge.html +++ b/src/main/resources/templates/user/mfa/webauthn-challenge.html @@ -42,6 +42,14 @@
Additional Verification Required
+ + From c3017fdb9bafc69dc7693165a799e107ef301bc3 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 12:25:18 -0600 Subject: [PATCH 04/12] docs: update CLAUDE.md with registration-guard profile and Playwright testing - Add registration-guard profile to Configuration Profiles section - Update Testing Strategy to reflect Playwright as the primary E2E framework with Selenide as legacy --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3822f1a..4babfdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,17 +58,19 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht 4. **Testing Strategy**: - Unit tests for individual components - Integration tests using `@IntegrationTest` annotation (combines Spring Boot test setup) - - UI tests with Selenide for end-to-end testing + - E2E tests with Playwright (`playwright/tests/`) — primary UI test framework + - Legacy UI tests with Selenide (`src/test/java/.../user/ui/`) - API tests using MockMvc for REST endpoints ### Important Conventions 1. **No Custom User Entity**: This demo uses the framework's User entity directly. Custom user data goes in separate entities (like UserProfile). -2. **Configuration Profiles**: +2. **Configuration Profiles**: - `local`: Development with local database - `test`: Integration testing with H2 - `docker-keycloak`: OIDC integration with Keycloak + - `registration-guard`: Enables domain-restricted registration (form/passwordless only) 3. **Template Organization**: All Thymeleaf templates are in `src/main/resources/templates/` with subdirectories for user management (`email/`, `password/`, etc.) From d25ad94dc3b923b3a9fe37d00b537489b1bf9ddc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:35:06 -0600 Subject: [PATCH 05/12] fix: unprotect MFA challenge page and make MFA opt-in via mfa profile 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). --- CLAUDE.md | 1 + src/main/resources/application-mfa.yml | 22 +++++ src/main/resources/application.yml | 9 +- .../mfa/MfaChallengeFlowIntegrationTest.java | 96 +++++++++++++++++++ .../demo/mfa/MfaConfigConsistencyTest.java | 82 ++++++++++++++++ .../resources/application-test.properties | 7 +- 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/application-mfa.yml create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaChallengeFlowIntegrationTest.java create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 43c1ba4..107ffb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht - `test`: Integration testing with H2 - `docker-keycloak`: OIDC integration with Keycloak - `registration-guard`: Enables domain-restricted registration (form/passwordless only) + - `mfa`: Enables multi-factor authentication (PASSWORD + WEBAUTHN); combine with another profile, e.g. `local,mfa` 3. **Template Organization**: All Thymeleaf templates are in `src/main/resources/templates/` with subdirectories for user management (`email/`, `password/`, etc.) diff --git a/src/main/resources/application-mfa.yml b/src/main/resources/application-mfa.yml new file mode 100644 index 0000000..f5142c5 --- /dev/null +++ b/src/main/resources/application-mfa.yml @@ -0,0 +1,22 @@ +# MFA Demo Profile +# +# Activates multi-factor authentication: after a password login, users must also verify with a +# passkey (WebAuthn) before reaching any protected page. +# +# Run alongside your normal profile, e.g.: +# ./gradlew bootRun --args='--spring.profiles.active=local,mfa' +# +# Notes: +# - The challenge page (user.mfa.webauthnEntryPointUri) must be in unprotectedURIs. The framework +# redirects partially-authenticated users to it; if the page itself required full authentication, +# the redirect would loop forever. +# - The passkey registration endpoints (/webauthn/register/options, /webauthn/register) are also +# unprotected here so that a partially-authenticated user can enroll their first passkey. Spring +# Security still requires an authenticated principal to register a credential; this only relaxes +# the all-factors requirement. Without this, a new user could never satisfy the WEBAUTHN factor. + +user: + mfa: + enabled: true + security: + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html,/webauthn/register/options,/webauthn/register diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f273d97..15a17f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -112,11 +112,16 @@ user: allowedOrigins: http://localhost:8080 mfa: - enabled: true + # Disabled by default: when MFA is on, users without a registered passkey cannot satisfy the + # WEBAUTHN factor and are locked out of all protected pages. Run with the 'mfa' profile (or set + # this to true) to see the MFA challenge flow in action. + enabled: false factors: - PASSWORD - WEBAUTHN passwordEntryPointUri: /user/login.html + # This page MUST be in unprotectedURIs below: partially-authenticated users are redirected here, + # and if the page itself requires full authentication the redirect loops forever. webauthnEntryPointUri: /user/mfa/webauthn-challenge.html audit: @@ -131,7 +136,7 @@ user: bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. defaultAction: deny # The default action for all requests. This can be either deny or allow. - unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/registration/passwordless,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn,/user/mfa/webauthn-challenge.html # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. diff --git a/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaChallengeFlowIntegrationTest.java b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaChallengeFlowIntegrationTest.java new file mode 100644 index 0000000..4fdc34d --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaChallengeFlowIntegrationTest.java @@ -0,0 +1,96 @@ +package com.digitalsanctuary.spring.demo.mfa; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.FactorGrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import com.digitalsanctuary.spring.user.test.annotations.IntegrationTest; + +/** + * Verifies the demo's MFA challenge flow wiring against the framework's Spring Security 7 MFA support. + * + * The critical contract: the WebAuthn challenge page (the configured webauthnEntryPointUri) MUST be reachable by a + * partially-authenticated user (PASSWORD factor only). If it is not in the unprotected URIs list, the access-denied + * handler redirects the user to the page they were just denied access to, producing an infinite redirect loop. + */ +@IntegrationTest +@TestPropertySource(properties = { + "user.mfa.enabled=true", + "user.mfa.factors=PASSWORD,WEBAUTHN", + "user.mfa.passwordEntryPointUri=/user/login.html", + "user.mfa.webauthnEntryPointUri=/user/mfa/webauthn-challenge.html" +}) +@DisplayName("MFA Challenge Flow Integration Tests") +class MfaChallengeFlowIntegrationTest { + + private static final String CHALLENGE_PAGE = "/user/mfa/webauthn-challenge.html"; + + @Autowired + private MockMvc mockMvc; + + private static List passwordOnlyAuthorities() { + return List.of( + new SimpleGrantedAuthority("ROLE_USER"), + FactorGrantedAuthority.fromAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY)); + } + + private static List allFactorAuthorities() { + return List.of( + new SimpleGrantedAuthority("ROLE_USER"), + FactorGrantedAuthority.fromAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY), + FactorGrantedAuthority.fromAuthority(FactorGrantedAuthority.WEBAUTHN_AUTHORITY)); + } + + @Test + @DisplayName("partially-authenticated user is redirected to the WebAuthn challenge page") + void partiallyAuthenticatedUserIsRedirectedToChallengePage() throws Exception { + mockMvc.perform(get("/api/events").with(user("user@test.com").authorities(passwordOnlyAuthorities()))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrlPattern(CHALLENGE_PAGE + "**")); + } + + @Test + @DisplayName("challenge page is accessible to a partially-authenticated user (no redirect loop)") + void challengePageIsAccessibleToPartiallyAuthenticatedUser() throws Exception { + // Regression guard: if the challenge page is not in unprotectedURIs, this returns 302 back to + // itself and real browsers fail with ERR_TOO_MANY_REDIRECTS. + mockMvc.perform(get(CHALLENGE_PAGE).with(user("user@test.com").authorities(passwordOnlyAuthorities()))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("MFA status reports the missing WEBAUTHN factor inside the JSONResponse data envelope") + void mfaStatusReportsMissingFactorInDataEnvelope() throws Exception { + // The UI (updateMfaStatusUI in webauthn-manage.js) consumes this exact shape: fields live under + // $.data, not at the top level. + mockMvc.perform(get("/user/mfa/status").with(user("user@test.com").authorities(passwordOnlyAuthorities()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.mfaEnabled").value(true)) + .andExpect(jsonPath("$.data.fullyAuthenticated").value(false)) + .andExpect(jsonPath("$.data.satisfiedFactors[0]").value("PASSWORD")) + .andExpect(jsonPath("$.data.missingFactors[0]").value("WEBAUTHN")); + } + + @Test + @DisplayName("fully-authenticated user can access protected endpoints and reports fullyAuthenticated") + void fullyAuthenticatedUserCanAccessProtectedPages() throws Exception { + mockMvc.perform(get("/api/events").with(user("user@test.com").authorities(allFactorAuthorities()))) + .andExpect(status().isOk()); + + mockMvc.perform(get("/user/mfa/status").with(user("user@test.com").authorities(allFactorAuthorities()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.fullyAuthenticated").value(true)) + .andExpect(jsonPath("$.data.missingFactors").isEmpty()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java new file mode 100644 index 0000000..d8d428e --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/demo/mfa/MfaConfigConsistencyTest.java @@ -0,0 +1,82 @@ +package com.digitalsanctuary.spring.demo.mfa; + +import static org.assertj.core.api.Assertions.assertThat; +import java.io.InputStream; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +/** + * Guards the consistency of the MFA configuration files themselves. + * + * The MFA entry point pages must be listed in {@code user.security.unprotectedURIs}: the framework's + * access-denied handler redirects partially-authenticated users to the entry point URI, and if that page is itself + * protected the redirect loops forever. The framework only auto-unprotects {@code /user/mfa/status}, not the entry + * point pages, so the demo config has to keep these two settings in sync by hand. + */ +@DisplayName("MFA Config Consistency Tests") +class MfaConfigConsistencyTest { + + @SuppressWarnings("unchecked") + private static Map loadYaml(String resource) { + InputStream in = MfaConfigConsistencyTest.class.getResourceAsStream(resource); + assertThat(in).as("config file %s should exist on the classpath", resource).isNotNull(); + return new Yaml().load(in); + } + + @SuppressWarnings("unchecked") + private static Map section(Map yaml, String... path) { + Map current = yaml; + for (String key : path) { + Object value = current.get(key); + assertThat(value).as("section %s should exist", String.join(".", path)).isInstanceOf(Map.class); + current = (Map) value; + } + return current; + } + + private static List unprotectedUris(Map yaml) { + Object uris = section(yaml, "user", "security").get("unprotectedURIs"); + assertThat(uris).as("user.security.unprotectedURIs should be set").isNotNull(); + return Arrays.stream(uris.toString().split(",")).map(String::trim).toList(); + } + + @Test + @DisplayName("application.yml unprotects the configured WebAuthn challenge page") + void baseConfigUnprotectsWebauthnEntryPoint() { + Map yaml = loadYaml("/application.yml"); + Map mfa = section(yaml, "user", "mfa"); + + String webauthnEntryPoint = String.valueOf(mfa.get("webauthnEntryPointUri")); + assertThat(webauthnEntryPoint).as("user.mfa.webauthnEntryPointUri should be configured").isNotEqualTo("null"); + assertThat(unprotectedUris(yaml)) + .as("the WebAuthn challenge page must be unprotected or MFA redirects loop forever") + .contains(webauthnEntryPoint); + } + + @Test + @DisplayName("application.yml leaves MFA disabled by default (opt-in via the mfa profile)") + void baseConfigLeavesMfaDisabled() { + // A user who logs in with a password but has no registered passkey cannot satisfy the WEBAUTHN + // factor and is locked out of every protected page, so the demo must not force MFA on by default. + Map mfa = section(loadYaml("/application.yml"), "user", "mfa"); + assertThat(mfa.get("enabled")).isEqualTo(Boolean.FALSE); + } + + @Test + @DisplayName("mfa profile enables MFA and unprotects the challenge page and passkey enrollment endpoints") + void mfaProfileEnablesMfaAndUnprotectsEntryPoint() { + Map yaml = loadYaml("/application-mfa.yml"); + Map mfa = section(yaml, "user", "mfa"); + assertThat(mfa.get("enabled")).isEqualTo(Boolean.TRUE); + + List uris = unprotectedUris(yaml); + assertThat(uris).contains("/user/mfa/webauthn-challenge.html"); + // Partially-authenticated users need to be able to enroll their first passkey, otherwise new + // accounts can never satisfy the WEBAUTHN factor. + assertThat(uris).contains("/webauthn/register/options", "/webauthn/register"); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index b84556a..1a18723 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -3,10 +3,15 @@ user.security.defaultAction=deny # Used if default is allow user.security.protectedURIs=/protected.html # Used if default is deny -user.security.unprotectedURIs=/,/index.html,/css/*,/js/*,/img/*,/register.html,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/login +# The MFA challenge page must stay unprotected: MFA tests redirect partially-authenticated users to +# it, and a protected challenge page would redirect back to itself forever. +user.security.unprotectedURIs=/,/index.html,/css/*,/js/*,/img/*,/register.html,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/login,/user/mfa/webauthn-challenge.html user.security.loginPageURI=/login.html +# MFA stays off for the general suite; MFA tests opt in via @TestPropertySource +user.mfa.enabled=false + # Account lockout configuration user.security.maxFailedLoginAttempts=3 user.security.lockoutDurationMinutes=30 From 8f11f7aa2435e6813d30ec56da2a16ff291b6a26 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:36:27 -0600 Subject: [PATCH 06/12] test: fix MFA challenge Playwright specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- playwright/tests/mfa/mfa-challenge.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts index 8efd928..3a9184a 100644 --- a/playwright/tests/mfa/mfa-challenge.spec.ts +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -35,10 +35,10 @@ test.describe('MFA', () => { await page.goto('/user/mfa/webauthn-challenge.html'); await page.waitForLoadState('domcontentloaded'); - // Verify cancel/sign out button is present (inside the logout form) - await page.waitForLoadState('networkidle'); + // Verify cancel/sign out button is present (the navbar also has a logout + // form, so target this button by its accessible name) await expect( - page.locator('form[action*="logout"] button[type="submit"]') + page.getByRole('button', { name: 'Cancel and sign out' }) ).toBeVisible(); }); }); @@ -62,14 +62,16 @@ test.describe('MFA', () => { expect(response.status()).toBe(404); }); - test('should require authentication for MFA status endpoint', async ({ page }) => { + test('should not expose MFA status to unauthenticated requests', async ({ page }) => { // Call without authentication const response = await page.request.get('/user/mfa/status', { maxRedirects: 0, }); - // MFA is disabled in playwright-test profile, so endpoint returns 404 - expect(response.status()).toBe(404); + // With MFA disabled the endpoint is not in unprotectedURIs, so Spring + // Security redirects unauthenticated requests to the login page. + expect(response.status()).toBe(302); + expect(response.headers()['location']).toContain('/user/login.html'); }); }); }); From 40408ca7a3b7bde3138eecc91914d91db605f92e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:50:13 -0600 Subject: [PATCH 07/12] fix: enable Spring Security factor merging so MFA can actually complete 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). --- .../spring/demo/config/MfaSecurityConfig.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java diff --git a/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java new file mode 100644 index 0000000..02edae9 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/demo/config/MfaSecurityConfig.java @@ -0,0 +1,28 @@ +package com.digitalsanctuary.spring.demo.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authorization.EnableMultiFactorAuthentication; + +/** + * Enables Spring Security 7's MFA filter support when {@code user.mfa.enabled=true}. + * + *

+ * {@code @EnableMultiFactorAuthentication} registers a {@code BeanPostProcessor} that sets {@code mfaEnabled} on the + * authentication filters. With it, a second login in the same session (e.g. a WebAuthn assertion after a password + * login) merges the new authentication's authorities with the current ones, accumulating + * {@code FactorGrantedAuthority}s. Without it, the passkey verification replaces the session authentication, + * dropping the PASSWORD factor — the user can then never satisfy both factors and bounces between the two challenge + * pages forever. + *

+ * + *

+ * The {@code authorities} attribute is left empty on purpose: the Spring User Framework already configures the + * required-factor authorization rules from the {@code user.mfa.factors} property. + *

+ */ +@Configuration +@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false) +@EnableMultiFactorAuthentication(authorities = {}) +public class MfaSecurityConfig { +} From 48652c4f808af24700f3c7f9f9cf709e30258dbf Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:50:13 -0600 Subject: [PATCH 08/12] fix: unwrap JSONResponse envelope in MFA status UI /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. --- src/main/resources/static/js/user/webauthn-manage.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index c902917..cd98af2 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -284,7 +284,15 @@ async function updateMfaStatusUI() { return; } - const status = await response.json(); + // The endpoint wraps the status in the framework's JSONResponse envelope: + // { success, messages, data: { mfaEnabled, fullyAuthenticated, ... } } + const body = await response.json(); + const status = body.data; + if (!status) { + console.warn('MFA status response missing data payload'); + container.classList.add('d-none'); + return; + } container.classList.remove('d-none'); // Build MFA badges using safe DOM methods From 2aa2910a80f05db0709dadf46db89fe1dd151b27 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:50:13 -0600 Subject: [PATCH 09/12] fix: clean up WebAuthn credentials when Test API deletes a user 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. --- .../spring/demo/test/api/TestDataController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java index 1aebd08..4fc447b 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java @@ -6,6 +6,7 @@ import java.util.Date; import java.util.HashMap; import java.util.Map; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.digitalsanctuary.spring.demo.user.profile.DemoUserProfileRepository; +import com.digitalsanctuary.spring.user.event.UserPreDeleteEvent; import com.digitalsanctuary.spring.user.persistence.model.PasswordResetToken; import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.persistence.model.User; @@ -50,6 +52,7 @@ public class TestDataController { private final RoleRepository roleRepository; private final PasswordEncoder passwordEncoder; private final DemoUserProfileRepository demoUserProfileRepository; + private final ApplicationEventPublisher eventPublisher; /** * Check if a user exists by email. @@ -231,6 +234,10 @@ public ResponseEntity> deleteTestUser(@RequestParam String e return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } + // Let framework listeners clean up their data first (e.g. WebAuthn credentials and user + // entities, which have a foreign key on the user account) + eventPublisher.publishEvent(new UserPreDeleteEvent(this, user)); + // Delete related entities first to avoid foreign key constraints demoUserProfileRepository.findById(user.getId()).ifPresent(demoUserProfileRepository::delete); From 4748e855f17509d032f776648e71b6de36cf120c Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:50:28 -0600 Subject: [PATCH 10/12] test: add full MFA flow E2E using a virtual authenticator 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. --- playwright/playwright.config.ts | 21 +++++++- playwright/tests/mfa/mfa-flow.spec.ts | 78 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 playwright/tests/mfa/mfa-flow.spec.ts diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index 1f00a2b..aad9120 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -84,38 +84,55 @@ export default defineConfig({ timeout: 10000, }, - /* Configure projects for major browsers */ + /* Configure projects for major browsers. + * + * Tests tagged @mfa-enabled need the server running with the mfa profile and are excluded from + * the default projects (whose specs assume MFA is off). Run them with: + * SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa + */ projects: [ { name: 'chromium', + grepInvert: /@mfa-enabled/, use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', + grepInvert: /@mfa-enabled/, use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', + grepInvert: /@mfa-enabled/, use: { ...devices['Desktop Safari'] }, }, /* Test against mobile viewports */ { name: 'Mobile Chrome', + grepInvert: /@mfa-enabled/, use: { ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', + grepInvert: /@mfa-enabled/, use: { ...devices['iPhone 12'] }, }, + + /* MFA flow tests: Chromium only (CDP virtual authenticator), MFA-enabled server required */ + { + name: 'chromium-mfa', + grep: /@mfa-enabled/, + use: { ...devices['Desktop Chrome'] }, + }, ], /* Run your local dev server before starting the tests */ webServer: { - command: 'cd .. && ./gradlew bootRun --args="--spring.profiles.active=local,playwright-test"', + command: `cd .. && ./gradlew bootRun --args="--spring.profiles.active=${process.env.SPRING_PROFILES || 'local,playwright-test'}"`, url: 'http://localhost:8080', reuseExistingServer: !process.env.CI, timeout: 120000, diff --git a/playwright/tests/mfa/mfa-flow.spec.ts b/playwright/tests/mfa/mfa-flow.spec.ts new file mode 100644 index 0000000..e318a55 --- /dev/null +++ b/playwright/tests/mfa/mfa-flow.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures'; + +/** + * Full MFA flow E2E test using Chromium's CDP WebAuthn virtual authenticator. + * + * Requires the app to run with MFA enabled: + * SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa + * (or start the server yourself with those profiles; the mfa profile must come last so its + * overrides win). + * + * Tagged @mfa-enabled so the default projects skip it — their specs assume MFA is off. + */ +test.describe('MFA Full Flow @mfa-enabled', () => { + test('password login requires passkey verification before reaching protected pages', async ({ + page, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('mfa-e2e'); + cleanupEmails.push(user.email); + + // Set up a virtual authenticator before any WebAuthn ceremony. automaticPresenceSimulation + // auto-approves create()/get() prompts so no human touch is needed. + const cdp = await page.context().newCDPSession(page); + await cdp.send('WebAuthn.enable'); + await cdp.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + + // Password login leaves the user partially authenticated: PASSWORD satisfied, WEBAUTHN missing. + await createAndLoginUser(page, testApiClient, user); + + const partialStatus = await (await page.request.get('/user/mfa/status')).json(); + expect(partialStatus.data.satisfiedFactors).toContain('PASSWORD'); + expect(partialStatus.data.missingFactors).toContain('WEBAUTHN'); + expect(partialStatus.data.fullyAuthenticated).toBe(false); + + // Any protected page redirects to the challenge page (and must not redirect-loop). + await page.goto('/user/update-user.html'); + await expect(page).toHaveURL(/\/user\/mfa\/webauthn-challenge\.html/); + await expect(page.locator('#verifyPasskeyBtn')).toBeVisible(); + + // Enroll the user's first passkey. The mfa profile unprotects the enrollment endpoints so a + // partially-authenticated user can register; the virtual authenticator answers the ceremony. + await page.evaluate(async () => { + const { registerPasskey } = await import('/js/user/webauthn-register.js'); + await registerPasskey('e2e-virtual-passkey'); + }); + + // Complete the challenge with the freshly enrolled passkey. + await page.locator('#verifyPasskeyBtn').click(); + await page.waitForURL((url) => !url.pathname.includes('webauthn-challenge'), { + timeout: 15000, + }); + + // Both factors satisfied now. + const fullStatus = await (await page.request.get('/user/mfa/status')).json(); + expect(fullStatus.data.fullyAuthenticated).toBe(true); + expect(fullStatus.data.missingFactors).toEqual([]); + + // Protected pages are reachable again. + await page.goto('/user/update-user.html'); + await expect(page).toHaveURL(/\/user\/update-user\.html/); + + // And the profile page renders the MFA badges from the status endpoint's data envelope. + const badges = page.locator('#mfaStatusBadges'); + await expect(badges).toContainText('MFA Active'); + await expect(badges).toContainText('Fully Authenticated'); + await expect(badges).not.toContainText('Additional Factor Required'); + }); +}); From c3e7b85f14b9b7a6f6d3dba442c630315a07135d Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:50:28 -0600 Subject: [PATCH 11/12] ci: run gradle and Playwright suites on pull requests 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). --- .github/workflows/tests.yml | 99 +++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5531b4e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,99 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + 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: + 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/ From bcdc481ff57430b6ef1cc25e013171d908dcac40 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 11 Jun 2026 20:55:33 -0600 Subject: [PATCH 12/12] ci: rename profile env var so Spring Boot does not bind it 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. --- .github/workflows/tests.yml | 4 ++-- playwright/playwright.config.ts | 4 ++-- playwright/tests/mfa/mfa-flow.spec.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5531b4e..7c1cd48 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -82,13 +82,13 @@ jobs: - name: Run E2E tests (MFA disabled) working-directory: playwright env: - SPRING_PROFILES: playwright-test + APP_PROFILES: playwright-test run: npx playwright test --project=chromium - name: Run E2E tests (MFA enabled) working-directory: playwright env: - SPRING_PROFILES: playwright-test,mfa + APP_PROFILES: playwright-test,mfa run: npx playwright test --project=chromium-mfa - name: Upload Playwright report diff --git a/playwright/playwright.config.ts b/playwright/playwright.config.ts index aad9120..821c5ac 100644 --- a/playwright/playwright.config.ts +++ b/playwright/playwright.config.ts @@ -88,7 +88,7 @@ export default defineConfig({ * * Tests tagged @mfa-enabled need the server running with the mfa profile and are excluded from * the default projects (whose specs assume MFA is off). Run them with: - * SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa + * APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa */ projects: [ { @@ -132,7 +132,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: `cd .. && ./gradlew bootRun --args="--spring.profiles.active=${process.env.SPRING_PROFILES || 'local,playwright-test'}"`, + command: `cd .. && ./gradlew bootRun --args="--spring.profiles.active=${process.env.APP_PROFILES || 'local,playwright-test'}"`, url: 'http://localhost:8080', reuseExistingServer: !process.env.CI, timeout: 120000, diff --git a/playwright/tests/mfa/mfa-flow.spec.ts b/playwright/tests/mfa/mfa-flow.spec.ts index e318a55..492cf0f 100644 --- a/playwright/tests/mfa/mfa-flow.spec.ts +++ b/playwright/tests/mfa/mfa-flow.spec.ts @@ -4,7 +4,7 @@ import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fi * Full MFA flow E2E test using Chromium's CDP WebAuthn virtual authenticator. * * Requires the app to run with MFA enabled: - * SPRING_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa + * APP_PROFILES=local,playwright-test,mfa npx playwright test --project=chromium-mfa * (or start the server yourself with those profiles; the mfa profile must come last so its * overrides win). *