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
31 changes: 31 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,37 @@ user:
- **Account Lockout Duration (`spring.security.accountLockoutDuration`)**: Duration (in minutes) for account lockout.
- **BCrypt Strength (`spring.security.bcryptStrength`)**: Adjust the bcrypt strength for password hashing. Default is `12`.

## WebAuthn / Passkey Settings

Provides passwordless login using biometrics, security keys, or device authentication. **HTTPS is required** for WebAuthn to function.

- **Enabled (`user.webauthn.enabled`)**: Enable or disable WebAuthn/Passkey support. Defaults to `true`.
- **Relying Party ID (`user.webauthn.rpId`)**: For development, use `localhost`. For production, use your domain (e.g., `example.com`). Defaults to `localhost`.
- **Relying Party Name (`user.webauthn.rpName`)**: The display name.
- **Allowed Origins (`user.webauthn.allowedOrigins`)**: Comma-separated list of allowed origins. Defaults to `https://localhost:8443`.

**Development Example:**
```properties
user.webauthn.enabled=true
user.webauthn.rpId=localhost
user.webauthn.rpName=My Application
user.webauthn.allowedOrigins=https://localhost:8443
```

**Production Example:**
```properties
user.webauthn.enabled=true
user.webauthn.rpId=example.com
user.webauthn.rpName=My Application
user.webauthn.allowedOrigins=https://example.com
```

**Important Notes:**
- WebAuthn requires HTTPS in production. Generate a proper SSL certificate (Let's Encrypt, commercial CA).
- For development, generate a self-signed certificate: `keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore keystore.p12 -validity 3650`
- Configure SSL in `application.properties`: `server.ssl.enabled=true`, `server.ssl.key-store=classpath:keystore.p12`
- Users must be authenticated before they can register a passkey. Passkeys enhance existing authentication, not replace initial registration.

## Mail Configuration

- **From Address (`spring.mail.fromAddress`)**: The email address used as the sender in outgoing emails.
Expand Down
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ dependencies {
compileOnly 'jakarta.validation:jakarta.validation-api:3.1.1'
compileOnly 'org.springframework.retry:spring-retry:2.0.12'

// WebAuthn support (Passkey authentication)
compileOnly 'org.springframework.security:spring-security-webauthn'
implementation 'com.webauthn4j:webauthn4j-core:0.30.2.RELEASE'

// Lombok dependencies
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
Expand All @@ -70,6 +74,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.security:spring-security-webauthn'
testImplementation 'org.springframework.retry:spring-retry:2.0.12'
testImplementation 'jakarta.validation:jakarta.validation-api:3.1.1'
testImplementation 'org.hibernate.validator:hibernate-validator:9.1.0.Final'
Expand Down
54 changes: 54 additions & 0 deletions db-scripts/webauthn-schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- WebAuthn (Passkey) schema for Spring Security 7.0.2
-- These table names and column names must match Spring Security's
-- JdbcPublicKeyCredentialUserEntityRepository and JdbcUserCredentialRepository defaults.
--
-- IMPORTANT: Run the application first so Hibernate creates the user_account table,
-- then run this script.

-- Table: user_entities
-- Links WebAuthn user handles to application users.
-- Spring Security expects: id, name, display_name
-- We add: user_account_id (FK to user_account for efficient joins)
CREATE TABLE IF NOT EXISTS `user_entities` (
`id` VARCHAR(255) NOT NULL COMMENT 'Base64url-encoded WebAuthn user handle',
`name` VARCHAR(255) NOT NULL COMMENT 'Username (email)',
`display_name` VARCHAR(255) NOT NULL COMMENT 'User full name for display',
`user_account_id` BIGINT(20) DEFAULT NULL COMMENT 'FK to user_account table (custom column)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_entities_name` (`name`),
KEY `idx_user_entities_user_account_id` (`user_account_id`),
CONSTRAINT `fk_user_entities_user_account`
FOREIGN KEY (`user_account_id`)
REFERENCES `user_account` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='WebAuthn user entities mapped to application users';

-- Table: user_credentials
-- Stores WebAuthn credentials (public keys) for passkey authentication.
-- All columns match Spring Security's expected schema.
CREATE TABLE IF NOT EXISTS `user_credentials` (
`credential_id` BLOB NOT NULL COMMENT 'Raw credential ID from authenticator',
`user_entity_user_id` VARCHAR(255) NOT NULL COMMENT 'FK to user_entities.id',
`public_key` BLOB NOT NULL COMMENT 'COSE-encoded public key',
`signature_count` BIGINT(20) NOT NULL DEFAULT 0 COMMENT 'Counter to detect cloned authenticators',
`uv_initialized` BIT(1) NOT NULL DEFAULT 0 COMMENT 'User verification performed during registration',
`backup_eligible` BIT(1) DEFAULT 0 COMMENT 'Credential can be synced (iCloud Keychain, etc.)',
`authenticator_transports` VARCHAR(255) DEFAULT NULL COMMENT 'Supported transports: usb, nfc, ble, internal',
`public_key_credential_type` VARCHAR(255) DEFAULT NULL COMMENT 'Credential type (e.g. public-key)',
`backup_state` BIT(1) DEFAULT 0 COMMENT 'Credential is currently backed up',
`attestation_object` BLOB DEFAULT NULL COMMENT 'Attestation data from registration',
`attestation_client_data_json` BLOB DEFAULT NULL COMMENT 'Client data JSON from registration',
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_used` TIMESTAMP NULL DEFAULT NULL,
`label` VARCHAR(255) DEFAULT NULL COMMENT 'User-friendly name (e.g., "My iPhone", "YubiKey")',
PRIMARY KEY (`credential_id`(255)),
KEY `idx_user_credentials_user_entity` (`user_entity_user_id`),
KEY `idx_user_credentials_created` (`created`),
KEY `idx_user_credentials_last_used` (`last_used`),
CONSTRAINT `fk_user_credentials_user_entity`
FOREIGN KEY (`user_entity_user_id`)
REFERENCES `user_entities` (`id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
COMMENT='WebAuthn credentials (public keys) for passkey authentication';
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.digitalsanctuary.spring.user.api;

import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo;
import com.digitalsanctuary.spring.user.exceptions.WebAuthnException;
import com.digitalsanctuary.spring.user.persistence.model.User;
import com.digitalsanctuary.spring.user.service.UserService;
import com.digitalsanctuary.spring.user.service.WebAuthnCredentialManagementService;
import com.digitalsanctuary.spring.user.util.GenericResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
* REST API for WebAuthn credential management.
*
* <p>
* This controller provides endpoints for managing WebAuthn credentials (passkeys). Authenticated users can list their
* registered passkeys, rename them for easier identification, and delete passkeys they no longer use.
* </p>
*
* <p>
* Endpoints:
* </p>
* <ul>
* <li>GET /user/webauthn/credentials - List all passkeys for the authenticated user</li>
* <li>GET /user/webauthn/has-credentials - Check if user has any passkeys</li>
* <li>PUT /user/webauthn/credentials/{id}/label - Rename a passkey</li>
* <li>DELETE /user/webauthn/credentials/{id} - Delete a passkey</li>
* </ul>
*/
@RestController
@RequestMapping("/user/webauthn")
@RequiredArgsConstructor
@Slf4j
public class WebAuthnManagementAPI {

private final WebAuthnCredentialManagementService credentialManagementService;
private final UserService userService;

/**
* Get user's registered passkeys.
*
* @param userDetails the authenticated user details
* @return ResponseEntity containing list of credential information
*/
@GetMapping("/credentials")
public ResponseEntity<List<WebAuthnCredentialInfo>> getCredentials(@AuthenticationPrincipal UserDetails userDetails) {

User user = userService.findUserByEmail(userDetails.getUsername());
if (user == null) {
log.error("User not found: {}", userDetails.getUsername());
throw new RuntimeException("User not found");
}

List<WebAuthnCredentialInfo> credentials = credentialManagementService.getUserCredentials(user);

return ResponseEntity.ok(credentials);
}

/**
* Check if user has any passkeys.
*
* @param userDetails the authenticated user details
* @return ResponseEntity containing true if user has passkeys, false otherwise
*/
@GetMapping("/has-credentials")
public ResponseEntity<Boolean> hasCredentials(@AuthenticationPrincipal UserDetails userDetails) {

User user = userService.findUserByEmail(userDetails.getUsername());
if (user == null) {
log.error("User not found: {}", userDetails.getUsername());
throw new RuntimeException("User not found");
}

boolean hasCredentials = credentialManagementService.hasCredentials(user);

return ResponseEntity.ok(hasCredentials);
}

/**
* Rename a passkey.
*
* <p>
* Updates the user-friendly label for a passkey. The label helps users identify their passkeys (e.g., "My iPhone", "Work Laptop").
* </p>
*
* <p>
* The label must be non-empty and no more than 255 characters.
* </p>
*
* @param id the credential ID to rename
* @param request the rename request containing the new label
* @param userDetails the authenticated user details
* @return ResponseEntity with success message or error
*/
@PutMapping("/credentials/{id}/label")
public ResponseEntity<GenericResponse> renameCredential(@PathVariable String id, @RequestBody @Valid RenameCredentialRequest request,
@AuthenticationPrincipal UserDetails userDetails) {

try {
User user = userService.findUserByEmail(userDetails.getUsername());
if (user == null) {
throw new WebAuthnException("User not found");
}

credentialManagementService.renameCredential(id, request.label(), user);

return ResponseEntity.ok(new GenericResponse("Passkey renamed successfully"));

} catch (WebAuthnException e) {
log.error("Failed to rename credential: {}", e.getMessage());
return ResponseEntity.badRequest().body(new GenericResponse(e.getMessage()));
}
}

/**
* Delete a passkey.
*
* <p>
* Soft-deletes a passkey by marking it as disabled. Includes last-credential protection to prevent users from being
* locked out of their accounts.
* </p>
*
* <p>
* If this is the user's last passkey and they have no password, the deletion will be blocked with an error message.
* </p>
*
* @param id the credential ID to delete
* @param userDetails the authenticated user details
* @return ResponseEntity with success message or error
*/
@DeleteMapping("/credentials/{id}")
public ResponseEntity<GenericResponse> deleteCredential(@PathVariable String id, @AuthenticationPrincipal UserDetails userDetails) {

try {
User user = userService.findUserByEmail(userDetails.getUsername());
if (user == null) {
throw new WebAuthnException("User not found");
}

credentialManagementService.deleteCredential(id, user);

return ResponseEntity.ok(new GenericResponse("Passkey deleted successfully"));

} catch (WebAuthnException e) {
log.error("Failed to delete credential: {}", e.getMessage());
return ResponseEntity.badRequest().body(new GenericResponse(e.getMessage()));
}
}

/**
* Request DTO for renaming credential.
*
* @param label the new label (must not be blank)
*/
public record RenameCredentialRequest(@NotBlank String label) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.digitalsanctuary.spring.user.dto;

import java.time.Instant;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* DTO for WebAuthn credential information displayed to users.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WebAuthnCredentialInfo {

/** Credential ID. */
private String id;

/** User-friendly label. */
private String label;

/** Creation date. */
private Instant created;

/** Last authentication date. */
private Instant lastUsed;

/** Supported transports (usb, nfc, ble, internal). */
private String transports;

/** Whether credential is backup-eligible (synced passkey). */
private Boolean backupEligible;

/** Whether credential is currently backed up. */
private Boolean backupState;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.digitalsanctuary.spring.user.exceptions;

/**
* Exception thrown for WebAuthn-related errors.
*
* <p>
* This is a checked exception used to signal WebAuthn-specific business logic errors such as:
* </p>
* <ul>
* <li>Attempting to delete the last passkey when user has no password</li>
* <li>Invalid credential label (empty, too long)</li>
* <li>Credential not found or access denied</li>
* <li>User not found during credential operations</li>
* </ul>
*/
public class WebAuthnException extends Exception {

/** Serial Version UID. */
private static final long serialVersionUID = 1L;

/**
* Instantiates a new WebAuthn exception.
*/
public WebAuthnException() {
super();
}

/**
* Instantiates a new WebAuthn exception.
*
* @param message the message
* @param cause the cause
*/
public WebAuthnException(final String message, final Throwable cause) {
super(message, cause);
}

/**
* Instantiates a new WebAuthn exception.
*
* @param message the message
*/
public WebAuthnException(final String message) {
super(message);
}

/**
* Instantiates a new WebAuthn exception.
*
* @param cause the cause
*/
public WebAuthnException(final Throwable cause) {
super(cause);
}
}
Loading
Loading