diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7c1cd48 --- /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: + APP_PROFILES: playwright-test + run: npx playwright test --project=chromium + + - name: Run E2E tests (MFA enabled) + working-directory: playwright + env: + APP_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/ diff --git a/CLAUDE.md b/CLAUDE.md index 08a2111..107ffb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,10 +62,12 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht 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) + - `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/playwright/playwright.config.ts b/playwright/playwright.config.ts index 1f00a2b..821c5ac 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: + * APP_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.APP_PROFILES || 'local,playwright-test'}"`, url: 'http://localhost:8080', reuseExistingServer: !process.env.CI, timeout: 120000, diff --git a/playwright/tests/mfa/mfa-challenge.spec.ts b/playwright/tests/mfa/mfa-challenge.spec.ts new file mode 100644 index 0000000..3a9184a --- /dev/null +++ b/playwright/tests/mfa/mfa-challenge.spec.ts @@ -0,0 +1,77 @@ +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 (the navbar also has a logout + // form, so target this button by its accessible name) + await expect( + page.getByRole('button', { name: 'Cancel and sign out' }) + ).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 not expose MFA status to unauthenticated requests', async ({ page }) => { + // Call without authentication + const response = await page.request.get('/user/mfa/status', { + maxRedirects: 0, + }); + + // 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'); + }); + }); +}); diff --git a/playwright/tests/mfa/mfa-flow.spec.ts b/playwright/tests/mfa/mfa-flow.spec.ts new file mode 100644 index 0000000..492cf0f --- /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: + * 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). + * + * 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'); + }); +}); 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 { +} 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/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); 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-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..15a17f8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -111,6 +111,19 @@ user: rpName: Spring User Framework Demo allowedOrigins: http://localhost:8080 + mfa: + # 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: 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). @@ -123,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/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..cd98af2 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -260,6 +260,84 @@ 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; + } + + // 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 + 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 +382,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
+
+
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