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.) diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts new file mode 100644 index 0000000..8efd928 --- /dev/null +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -0,0 +1,75 @@ +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'); + + // 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 }) => { + // 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); + }); + }); +}); 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..c902917 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -260,6 +260,76 @@ async function handleRegisterPasskey() { } } +/** + * Update the MFA Status section in the auth-methods card. + * 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'); + const badgesEl = document.getElementById('mfaStatusBadges'); + if (!container || !badgesEl) return; + + try { + const response = await fetch('/user/mfa/status'); + + 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; + } + + 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 +374,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..3726d71 --- /dev/null +++ b/src/main/resources/templates/user/mfa/webauthn-challenge.html @@ -0,0 +1,57 @@ + + + + + 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
+
+