diff --git a/build.gradle b/build.gradle index 3ae9aec..5bcafd3 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.1.1-SNAPSHOT' + + // 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.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..34597f1 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,29 @@ 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 && isWebAuthnSupported()) { + passkeySection.style.display = "block"; + + 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(null, "Passkey authentication failed: " + error.message, "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..7010969 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-authenticate.js @@ -0,0 +1,87 @@ +/** + * 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) { + const error = await finishResponse.text(); + throw new Error(error || 'Authentication failed'); + } + + // 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..1e1767c --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -0,0 +1,189 @@ +/** + * 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(); + +/** + * 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'); + } + } +} + +/** + * Display credentials in UI. + */ +function displayCredentials(container, credentials) { + if (credentials.length === 0) { + container.innerHTML = '

No passkeys registered yet.

'; + return; + } + + container.innerHTML = credentials.map(cred => ` +
+
+
+ ${escapeHtml(cred.label || 'Unnamed Passkey')} +
+ + Created: ${new Date(cred.created).toLocaleDateString()} + ${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'} + +
+ ${cred.backupEligible + ? 'Synced' + : 'Device-bound'} +
+
+ + +
+
+
+ `).join(''); +} + +/** + * Rename a passkey. + */ +async function renamePasskey(credentialId) { + const newLabel = prompt('Enter new name for this passkey:'); + if (!newLabel) return; + + const globalMessage = document.getElementById('passkeyMessage'); + + try { + const response = await fetch(`/user/webauthn/credentials/${credentialId}/label`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify({ label: newLabel }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to rename passkey'); + } + + if (globalMessage) { + showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success'); + } + loadPasskeys(); + } catch (error) { + console.error('Failed to rename passkey:', error); + if (globalMessage) { + showMessage(globalMessage, error.message, 'alert-danger'); + } + } +} + +/** + * Delete a passkey with confirmation. + */ +async function deletePasskey(credentialId) { + if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) { + return; + } + + const globalMessage = document.getElementById('passkeyMessage'); + + try { + const response = await fetch(`/user/webauthn/credentials/${credentialId}`, { + method: 'DELETE', + headers: { [csrfHeader]: csrfToken } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to delete passkey'); + } + + if (globalMessage) { + showMessage(globalMessage, 'Passkey deleted successfully.', 'alert-success'); + } + loadPasskeys(); + } catch (error) { + console.error('Failed to delete passkey:', error); + if (globalMessage) { + showMessage(globalMessage, error.message, 'alert-danger'); + } + } +} + +/** + * Handle register passkey button click. + */ +async function handleRegisterPasskey() { + const globalMessage = document.getElementById('passkeyMessage'); + const labelInput = document.getElementById('passkeyLabel'); + const label = labelInput ? labelInput.value.trim() : ''; + + try { + await registerPasskey(label || 'My Passkey'); + if (globalMessage) { + showMessage(globalMessage, 'Passkey registered successfully!', 'alert-success'); + } + if (labelInput) labelInput.value = ''; + loadPasskeys(); + } catch (error) { + console.error('Registration error:', error); + if (globalMessage) { + showMessage(globalMessage, 'Failed to register passkey: ' + error.message, 'alert-danger'); + } + } +} + +// Expose to global scope for onclick handlers in the credential list +window.renamePasskey = renamePasskey; +window.deletePasskey = deletePasskey; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', async () => { + const passkeySection = document.getElementById('passkey-section'); + if (!passkeySection) return; + + if (!isWebAuthnSupported()) { + passkeySection.innerHTML = '
Your browser does not support passkeys.
'; + return; + } + + // Wire up register button + const registerBtn = document.getElementById('registerPasskeyBtn'); + if (registerBtn) { + registerBtn.addEventListener('click', handleRegisterPasskey); + } + + // Load existing passkeys + loadPasskeys(); +}); diff --git a/src/main/resources/static/js/user/webauthn-register.js b/src/main/resources/static/js/user/webauthn-register.js new file mode 100644 index 0000000..9311802 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-register.js @@ -0,0 +1,86 @@ +/** + * WebAuthn passkey registration for authenticated users. + */ +import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js'; + +/** + * Register a new passkey for the authenticated user. + */ +export async function registerPasskey(labelInput) { + const credentialName = labelInput || 'My Passkey'; + const csrfHeader = getCsrfHeaderName(); + const csrfToken = getCsrfToken(); + + // 1. Request registration options (challenge) from Spring Security + const optionsResponse = await fetch('/webauthn/register/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + } + }); + + if (!optionsResponse.ok) { + throw new Error('Failed to start registration'); + } + + 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); + options.user.id = base64urlToBuffer(options.user.id); + + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })); + } + + // 3. Call browser WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: options + }); + + if (!credential) { + throw new Error('No credential returned from authenticator'); + } + + // 4. Build the registration request in Spring Security's expected format: + // { publicKey: { credential: {...}, label: "..." } } + const registrationRequest = { + publicKey: { + credential: { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: bufferToBase64url(credential.response.attestationObject), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + transports: credential.response.getTransports ? credential.response.getTransports() : [] + }, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: credential.authenticatorAttachment + }, + label: credentialName + } + }; + + // 5. Send credential to Spring Security + const finishResponse = await fetch('/webauthn/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify(registrationRequest) + }); + + if (!finishResponse.ok) { + const error = await finishResponse.text(); + throw new Error(error || 'Registration failed'); + } + + return credential; +} diff --git a/src/main/resources/static/js/user/webauthn-utils.js b/src/main/resources/static/js/user/webauthn-utils.js new file mode 100644 index 0000000..e9a2f33 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-utils.js @@ -0,0 +1,75 @@ +/** + * WebAuthn utility functions for base64url encoding/decoding and browser support checks. + */ + +/** + * Get CSRF token from meta tag. + */ +export function getCsrfToken() { + const meta = document.querySelector('meta[name="_csrf"]'); + return meta ? meta.getAttribute('content') : ''; +} + +/** + * Get CSRF header name from meta tag. + */ +export function getCsrfHeaderName() { + const meta = document.querySelector('meta[name="_csrf_header"]'); + return meta ? meta.getAttribute('content') : 'X-CSRF-TOKEN'; +} + +/** + * Convert base64url string to ArrayBuffer. + */ +export function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (base64.length % 4)) % 4; + const padded = base64 + '='.repeat(padLen); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Convert ArrayBuffer to base64url string. + */ +export function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + const base64 = btoa(binary); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** + * Check if WebAuthn is supported in this browser. + */ +export function isWebAuthnSupported() { + return window.PublicKeyCredential !== undefined && + navigator.credentials !== undefined; +} + +/** + * Check if platform authenticator is available (TouchID, FaceID, Windows Hello). + */ +export async function isPlatformAuthenticatorAvailable() { + if (!isWebAuthnSupported()) { + return false; + } + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +} + +/** + * Escape HTML to prevent XSS. + */ +export function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/main/resources/templates/user/login.html b/src/main/resources/templates/user/login.html index ed2eefc..ec64752 100644 --- a/src/main/resources/templates/user/login.html +++ b/src/main/resources/templates/user/login.html @@ -43,6 +43,13 @@
Log in with
Login with Keycloak + + +

or

diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index b1f9da9..b627634 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -45,6 +45,29 @@

Update Profile

+ +
+
+
Passkeys
+
+
+
+ + +
+ + +
+ + +
+

Loading passkeys...

+
+
+
+
Change Password @@ -54,6 +77,7 @@

Update Profile

+