From e36a82cb890eb6808282ff5874974e837ebdeacc Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Mar 2026 11:01:35 -0700 Subject: [PATCH 1/2] 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 --- build.gradle | 2 +- 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 ++ 9 files changed, 285 insertions(+), 1 deletion(-) 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/build.gradle b/build.gradle index d497fae..97134a6 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.1' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.2-SNAPSHOT' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' 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 fce7a8a2e2f992d4557fa91f0275e4cbe9c0f6a0 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Mon, 2 Mar 2026 11:43:25 -0700 Subject: [PATCH 2/2] 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; }