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