Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 7 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.

Expand Down
25 changes: 25 additions & 0 deletions src/main/resources/static/js/user/login.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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 = '<span class="spinner-border spinner-border-sm me-2"></span> 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 = '<i class="bi bi-key me-2"></i> Sign in with Passkey';
}
});
}
});

function validateForm(form) {
Expand Down
87 changes: 87 additions & 0 deletions src/main/resources/static/js/user/webauthn-authenticate.js
Original file line number Diff line number Diff line change
@@ -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;
}
189 changes: 189 additions & 0 deletions src/main/resources/static/js/user/webauthn-manage.js
Original file line number Diff line number Diff line change
@@ -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 = '<p class="text-muted">No passkeys registered yet.</p>';
return;
}

container.innerHTML = credentials.map(cred => `
<div class="card mb-2" data-id="${escapeHtml(cred.id)}">
<div class="card-body d-flex justify-content-between align-items-center py-2">
<div>
<strong>${escapeHtml(cred.label || 'Unnamed Passkey')}</strong>
<br>
<small class="text-muted">
Created: ${new Date(cred.created).toLocaleDateString()}
${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'}
</small>
<br>
${cred.backupEligible
? '<span class="badge bg-success">Synced</span>'
: '<span class="badge bg-warning text-dark">Device-bound</span>'}
</div>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="window.renamePasskey('${escapeHtml(cred.id)}')">
<i class="bi bi-pencil"></i> Rename
</button>
<button class="btn btn-sm btn-outline-danger" onclick="window.deletePasskey('${escapeHtml(cred.id)}')">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
`).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 = '<div class="alert alert-warning">Your browser does not support passkeys.</div>';
return;
}

// Wire up register button
const registerBtn = document.getElementById('registerPasskeyBtn');
if (registerBtn) {
registerBtn.addEventListener('click', handleRegisterPasskey);
}

// Load existing passkeys
loadPasskeys();
});
Loading
Loading