Skip to content
Open
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
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ repositories {

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

// WebAuthn support (Passkey authentication)
implementation 'org.springframework.security:spring-security-webauthn'
Expand Down
76 changes: 76 additions & 0 deletions playwright/tests/mfa/mfa-challenge.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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,
});

// Should not return 200 for unauthenticated request
// Expect redirect to login (302/303) or error (401/403) or 404 (MFA disabled)
expect([302, 303, 401, 403, 404]).toContain(response.status());
});
});
});
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";
}

}
2 changes: 2 additions & 0 deletions src/main/resources/application-playwright-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
59 changes: 59 additions & 0 deletions src/main/resources/static/js/user/mfa-webauthn-challenge.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
75 changes: 75 additions & 0 deletions src/main/resources/static/js/user/webauthn-manage.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,78 @@ 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.
*/
Comment on lines +263 to +267
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc says the MFA status container is hidden only when the endpoint returns 404, but the current implementation hides it for any non-2xx response. Either update the comment to match the behavior, or change the logic to check response.status === 404 specifically and handle other error statuses differently (e.g., log/show a message).

Copilot uses AI. Check for mistakes.
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', {
headers: { [csrfHeader]: csrfToken }
});

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;
}
Comment on lines +283 to +287
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateMfaStatusUI() currently hides the MFA section on any non-OK response, which can mask real server-side errors (500s) and make debugging harder. Consider only hiding on 404 (MFA disabled) and treating other statuses as an error state (log + optional user-visible message).

Copilot uses AI. Check for mistakes.

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.
*/
Expand Down Expand Up @@ -304,6 +376,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');
Expand Down
49 changes: 49 additions & 0 deletions src/main/resources/templates/user/mfa/webauthn-challenge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org" layout:decorate="~{layout}">

<head>
<title>Verify Your Identity</title>
</head>

<body>
<div layout:fragment="content">
<section id="main_content" class="my-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-sm">
<div class="card-header text-center">
<h5><i class="bi bi-shield-lock me-2"></i>Additional Verification Required</h5>
</div>
<div class="card-body text-center">
<p class="text-muted mb-4">
Your account requires an additional verification step.
Please verify your identity using your passkey.
</p>

<!-- Error message (shown by JS) -->
<div id="challengeError" class="alert alert-danger text-center d-none" role="alert"></div>

<button id="verifyPasskeyBtn" type="button" class="btn btn-primary btn-lg w-100">
<i class="bi bi-key me-2"></i> Verify with Passkey
</button>

<div class="mt-3">
<form th:action="@{/user/logout}" method="POST" class="d-inline">
<button type="submit" class="btn btn-link text-muted small p-0 border-0">
Cancel and sign out
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</section>

<script type="module" th:src="@{/js/user/mfa-webauthn-challenge.js}"></script>
</div>
</body>

</html>
7 changes: 7 additions & 0 deletions src/main/resources/templates/user/update-user.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ <h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Authentication Methods</h
<i class="bi bi-key me-1"></i> Set a Password
</a>
</div>

<!-- MFA Status (populated by JS from /user/mfa/status) -->
<div id="mfaStatusContainer" class="mt-3 d-none">
<hr>
<h6 class="mb-2"><i class="bi bi-shield-check me-1"></i>Multi-Factor Authentication</h6>
<div id="mfaStatusBadges"></div>
</div>
</div>
</div>

Expand Down
Loading