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 => ` +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 @@Loading passkeys...
+