Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,6 @@ playwright/test-results/
playwright/playwright-report/
playwright/reports/
playwright/.env

# Curl cookie files
cookies.txt
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ This version uses:

- **Authentication & Security**
- Username/password authentication
- WebAuthn/Passkey passwordless login (biometrics, security keys)
- Passkey management (register, rename, delete)
- OAuth2 login with Google, Facebook, and Keycloak
- Role-based access control
- CSRF protection
Expand Down Expand Up @@ -376,6 +378,39 @@ To enable SSO:

Then update your OAuth2 providers' callback URLs to use the ngrok domain.

---

#### **WebAuthn / Passkeys**

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.

**Configuration** (in `application.yml`):
```yaml
user:
webauthn:
enabled: true # Enable passkey support
rpId: localhost # Must match your domain
rpName: Spring User Framework Demo # Display name shown during registration
allowedOrigins: http://localhost:8080 # Must match browser origin exactly
```

**Important**: You must also add the WebAuthn endpoints to your unprotected URIs:
```yaml
user:
security:
unprotectedURIs: ...,/webauthn/authenticate/**,/login/webauthn
```

**How it works:**
- **Register a passkey**: Log in with username/password, go to your profile page, and click "Add Passkey"
- **Log in with passkey**: On the login page, click the "Sign in with a Passkey" button
- **Manage passkeys**: From your profile page, rename or delete registered passkeys

**Development notes:**
- HTTP works on `localhost` without HTTPS
- For testing on other devices, use ngrok (`ngrok http 8080`) and update `rpId` and `allowedOrigins` to match the ngrok domain
- The database tables (`user_entities`, `user_credentials`) are created automatically by Hibernate

### Environment Variables

For production deployments, use environment variables instead of hardcoding values:
Expand Down Expand Up @@ -640,6 +675,18 @@ Solution:
4. Verify Keycloak realm and client settings
```

#### WebAuthn/Passkey Issues
**Problem**: Passkey registration or login fails
```
Solution:
1. Verify user.webauthn.enabled is true in application.yml
2. Check that rpId matches your domain (localhost for local dev)
3. Ensure allowedOrigins matches the exact browser URL (including port)
4. Verify /webauthn/authenticate/** and /login/webauthn are in unprotectedURIs
5. For non-localhost testing, HTTPS is required - use ngrok
6. Check browser console for WebAuthn API errors
```

#### Email Not Sending
**Problem**: Registration emails not received
```
Expand Down
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.2.0'

// WebAuthn support (Passkey authentication)
implementation 'org.springframework.security:spring-security-webauthn'

// Spring Boot starters
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application-prd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,9 @@ management:
show-details: never # Don't expose detailed health info in production

user:
webauthn:
rpId: ${WEBAUTHN_RP_ID:example.com}
rpName: ${WEBAUTHN_RP_NAME:Spring User Framework Demo}
allowedOrigins: ${WEBAUTHN_ALLOWED_ORIGINS:https://example.com}
security:
disableCSRFdURIs: # No CSRF disabled URIs in production for better security
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
26 changes: 26 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,30 @@ 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 && passkeyBtn && isWebAuthnSupported()) {
passkeySection.style.display = "block";
const passkeyError = document.getElementById("passkeyError");

Comment on lines 14 to 21
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repo has Playwright coverage for login flows, but the new passkey login UI/behavior isn’t covered (button visibility when WebAuthn is supported/unsupported, error handling, redirect on success). Consider adding Playwright tests that (1) verify the passkey button is hidden when PublicKeyCredential is unavailable (via page.addInitScript), and (2) verify it becomes visible when supported (even if the full WebAuthn ceremony is mocked).

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This project uses Selenide (not Playwright) for UI tests. WebAuthn/passkey UI tests will be addressed in a follow-up PR, as they require virtual authenticator setup that's out of scope for this initial implementation.

passkeyBtn.addEventListener("click", async () => {
passkeyBtn.disabled = true;
Comment on lines 14 to 23
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

passkeyBtn is used without checking it exists. If the template changes or the button is missing, passkeyBtn.addEventListener(...) will throw and prevent the rest of the login page JS from running. Add a null check for passkeyBtn in the same condition as passkeySection (or query it from within passkeySection).

Copilot uses AI. Check for mistakes.
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(passkeyError, "Passkey authentication failed. Please try again.", "alert-danger");
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = '<i class="bi bi-key me-2"></i> Sign in with Passkey';
Comment on lines 29 to 33
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showMessage(null, ...) is a no-op (shared.js returns early when the container is falsy), so passkey authentication failures won’t display any user-visible error. Add an error container to the login page (e.g., an alert div) and pass it to showMessage, or reuse the existing #loginError element when present.

Copilot uses AI. Check for mistakes.
}
});
}
});

function validateForm(form) {
Expand Down
94 changes: 94 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,94 @@
/**
* 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) {
let msg = 'Authentication failed';
try {
const data = await finishResponse.json();
msg = data.message || msg;
} catch {
const text = await finishResponse.text();
if (text) msg = text;
}
throw new Error(msg);
}

// 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;
}
Loading
Loading