Skip to content

Commit c2c476f

Browse files
committed
Add WebAuthn passkey registration and login to demo app
1 parent 15b6414 commit c2c476f

10 files changed

Lines changed: 513 additions & 3 deletions

File tree

build.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ repositories {
3939

4040
dependencies {
4141
// DigitalSanctuary Spring User Framework
42-
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.3'
42+
implementation 'com.digitalsanctuary:ds-spring-user-framework:4.1.1-SNAPSHOT'
43+
44+
// WebAuthn support (Passkey authentication)
45+
implementation 'org.springframework.security:spring-security-webauthn'
4346

4447
// Spring Boot starters
4548
implementation 'org.springframework.boot:spring-boot-starter-actuator'

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ services:
2121
timeout: 5s
2222
retries: 3
2323

24+
myapp-db-adminer:
25+
image: adminer
26+
container_name: springuser-db-adminer
27+
ports:
28+
- "8081:8080"
29+
depends_on:
30+
- myapp-db
31+
2432
mailserver:
2533
image: docker.io/mailserver/docker-mailserver:latest
2634
container_name: springuser-mail

src/main/resources/application.yml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,15 @@ springdoc:
102102
user:
103103
actuallyDeleteAccount: false # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted.
104104
registration:
105-
sendVerificationEmail: true # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified.
105+
sendVerificationEmail: false # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified.
106106
googleEnabled: false # If true, Google OAuth2 will be enabled for registration.
107107
facebookEnabled: false # If true, Facebook OAuth2 will be enabled for registration.
108+
webauthn:
109+
enabled: true
110+
rpId: localhost
111+
rpName: Spring User Framework Demo
112+
allowedOrigins: http://localhost:8080
113+
108114
audit:
109115
logFilePath: /opt/app/logs/user-audit.log # The path to the audit log file.
110116
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:
117123
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.
118124
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.
119125
defaultAction: deny # The default action for all requests. This can be either deny or allow.
120-
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.
126+
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.
121127
protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow.
122128
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.
123129

src/main/resources/static/js/user/login.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { showMessage } from "/js/shared.js";
2+
import { isWebAuthnSupported } from "/js/user/webauthn-utils.js";
3+
import { authenticateWithPasskey } from "/js/user/webauthn-authenticate.js";
24

35
document.addEventListener("DOMContentLoaded", () => {
46
const form = document.querySelector("form");
@@ -8,6 +10,29 @@ document.addEventListener("DOMContentLoaded", () => {
810
event.preventDefault();
911
}
1012
});
13+
14+
// Show passkey login button if WebAuthn is supported
15+
const passkeySection = document.getElementById("passkey-login-section");
16+
const passkeyBtn = document.getElementById("passkeyLoginBtn");
17+
18+
if (passkeySection && isWebAuthnSupported()) {
19+
passkeySection.style.display = "block";
20+
21+
passkeyBtn.addEventListener("click", async () => {
22+
passkeyBtn.disabled = true;
23+
passkeyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Authenticating...';
24+
25+
try {
26+
const redirectUrl = await authenticateWithPasskey();
27+
window.location.href = redirectUrl;
28+
} catch (error) {
29+
console.error("Passkey authentication failed:", error);
30+
showMessage(null, "Passkey authentication failed: " + error.message, "alert-danger");
31+
passkeyBtn.disabled = false;
32+
passkeyBtn.innerHTML = '<i class="bi bi-key me-2"></i> Sign in with Passkey';
33+
}
34+
});
35+
}
1136
});
1237

1338
function validateForm(form) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* WebAuthn passkey authentication (login).
3+
*/
4+
import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js';
5+
6+
/**
7+
* Authenticate with passkey (discoverable credential / usernameless).
8+
*/
9+
export async function authenticateWithPasskey() {
10+
const csrfHeader = getCsrfHeaderName();
11+
const csrfToken = getCsrfToken();
12+
13+
// 1. Request authentication options (challenge) from Spring Security
14+
const optionsResponse = await fetch('/webauthn/authenticate/options', {
15+
method: 'POST',
16+
headers: {
17+
'Content-Type': 'application/json',
18+
[csrfHeader]: csrfToken
19+
}
20+
});
21+
22+
if (!optionsResponse.ok) {
23+
throw new Error('Failed to start authentication');
24+
}
25+
26+
const options = await optionsResponse.json();
27+
28+
// 2. Convert base64url fields to ArrayBuffer
29+
// Spring Security 7 returns options directly (not wrapped in publicKey)
30+
options.challenge = base64urlToBuffer(options.challenge);
31+
32+
if (options.allowCredentials) {
33+
options.allowCredentials = options.allowCredentials.map(cred => ({
34+
...cred,
35+
id: base64urlToBuffer(cred.id)
36+
}));
37+
}
38+
39+
// 3. Call browser WebAuthn API
40+
const assertion = await navigator.credentials.get({
41+
publicKey: options
42+
});
43+
44+
if (!assertion) {
45+
throw new Error('No assertion returned from authenticator');
46+
}
47+
48+
// 4. Convert assertion to JSON in Spring Security's expected format
49+
const assertionJSON = {
50+
id: assertion.id,
51+
rawId: bufferToBase64url(assertion.rawId),
52+
credType: assertion.type,
53+
response: {
54+
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
55+
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
56+
signature: bufferToBase64url(assertion.response.signature),
57+
userHandle: assertion.response.userHandle
58+
? bufferToBase64url(assertion.response.userHandle)
59+
: null
60+
},
61+
clientExtensionResults: assertion.getClientExtensionResults(),
62+
authenticatorAttachment: assertion.authenticatorAttachment
63+
};
64+
65+
// 5. Send assertion to Spring Security
66+
const finishResponse = await fetch('/login/webauthn', {
67+
method: 'POST',
68+
headers: {
69+
'Content-Type': 'application/json',
70+
[csrfHeader]: csrfToken
71+
},
72+
body: JSON.stringify(assertionJSON)
73+
});
74+
75+
if (!finishResponse.ok) {
76+
const error = await finishResponse.text();
77+
throw new Error(error || 'Authentication failed');
78+
}
79+
80+
// Spring Security returns { authenticated: true, redirectUrl: "..." }
81+
const authResponse = await finishResponse.json();
82+
if (!authResponse || !authResponse.authenticated || !authResponse.redirectUrl) {
83+
throw new Error('Authentication failed');
84+
}
85+
86+
return authResponse.redirectUrl;
87+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* WebAuthn credential management (list, rename, delete) for the user profile page.
3+
*/
4+
import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from '/js/user/webauthn-utils.js';
5+
import { registerPasskey } from '/js/user/webauthn-register.js';
6+
import { showMessage } from '/js/shared.js';
7+
8+
const csrfHeader = getCsrfHeaderName();
9+
const csrfToken = getCsrfToken();
10+
11+
/**
12+
* Load and display user's passkeys.
13+
*/
14+
export async function loadPasskeys() {
15+
const container = document.getElementById('passkeys-list');
16+
const globalMessage = document.getElementById('passkeyMessage');
17+
if (!container) return;
18+
19+
try {
20+
const response = await fetch('/user/webauthn/credentials', {
21+
headers: { [csrfHeader]: csrfToken }
22+
});
23+
24+
if (!response.ok) {
25+
throw new Error('Failed to load passkeys');
26+
}
27+
28+
const credentials = await response.json();
29+
displayCredentials(container, credentials);
30+
} catch (error) {
31+
console.error('Failed to load passkeys:', error);
32+
if (globalMessage) {
33+
showMessage(globalMessage, 'Failed to load passkeys.', 'alert-danger');
34+
}
35+
}
36+
}
37+
38+
/**
39+
* Display credentials in UI.
40+
*/
41+
function displayCredentials(container, credentials) {
42+
if (credentials.length === 0) {
43+
container.innerHTML = '<p class="text-muted">No passkeys registered yet.</p>';
44+
return;
45+
}
46+
47+
container.innerHTML = credentials.map(cred => `
48+
<div class="card mb-2" data-id="${escapeHtml(cred.id)}">
49+
<div class="card-body d-flex justify-content-between align-items-center py-2">
50+
<div>
51+
<strong>${escapeHtml(cred.label || 'Unnamed Passkey')}</strong>
52+
<br>
53+
<small class="text-muted">
54+
Created: ${new Date(cred.created).toLocaleDateString()}
55+
${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'}
56+
</small>
57+
<br>
58+
${cred.backupEligible
59+
? '<span class="badge bg-success">Synced</span>'
60+
: '<span class="badge bg-warning text-dark">Device-bound</span>'}
61+
</div>
62+
<div>
63+
<button class="btn btn-sm btn-outline-secondary me-1" onclick="window.renamePasskey('${escapeHtml(cred.id)}')">
64+
<i class="bi bi-pencil"></i> Rename
65+
</button>
66+
<button class="btn btn-sm btn-outline-danger" onclick="window.deletePasskey('${escapeHtml(cred.id)}')">
67+
<i class="bi bi-trash"></i> Delete
68+
</button>
69+
</div>
70+
</div>
71+
</div>
72+
`).join('');
73+
}
74+
75+
/**
76+
* Rename a passkey.
77+
*/
78+
async function renamePasskey(credentialId) {
79+
const newLabel = prompt('Enter new name for this passkey:');
80+
if (!newLabel) return;
81+
82+
const globalMessage = document.getElementById('passkeyMessage');
83+
84+
try {
85+
const response = await fetch(`/user/webauthn/credentials/${credentialId}/label`, {
86+
method: 'PUT',
87+
headers: {
88+
'Content-Type': 'application/json',
89+
[csrfHeader]: csrfToken
90+
},
91+
body: JSON.stringify({ label: newLabel })
92+
});
93+
94+
if (!response.ok) {
95+
const data = await response.json();
96+
throw new Error(data.message || 'Failed to rename passkey');
97+
}
98+
99+
if (globalMessage) {
100+
showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success');
101+
}
102+
loadPasskeys();
103+
} catch (error) {
104+
console.error('Failed to rename passkey:', error);
105+
if (globalMessage) {
106+
showMessage(globalMessage, error.message, 'alert-danger');
107+
}
108+
}
109+
}
110+
111+
/**
112+
* Delete a passkey with confirmation.
113+
*/
114+
async function deletePasskey(credentialId) {
115+
if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) {
116+
return;
117+
}
118+
119+
const globalMessage = document.getElementById('passkeyMessage');
120+
121+
try {
122+
const response = await fetch(`/user/webauthn/credentials/${credentialId}`, {
123+
method: 'DELETE',
124+
headers: { [csrfHeader]: csrfToken }
125+
});
126+
127+
if (!response.ok) {
128+
const data = await response.json();
129+
throw new Error(data.message || 'Failed to delete passkey');
130+
}
131+
132+
if (globalMessage) {
133+
showMessage(globalMessage, 'Passkey deleted successfully.', 'alert-success');
134+
}
135+
loadPasskeys();
136+
} catch (error) {
137+
console.error('Failed to delete passkey:', error);
138+
if (globalMessage) {
139+
showMessage(globalMessage, error.message, 'alert-danger');
140+
}
141+
}
142+
}
143+
144+
/**
145+
* Handle register passkey button click.
146+
*/
147+
async function handleRegisterPasskey() {
148+
const globalMessage = document.getElementById('passkeyMessage');
149+
const labelInput = document.getElementById('passkeyLabel');
150+
const label = labelInput ? labelInput.value.trim() : '';
151+
152+
try {
153+
await registerPasskey(label || 'My Passkey');
154+
if (globalMessage) {
155+
showMessage(globalMessage, 'Passkey registered successfully!', 'alert-success');
156+
}
157+
if (labelInput) labelInput.value = '';
158+
loadPasskeys();
159+
} catch (error) {
160+
console.error('Registration error:', error);
161+
if (globalMessage) {
162+
showMessage(globalMessage, 'Failed to register passkey: ' + error.message, 'alert-danger');
163+
}
164+
}
165+
}
166+
167+
// Expose to global scope for onclick handlers in the credential list
168+
window.renamePasskey = renamePasskey;
169+
window.deletePasskey = deletePasskey;
170+
171+
// Initialize on page load
172+
document.addEventListener('DOMContentLoaded', async () => {
173+
const passkeySection = document.getElementById('passkey-section');
174+
if (!passkeySection) return;
175+
176+
if (!isWebAuthnSupported()) {
177+
passkeySection.innerHTML = '<div class="alert alert-warning">Your browser does not support passkeys.</div>';
178+
return;
179+
}
180+
181+
// Wire up register button
182+
const registerBtn = document.getElementById('registerPasskeyBtn');
183+
if (registerBtn) {
184+
registerBtn.addEventListener('click', handleRegisterPasskey);
185+
}
186+
187+
// Load existing passkeys
188+
loadPasskeys();
189+
});

0 commit comments

Comments
 (0)