Summary
Companion issue to SpringUserFramework#268 / PR #272.
The library now wraps Spring Security 7's MFA in user.mfa.* properties. When enabled, DelegatingMissingAuthorityAccessDeniedHandler redirects partially-authenticated users to factor-specific login pages (e.g., after password login → WebAuthn challenge page). The demo app currently has zero MFA awareness and needs config, challenge pages, profile status display, and tests.
Implementation Plan
Step 1: Configuration
File: src/main/resources/application.yml
Add MFA config block under the existing user: section (after webauthn:):
user:
mfa:
enabled: true
factors: PASSWORD, WEBAUTHN
passwordEntryPointUri: /user/login.html
webauthnEntryPointUri: /user/mfa/webauthn-challenge.html
Also add /user/mfa/** to unprotectedURIs so partially-authenticated users can reach the challenge page.
Ensure MFA config is present in application-local.yml too, or inherits from main.
Step 2: WebAuthn Challenge Page (template)
New file: src/main/resources/templates/user/mfa/webauthn-challenge.html
A dedicated page that DelegatingMissingAuthorityAccessDeniedHandler redirects to when the WEBAUTHN factor is missing. Should:
- Use the existing
layout.html decorator (layout:decorate="~{layout}")
- Show a clear message: "Additional verification required — please verify with your passkey"
- Have a single "Verify with Passkey" button (reuse
authenticateWithPasskey() from webauthn-authenticate.js)
- Show error/success feedback (same pattern as login page's
#passkeyError)
- Include a link back to the login page as fallback
Step 3: Challenge Page JavaScript
New file: src/main/resources/static/js/user/mfa-webauthn-challenge.js
- Import
authenticateWithPasskey from webauthn-authenticate.js and showMessage from shared.js
- On DOM ready, show button for passkey verification
- On success, redirect to the saved request URL (Spring Security saves the original URL via its request cache)
- On failure, show error message with retry option
Step 4: Controller Route
File: src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.java
Add a route for the challenge page:
@GetMapping("/user/mfa/webauthn-challenge.html")
public String mfaWebauthnChallenge() {
return "user/mfa/webauthn-challenge";
}
Step 5: MFA Status on Profile Page
File: src/main/resources/static/js/user/auth-methods.js (existing — extend)
Add a call to GET /user/mfa/status and display the result:
- Show "MFA: Enabled" / "MFA: Disabled" badge
- List required factors and which are satisfied for the current session
- Show
fullyAuthenticated status
File: Profile/auth-methods template
Add an MFA status section (card or badge area) populated by the JS above.
Step 6: Playwright Tests
New file: playwright/tests/auth/mfa.spec.ts
Tests (require playwright-test profile with MFA enabled):
- Password login redirects to WebAuthn challenge — Login with password, verify redirect to
/user/mfa/webauthn-challenge.html
- MFA status endpoint returns correct data — Call
GET /user/mfa/status and verify response shape
- MFA disabled returns 404 for status endpoint — With MFA disabled config, verify 404
Note: Full end-to-end passkey verification in Playwright requires WebAuthn virtual authenticator support. The redirect test is the most practical automated test; the actual passkey ceremony may need manual testing or Playwright's cdpSession for virtual authenticator.
Step 7: Update Demo App Dependency Version
File: build.gradle
Ensure the library dependency version matches the new SNAPSHOT that includes MFA support. Run publishLocal in the library repo first.
Key Files to Reuse
| Existing file |
Reuse for |
static/js/user/webauthn-authenticate.js |
authenticateWithPasskey() — import directly in challenge page JS |
static/js/user/webauthn-utils.js |
CSRF helpers, base64url utils |
static/js/shared.js |
showMessage() for error/success display |
templates/user/login.html |
Template structure, card layout, passkey button pattern |
templates/layout.html |
Base layout decorator |
controller/PageController.java |
Controller pattern for simple page routes |
How MFA Works (for context)
With user.mfa.factors: PASSWORD, WEBAUTHN:
- User logs in with password → gets
FactorGrantedAuthority.PASSWORD_AUTHORITY
- Spring Security's
AllRequiredFactorsAuthorizationManager sees WEBAUTHN factor is missing
DelegatingMissingAuthorityAccessDeniedHandler redirects to webauthnEntryPointUri
- User completes passkey challenge → gets
FactorGrantedAuthority.WEBAUTHN_AUTHORITY
- Both factors satisfied → user is fully authenticated and redirected to original request
Verification
cd SpringUserFramework && ./gradlew publishLocal
cd SpringUserFrameworkDemoApp && ./gradlew bootRun --args='--spring.profiles.active=local,playwright-test'
- Browser: Login with password → should redirect to challenge page → verify with passkey → success
- API:
curl http://localhost:8080/user/mfa/status → JSON with mfaEnabled: true
- Profile: Login fully, visit auth-methods page, verify MFA status section
cd playwright && npx playwright test --project=chromium tests/auth/mfa.spec.ts
Related
Summary
Companion issue to SpringUserFramework#268 / PR #272.
The library now wraps Spring Security 7's MFA in
user.mfa.*properties. When enabled,DelegatingMissingAuthorityAccessDeniedHandlerredirects partially-authenticated users to factor-specific login pages (e.g., after password login → WebAuthn challenge page). The demo app currently has zero MFA awareness and needs config, challenge pages, profile status display, and tests.Implementation Plan
Step 1: Configuration
File:
src/main/resources/application.ymlAdd MFA config block under the existing
user:section (afterwebauthn:):Also add
/user/mfa/**tounprotectedURIsso partially-authenticated users can reach the challenge page.Ensure MFA config is present in
application-local.ymltoo, or inherits from main.Step 2: WebAuthn Challenge Page (template)
New file:
src/main/resources/templates/user/mfa/webauthn-challenge.htmlA dedicated page that
DelegatingMissingAuthorityAccessDeniedHandlerredirects to when the WEBAUTHN factor is missing. Should:layout.htmldecorator (layout:decorate="~{layout}")authenticateWithPasskey()fromwebauthn-authenticate.js)#passkeyError)Step 3: Challenge Page JavaScript
New file:
src/main/resources/static/js/user/mfa-webauthn-challenge.jsauthenticateWithPasskeyfromwebauthn-authenticate.jsandshowMessagefromshared.jsStep 4: Controller Route
File:
src/main/java/com/digitalsanctuary/spring/demo/controller/PageController.javaAdd a route for the challenge page:
Step 5: MFA Status on Profile Page
File:
src/main/resources/static/js/user/auth-methods.js(existing — extend)Add a call to
GET /user/mfa/statusand display the result:fullyAuthenticatedstatusFile: Profile/auth-methods template
Add an MFA status section (card or badge area) populated by the JS above.
Step 6: Playwright Tests
New file:
playwright/tests/auth/mfa.spec.tsTests (require
playwright-testprofile with MFA enabled):/user/mfa/webauthn-challenge.htmlGET /user/mfa/statusand verify response shapeStep 7: Update Demo App Dependency Version
File:
build.gradleEnsure the library dependency version matches the new SNAPSHOT that includes MFA support. Run
publishLocalin the library repo first.Key Files to Reuse
static/js/user/webauthn-authenticate.jsauthenticateWithPasskey()— import directly in challenge page JSstatic/js/user/webauthn-utils.jsstatic/js/shared.jsshowMessage()for error/success displaytemplates/user/login.htmltemplates/layout.htmlcontroller/PageController.javaHow MFA Works (for context)
With
user.mfa.factors: PASSWORD, WEBAUTHN:FactorGrantedAuthority.PASSWORD_AUTHORITYAllRequiredFactorsAuthorizationManagersees WEBAUTHN factor is missingDelegatingMissingAuthorityAccessDeniedHandlerredirects towebauthnEntryPointUriFactorGrantedAuthority.WEBAUTHN_AUTHORITYVerification
cd SpringUserFramework && ./gradlew publishLocalcd SpringUserFrameworkDemoApp && ./gradlew bootRun --args='--spring.profiles.active=local,playwright-test'curl http://localhost:8080/user/mfa/status→ JSON withmfaEnabled: truecd playwright && npx playwright test --project=chromium tests/auth/mfa.spec.tsRelated