diff --git a/.gitignore b/.gitignore index 77b4da8..beca067 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,6 @@ playwright/test-results/ playwright/playwright-report/ playwright/reports/ playwright/.env + +# Curl cookie files +cookies.txt diff --git a/README.md b/README.md index fe67fe4..14748ff 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ This version uses: - **Authentication & Security** - Username/password authentication + - WebAuthn/Passkey passwordless login (biometrics, security keys) + - Passkey management (register, rename, delete) - OAuth2 login with Google, Facebook, and Keycloak - Role-based access control - CSRF protection @@ -376,6 +378,39 @@ To enable SSO: Then update your OAuth2 providers' callback URLs to use the ngrok domain. +--- + +#### **WebAuthn / Passkeys** + +The demo app includes full WebAuthn/Passkey support for passwordless login. Users can register passkeys (biometrics, security keys) from their profile page and use them to log in without a password. + +**Configuration** (in `application.yml`): +```yaml +user: + webauthn: + enabled: true # Enable passkey support + rpId: localhost # Must match your domain + rpName: Spring User Framework Demo # Display name shown during registration + allowedOrigins: http://localhost:8080 # Must match browser origin exactly +``` + +**Important**: You must also add the WebAuthn endpoints to your unprotected URIs: +```yaml +user: + security: + unprotectedURIs: ...,/webauthn/authenticate/**,/login/webauthn +``` + +**How it works:** +- **Register a passkey**: Log in with username/password, go to your profile page, and click "Add Passkey" +- **Log in with passkey**: On the login page, click the "Sign in with a Passkey" button +- **Manage passkeys**: From your profile page, rename or delete registered passkeys + +**Development notes:** +- HTTP works on `localhost` without HTTPS +- For testing on other devices, use ngrok (`ngrok http 8080`) and update `rpId` and `allowedOrigins` to match the ngrok domain +- The database tables (`user_entities`, `user_credentials`) are created automatically by Hibernate + ### Environment Variables For production deployments, use environment variables instead of hardcoding values: @@ -640,6 +675,18 @@ Solution: 4. Verify Keycloak realm and client settings ``` +#### WebAuthn/Passkey Issues +**Problem**: Passkey registration or login fails +``` +Solution: +1. Verify user.webauthn.enabled is true in application.yml +2. Check that rpId matches your domain (localhost for local dev) +3. Ensure allowedOrigins matches the exact browser URL (including port) +4. Verify /webauthn/authenticate/** and /login/webauthn are in unprotectedURIs +5. For non-localhost testing, HTTPS is required - use ngrok +6. Check browser console for WebAuthn API errors +``` + #### Email Not Sending **Problem**: Registration emails not received ``` diff --git a/build.gradle b/build.gradle index 6380068..6ccf147 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,10 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.3' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.0' + + // WebAuthn support (Passkey authentication) + implementation 'org.springframework.security:spring-security-webauthn' // Spring Boot starters implementation 'org.springframework.boot:spring-boot-starter-actuator' diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml index ac765be..1dc15ca 100644 --- a/src/main/resources/application-prd.yml +++ b/src/main/resources/application-prd.yml @@ -37,5 +37,9 @@ management: show-details: never # Don't expose detailed health info in production user: + webauthn: + rpId: ${WEBAUTHN_RP_ID:example.com} + rpName: ${WEBAUTHN_RP_NAME:Spring User Framework Demo} + allowedOrigins: ${WEBAUTHN_ALLOWED_ORIGINS:https://example.com} security: disableCSRFdURIs: # No CSRF disabled URIs in production for better security \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index de57286..ebf93b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -105,6 +105,12 @@ user: sendVerificationEmail: true # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. googleEnabled: false # If true, Google OAuth2 will be enabled for registration. facebookEnabled: false # If true, Facebook OAuth2 will be enabled for registration. + webauthn: + enabled: true + rpId: localhost + rpName: Spring User Framework Demo + allowedOrigins: http://localhost:8080 + 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). @@ -117,7 +123,7 @@ user: bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. defaultAction: deny # The default action for all requests. This can be either deny or allow. - unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. diff --git a/src/main/resources/static/js/user/login.js b/src/main/resources/static/js/user/login.js index e8bbba6..c608ecf 100644 --- a/src/main/resources/static/js/user/login.js +++ b/src/main/resources/static/js/user/login.js @@ -1,4 +1,6 @@ import { showMessage } from "/js/shared.js"; +import { isWebAuthnSupported } from "/js/user/webauthn-utils.js"; +import { authenticateWithPasskey } from "/js/user/webauthn-authenticate.js"; document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector("form"); @@ -8,6 +10,30 @@ document.addEventListener("DOMContentLoaded", () => { event.preventDefault(); } }); + + // Show passkey login button if WebAuthn is supported + const passkeySection = document.getElementById("passkey-login-section"); + const passkeyBtn = document.getElementById("passkeyLoginBtn"); + + if (passkeySection && passkeyBtn && isWebAuthnSupported()) { + passkeySection.style.display = "block"; + const passkeyError = document.getElementById("passkeyError"); + + passkeyBtn.addEventListener("click", async () => { + passkeyBtn.disabled = true; + passkeyBtn.innerHTML = ' Authenticating...'; + + try { + const redirectUrl = await authenticateWithPasskey(); + window.location.href = redirectUrl; + } catch (error) { + console.error("Passkey authentication failed:", error); + showMessage(passkeyError, "Passkey authentication failed. Please try again.", "alert-danger"); + passkeyBtn.disabled = false; + passkeyBtn.innerHTML = ' Sign in with Passkey'; + } + }); + } }); function validateForm(form) { diff --git a/src/main/resources/static/js/user/webauthn-authenticate.js b/src/main/resources/static/js/user/webauthn-authenticate.js new file mode 100644 index 0000000..6aa8ad2 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-authenticate.js @@ -0,0 +1,94 @@ +/** + * WebAuthn passkey authentication (login). + */ +import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js'; + +/** + * Authenticate with passkey (discoverable credential / usernameless). + */ +export async function authenticateWithPasskey() { + const csrfHeader = getCsrfHeaderName(); + const csrfToken = getCsrfToken(); + + // 1. Request authentication options (challenge) from Spring Security + const optionsResponse = await fetch('/webauthn/authenticate/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + } + }); + + if (!optionsResponse.ok) { + throw new Error('Failed to start authentication'); + } + + const options = await optionsResponse.json(); + + // 2. Convert base64url fields to ArrayBuffer + // Spring Security 7 returns options directly (not wrapped in publicKey) + options.challenge = base64urlToBuffer(options.challenge); + + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })); + } + + // 3. Call browser WebAuthn API + const assertion = await navigator.credentials.get({ + publicKey: options + }); + + if (!assertion) { + throw new Error('No assertion returned from authenticator'); + } + + // 4. Convert assertion to JSON in Spring Security's expected format + const assertionJSON = { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + credType: assertion.type, + response: { + authenticatorData: bufferToBase64url(assertion.response.authenticatorData), + clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), + signature: bufferToBase64url(assertion.response.signature), + userHandle: assertion.response.userHandle + ? bufferToBase64url(assertion.response.userHandle) + : null + }, + clientExtensionResults: assertion.getClientExtensionResults(), + authenticatorAttachment: assertion.authenticatorAttachment + }; + + // 5. Send assertion to Spring Security + const finishResponse = await fetch('/login/webauthn', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify(assertionJSON) + }); + + if (!finishResponse.ok) { + let msg = 'Authentication failed'; + try { + const data = await finishResponse.json(); + msg = data.message || msg; + } catch { + const text = await finishResponse.text(); + if (text) msg = text; + } + throw new Error(msg); + } + + // Spring Security returns { authenticated: true, redirectUrl: "..." } + const authResponse = await finishResponse.json(); + if (!authResponse || !authResponse.authenticated || !authResponse.redirectUrl) { + throw new Error('Authentication failed'); + } + + return authResponse.redirectUrl; +} diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js new file mode 100644 index 0000000..acddb48 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -0,0 +1,289 @@ +/** + * WebAuthn credential management (list, rename, delete) for the user profile page. + */ +import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from '/js/user/webauthn-utils.js'; +import { registerPasskey } from '/js/user/webauthn-register.js'; +import { showMessage } from '/js/shared.js'; + +const csrfHeader = getCsrfHeaderName(); +const csrfToken = getCsrfToken(); +let renameModalInstance; + +/** + * Load and display user's passkeys. + */ +export async function loadPasskeys() { + const container = document.getElementById('passkeys-list'); + const globalMessage = document.getElementById('passkeyMessage'); + if (!container) return; + + try { + const response = await fetch('/user/webauthn/credentials', { + headers: { [csrfHeader]: csrfToken } + }); + + if (!response.ok) { + throw new Error('Failed to load passkeys'); + } + + const credentials = await response.json(); + displayCredentials(container, credentials); + } catch (error) { + console.error('Failed to load passkeys:', error); + if (globalMessage) { + showMessage(globalMessage, 'Failed to load passkeys.', 'alert-danger'); + } + } +} + +/** + * Format a date string safely, returning 'Unknown' for invalid values. + */ +function formatDate(dateStr) { + if (!dateStr) return 'Unknown'; + const date = new Date(dateStr); + return isNaN(date) ? 'Unknown' : date.toLocaleDateString(); +} + +/** + * Display credentials in UI. + */ +function displayCredentials(container, credentials) { + if (credentials.length === 0) { + container.innerHTML = '
No passkeys registered yet.
'; + return; + } + + container.innerHTML = credentials.map(cred => ` +or
diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index b1f9da9..ef8f0a4 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -45,6 +45,53 @@Loading passkeys...
+