Skip to content

Commit e36a82c

Browse files
committed
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
1 parent 6312089 commit e36a82c

9 files changed

Lines changed: 285 additions & 1 deletion

File tree

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ repositories {
3939

4040
dependencies {
4141
// DigitalSanctuary Spring User Framework
42-
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.1'
42+
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.2-SNAPSHOT'
4343

4444
// WebAuthn support (Passkey authentication)
4545
implementation 'org.springframework.security:spring-security-webauthn'
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test, expect, generateTestUser, createAndLoginUser } from '../../src/fixtures';
2+
3+
test.describe('MFA', () => {
4+
test.describe('Challenge Page', () => {
5+
test('should render the MFA WebAuthn challenge page structure', async ({
6+
page,
7+
testApiClient,
8+
cleanupEmails,
9+
}) => {
10+
// Login first so we have a session (page requires auth when MFA is disabled)
11+
const user = generateTestUser('mfa-page');
12+
cleanupEmails.push(user.email);
13+
14+
await createAndLoginUser(page, testApiClient, user);
15+
16+
// Navigate to the challenge page
17+
await page.goto('/user/mfa/webauthn-challenge.html');
18+
await page.waitForLoadState('domcontentloaded');
19+
20+
// Verify page structure
21+
await expect(page.locator('.card-header')).toContainText('Additional Verification Required');
22+
await expect(page.locator('#verifyPasskeyBtn')).toBeVisible();
23+
});
24+
25+
test('should have a cancel/sign out option', async ({
26+
page,
27+
testApiClient,
28+
cleanupEmails,
29+
}) => {
30+
const user = generateTestUser('mfa-cancel');
31+
cleanupEmails.push(user.email);
32+
33+
await createAndLoginUser(page, testApiClient, user);
34+
35+
await page.goto('/user/mfa/webauthn-challenge.html');
36+
await page.waitForLoadState('domcontentloaded');
37+
38+
// Verify cancel/sign out button is present (inside the logout form)
39+
await page.waitForLoadState('networkidle');
40+
await expect(
41+
page.locator('form[action*="logout"] button[type="submit"]')
42+
).toBeVisible();
43+
});
44+
});
45+
46+
test.describe('MFA Status Endpoint', () => {
47+
test('should handle MFA status request for authenticated user', async ({
48+
page,
49+
testApiClient,
50+
cleanupEmails,
51+
}) => {
52+
const user = generateTestUser('mfa-status');
53+
cleanupEmails.push(user.email);
54+
55+
await createAndLoginUser(page, testApiClient, user);
56+
57+
// Call the MFA status endpoint
58+
const response = await page.request.get('/user/mfa/status');
59+
60+
// With MFA disabled in playwright-test profile, expect 404
61+
// With MFA enabled, expect 200 with proper response shape
62+
expect([200, 404]).toContain(response.status());
63+
64+
if (response.status() === 200) {
65+
const body = await response.json();
66+
expect(typeof body.mfaEnabled).toBe('boolean');
67+
expect(typeof body.fullyAuthenticated).toBe('boolean');
68+
}
69+
});
70+
71+
test('should require authentication for MFA status endpoint', async ({ page }) => {
72+
// Call without authentication
73+
const response = await page.request.get('/user/mfa/status', {
74+
maxRedirects: 0,
75+
});
76+
77+
// Should not return 200 for unauthenticated request
78+
// Expect redirect to login (302/303) or error (401/403) or 404 (MFA disabled)
79+
expect([302, 303, 401, 403, 404]).toContain(response.status());
80+
});
81+
});
82+
});

src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,14 @@ public String terms() {
5151
return "terms";
5252
}
5353

54+
/**
55+
* MFA WebAuthn Challenge Page.
56+
*
57+
* @return the path to the MFA WebAuthn challenge page
58+
*/
59+
@GetMapping("/user/mfa/webauthn-challenge.html")
60+
public String mfaWebAuthnChallenge() {
61+
return "user/mfa/webauthn-challenge";
62+
}
5463

5564
}

src/main/resources/application-playwright-test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ spring:
1919

2020
# Enable test API endpoints by adding them to unprotected URIs
2121
user:
22+
mfa:
23+
enabled: false
2224
registration:
2325
# Disable email sending since tests use Test API for token retrieval
2426
sendVerificationEmail: false

src/main/resources/application.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ user:
111111
rpName: Spring User Framework Demo
112112
allowedOrigins: http://localhost:8080
113113

114+
mfa:
115+
enabled: true
116+
factors:
117+
- PASSWORD
118+
- WEBAUTHN
119+
passwordEntryPointUri: /user/login.html
120+
webauthnEntryPointUri: /user/mfa/webauthn-challenge.html
121+
114122
audit:
115123
logFilePath: /opt/app/logs/user-audit.log # The path to the audit log file.
116124
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).
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* MFA WebAuthn challenge page — prompts the user to verify with their passkey
3+
* after initial password authentication when MFA is enabled.
4+
*/
5+
import { showMessage } from '/js/shared.js';
6+
import { isWebAuthnSupported } from '/js/user/webauthn-utils.js';
7+
import { authenticateWithPasskey } from '/js/user/webauthn-authenticate.js';
8+
9+
const BUTTON_LABEL = 'Verify with Passkey';
10+
const BUTTON_ICON_CLASS = 'bi bi-key me-2';
11+
12+
function setButtonReady(btn) {
13+
btn.textContent = '';
14+
const icon = document.createElement('i');
15+
icon.className = BUTTON_ICON_CLASS;
16+
btn.appendChild(icon);
17+
btn.appendChild(document.createTextNode(' ' + BUTTON_LABEL));
18+
}
19+
20+
function setButtonLoading(btn) {
21+
btn.textContent = '';
22+
const spinner = document.createElement('span');
23+
spinner.className = 'spinner-border spinner-border-sm me-2';
24+
btn.appendChild(spinner);
25+
btn.appendChild(document.createTextNode(' Verifying...'));
26+
}
27+
28+
document.addEventListener('DOMContentLoaded', () => {
29+
const verifyBtn = document.getElementById('verifyPasskeyBtn');
30+
const errorEl = document.getElementById('challengeError');
31+
32+
if (!verifyBtn) return;
33+
34+
if (!isWebAuthnSupported()) {
35+
verifyBtn.disabled = true;
36+
showMessage(errorEl,
37+
'Your browser does not support passkeys. Please use a different browser or contact support.',
38+
'alert-danger');
39+
return;
40+
}
41+
42+
verifyBtn.addEventListener('click', async () => {
43+
verifyBtn.disabled = true;
44+
setButtonLoading(verifyBtn);
45+
errorEl.classList.add('d-none');
46+
47+
try {
48+
const redirectUrl = await authenticateWithPasskey();
49+
window.location.href = redirectUrl;
50+
} catch (error) {
51+
console.error('MFA WebAuthn challenge failed:', error);
52+
showMessage(errorEl,
53+
'Verification failed. Please try again or cancel and sign out.',
54+
'alert-danger');
55+
verifyBtn.disabled = false;
56+
setButtonReady(verifyBtn);
57+
}
58+
});
59+
});

src/main/resources/static/js/user/webauthn-manage.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,71 @@ async function handleRegisterPasskey() {
260260
}
261261
}
262262

263+
/**
264+
* Update the MFA Status section in the auth-methods card.
265+
* Silently hides the container if the MFA status endpoint returns 404 (MFA disabled).
266+
*/
267+
async function updateMfaStatusUI() {
268+
const container = document.getElementById('mfaStatusContainer');
269+
const badgesEl = document.getElementById('mfaStatusBadges');
270+
if (!container || !badgesEl) return;
271+
272+
try {
273+
const response = await fetch('/user/mfa/status', {
274+
headers: { [csrfHeader]: csrfToken }
275+
});
276+
277+
if (!response.ok) {
278+
container.classList.add('d-none');
279+
return;
280+
}
281+
282+
const status = await response.json();
283+
container.classList.remove('d-none');
284+
285+
// Build MFA badges using safe DOM methods
286+
badgesEl.textContent = '';
287+
288+
if (status.mfaEnabled) {
289+
badgesEl.appendChild(createBadge('MFA Active', 'bg-primary', 'bi-shield-lock'));
290+
}
291+
292+
if (status.fullyAuthenticated) {
293+
badgesEl.appendChild(createBadge('Fully Authenticated', 'bg-success', 'bi-shield-check'));
294+
} else {
295+
badgesEl.appendChild(createBadge('Additional Factor Required', 'bg-warning text-dark', 'bi-shield-exclamation'));
296+
}
297+
298+
if (Array.isArray(status.satisfiedFactors)) {
299+
status.satisfiedFactors.forEach(factor => {
300+
badgesEl.appendChild(createBadge(factor, 'bg-secondary', 'bi-check-circle'));
301+
});
302+
}
303+
304+
if (Array.isArray(status.missingFactors) && status.missingFactors.length > 0) {
305+
status.missingFactors.forEach(factor => {
306+
badgesEl.appendChild(createBadge(factor + ' (pending)', 'bg-danger', 'bi-x-circle'));
307+
});
308+
}
309+
} catch (error) {
310+
console.error('Failed to fetch MFA status:', error);
311+
container.classList.add('d-none');
312+
}
313+
}
314+
315+
/**
316+
* Create a Bootstrap badge span element with an icon.
317+
*/
318+
function createBadge(text, bgClass, iconClass) {
319+
const badge = document.createElement('span');
320+
badge.className = `badge ${bgClass} me-2`;
321+
const icon = document.createElement('i');
322+
icon.className = `bi ${iconClass} me-1`;
323+
badge.appendChild(icon);
324+
badge.appendChild(document.createTextNode(text));
325+
return badge;
326+
}
327+
263328
/**
264329
* Update the Authentication Methods UI card with current state.
265330
*/
@@ -304,6 +369,9 @@ async function updateAuthMethodsUI() {
304369
if (changePasswordLink) {
305370
changePasswordLink.textContent = auth.hasPassword ? 'Change Password' : 'Set a Password';
306371
}
372+
373+
// Update MFA status section
374+
await updateMfaStatusUI();
307375
} catch (error) {
308376
console.error('Failed to update auth methods UI:', error);
309377
const section = document.getElementById('auth-methods-section');
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!DOCTYPE HTML>
2+
<html xmlns:th="http://www.thymeleaf.org" layout:decorate="~{layout}">
3+
4+
<head>
5+
<title>Verify Your Identity</title>
6+
</head>
7+
8+
<body>
9+
<div layout:fragment="content">
10+
<section id="main_content" class="my-5">
11+
<div class="container">
12+
<div class="row justify-content-center">
13+
<div class="col-md-8 col-lg-6">
14+
<div class="card shadow-sm">
15+
<div class="card-header text-center">
16+
<h5><i class="bi bi-shield-lock me-2"></i>Additional Verification Required</h5>
17+
</div>
18+
<div class="card-body text-center">
19+
<p class="text-muted mb-4">
20+
Your account requires an additional verification step.
21+
Please verify your identity using your passkey.
22+
</p>
23+
24+
<!-- Error message (shown by JS) -->
25+
<div id="challengeError" class="alert alert-danger text-center d-none" role="alert"></div>
26+
27+
<button id="verifyPasskeyBtn" type="button" class="btn btn-primary btn-lg w-100">
28+
<i class="bi bi-key me-2"></i> Verify with Passkey
29+
</button>
30+
31+
<div class="mt-3">
32+
<form th:action="@{/user/logout}" method="POST" class="d-inline">
33+
<button type="submit" class="btn btn-link text-muted small p-0 border-0">
34+
Cancel and sign out
35+
</button>
36+
</form>
37+
</div>
38+
</div>
39+
</div>
40+
</div>
41+
</div>
42+
</div>
43+
</section>
44+
45+
<script type="module" th:src="@{/js/user/mfa-webauthn-challenge.js}"></script>
46+
</div>
47+
</body>
48+
49+
</html>

src/main/resources/templates/user/update-user.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ <h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Authentication Methods</h
6464
<i class="bi bi-key me-1"></i> Set a Password
6565
</a>
6666
</div>
67+
68+
<!-- MFA Status (populated by JS from /user/mfa/status) -->
69+
<div id="mfaStatusContainer" class="mt-3 d-none">
70+
<hr>
71+
<h6 class="mb-2"><i class="bi bi-shield-check me-1"></i>Multi-Factor Authentication</h6>
72+
<div id="mfaStatusBadges"></div>
73+
</div>
6774
</div>
6875
</div>
6976

0 commit comments

Comments
 (0)