Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +14 to +36
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/

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +37 to +99
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down
21 changes: 19 additions & 2 deletions playwright/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions playwright/tests/mfa/mfa-challenge.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
78 changes: 78 additions & 0 deletions playwright/tests/mfa/mfa-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>
* {@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) <em>merges</em> the new authentication's authorities with the current ones, accumulating
* {@code FactorGrantedAuthority}s. Without it, the passkey verification <em>replaces</em> the session authentication,
* dropping the PASSWORD factor — the user can then never satisfy both factors and bounces between the two challenge
* pages forever.
* </p>
*
* <p>
* 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.
* </p>
*/
@Configuration
@ConditionalOnProperty(name = "user.mfa.enabled", havingValue = "true", matchIfMissing = false)
@EnableMultiFactorAuthentication(authorities = {})
public class MfaSecurityConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

}
Loading
Loading