Skip to content

Commit fdc4615

Browse files
authored
Merge pull request #55 from devondragon/webauthn-test
Add WebAuthn/Passkey authentication support
2 parents 604a736 + 038a7d9 commit fdc4615

12 files changed

Lines changed: 699 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,6 @@ playwright/test-results/
145145
playwright/playwright-report/
146146
playwright/reports/
147147
playwright/.env
148+
149+
# Curl cookie files
150+
cookies.txt

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ This version uses:
7171

7272
- **Authentication & Security**
7373
- Username/password authentication
74+
- WebAuthn/Passkey passwordless login (biometrics, security keys)
75+
- Passkey management (register, rename, delete)
7476
- OAuth2 login with Google, Facebook, and Keycloak
7577
- Role-based access control
7678
- CSRF protection
@@ -376,6 +378,39 @@ To enable SSO:
376378
377379
Then update your OAuth2 providers' callback URLs to use the ngrok domain.
378380
381+
---
382+
383+
#### **WebAuthn / Passkeys**
384+
385+
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.
386+
387+
**Configuration** (in `application.yml`):
388+
```yaml
389+
user:
390+
webauthn:
391+
enabled: true # Enable passkey support
392+
rpId: localhost # Must match your domain
393+
rpName: Spring User Framework Demo # Display name shown during registration
394+
allowedOrigins: http://localhost:8080 # Must match browser origin exactly
395+
```
396+
397+
**Important**: You must also add the WebAuthn endpoints to your unprotected URIs:
398+
```yaml
399+
user:
400+
security:
401+
unprotectedURIs: ...,/webauthn/authenticate/**,/login/webauthn
402+
```
403+
404+
**How it works:**
405+
- **Register a passkey**: Log in with username/password, go to your profile page, and click "Add Passkey"
406+
- **Log in with passkey**: On the login page, click the "Sign in with a Passkey" button
407+
- **Manage passkeys**: From your profile page, rename or delete registered passkeys
408+
409+
**Development notes:**
410+
- HTTP works on `localhost` without HTTPS
411+
- For testing on other devices, use ngrok (`ngrok http 8080`) and update `rpId` and `allowedOrigins` to match the ngrok domain
412+
- The database tables (`user_entities`, `user_credentials`) are created automatically by Hibernate
413+
379414
### Environment Variables
380415
381416
For production deployments, use environment variables instead of hardcoding values:
@@ -640,6 +675,18 @@ Solution:
640675
4. Verify Keycloak realm and client settings
641676
```
642677
678+
#### WebAuthn/Passkey Issues
679+
**Problem**: Passkey registration or login fails
680+
```
681+
Solution:
682+
1. Verify user.webauthn.enabled is true in application.yml
683+
2. Check that rpId matches your domain (localhost for local dev)
684+
3. Ensure allowedOrigins matches the exact browser URL (including port)
685+
4. Verify /webauthn/authenticate/** and /login/webauthn are in unprotectedURIs
686+
5. For non-localhost testing, HTTPS is required - use ngrok
687+
6. Check browser console for WebAuthn API errors
688+
```
689+
643690
#### Email Not Sending
644691
**Problem**: Registration emails not received
645692
```

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.2.0'
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'

src/main/resources/application-prd.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,9 @@ management:
3737
show-details: never # Don't expose detailed health info in production
3838

3939
user:
40+
webauthn:
41+
rpId: ${WEBAUTHN_RP_ID:example.com}
42+
rpName: ${WEBAUTHN_RP_NAME:Spring User Framework Demo}
43+
allowedOrigins: ${WEBAUTHN_ALLOWED_ORIGINS:https://example.com}
4044
security:
4145
disableCSRFdURIs: # No CSRF disabled URIs in production for better security

src/main/resources/application.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ user:
105105
sendVerificationEmail: true # 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: 26 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,30 @@ 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 && passkeyBtn && isWebAuthnSupported()) {
19+
passkeySection.style.display = "block";
20+
const passkeyError = document.getElementById("passkeyError");
21+
22+
passkeyBtn.addEventListener("click", async () => {
23+
passkeyBtn.disabled = true;
24+
passkeyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Authenticating...';
25+
26+
try {
27+
const redirectUrl = await authenticateWithPasskey();
28+
window.location.href = redirectUrl;
29+
} catch (error) {
30+
console.error("Passkey authentication failed:", error);
31+
showMessage(passkeyError, "Passkey authentication failed. Please try again.", "alert-danger");
32+
passkeyBtn.disabled = false;
33+
passkeyBtn.innerHTML = '<i class="bi bi-key me-2"></i> Sign in with Passkey';
34+
}
35+
});
36+
}
1137
});
1238

1339
function validateForm(form) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
let msg = 'Authentication failed';
77+
try {
78+
const data = await finishResponse.json();
79+
msg = data.message || msg;
80+
} catch {
81+
const text = await finishResponse.text();
82+
if (text) msg = text;
83+
}
84+
throw new Error(msg);
85+
}
86+
87+
// Spring Security returns { authenticated: true, redirectUrl: "..." }
88+
const authResponse = await finishResponse.json();
89+
if (!authResponse || !authResponse.authenticated || !authResponse.redirectUrl) {
90+
throw new Error('Authentication failed');
91+
}
92+
93+
return authResponse.redirectUrl;
94+
}

0 commit comments

Comments
 (0)