-
Notifications
You must be signed in to change notification settings - Fork 7
feat: Add MFA challenge UI pages and demo support #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
devondragon
wants to merge
2
commits into
main
Choose a base branch
from
feature/mfa-challenge-ui
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
src/main/resources/static/js/user/mfa-webauthn-challenge.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
| */ | ||
| 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
|
||
|
|
||
| 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. | ||
| */ | ||
|
|
@@ -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'); | ||
|
|
||
49 changes: 49 additions & 0 deletions
49
src/main/resources/templates/user/mfa/webauthn-challenge.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 === 404specifically and handle other error statuses differently (e.g., log/show a message).