diff --git a/CONFIG.md b/CONFIG.md index 00eb99d..f9e078b 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -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. diff --git a/build.gradle b/build.gradle index 617d902..cf284d4 100644 --- a/build.gradle +++ b/build.gradle @@ -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' @@ -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' diff --git a/db-scripts/webauthn-schema.sql b/db-scripts/webauthn-schema.sql new file mode 100644 index 0000000..66303f2 --- /dev/null +++ b/db-scripts/webauthn-schema.sql @@ -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'; diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java new file mode 100644 index 0000000..bc0c36d --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java @@ -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. + * + *

+ * 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. + *

+ * + *

+ * Endpoints: + *

+ * + */ +@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> 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 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 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. + * + *

+ * Updates the user-friendly label for a passkey. The label helps users identify their passkeys (e.g., "My iPhone", "Work Laptop"). + *

+ * + *

+ * The label must be non-empty and no more than 255 characters. + *

+ * + * @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 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. + * + *

+ * Soft-deletes a passkey by marking it as disabled. Includes last-credential protection to prevent users from being + * locked out of their accounts. + *

+ * + *

+ * If this is the user's last passkey and they have no password, the deletion will be blocked with an error message. + *

+ * + * @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 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) { + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java b/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java new file mode 100644 index 0000000..924621c --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/WebAuthnCredentialInfo.java @@ -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; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java b/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java new file mode 100644 index 0000000..ac3f1b4 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnException.java @@ -0,0 +1,55 @@ +package com.digitalsanctuary.spring.user.exceptions; + +/** + * Exception thrown for WebAuthn-related errors. + * + *

+ * This is a checked exception used to signal WebAuthn-specific business logic errors such as: + *

+ *
    + *
  • Attempting to delete the last passkey when user has no password
  • + *
  • Invalid credential label (empty, too long)
  • + *
  • Credential not found or access denied
  • + *
  • User not found during credential operations
  • + *
+ */ +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); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java new file mode 100644 index 0000000..7a55fe5 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnCredentialQueryRepository.java @@ -0,0 +1,155 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Base64; +import java.util.List; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.dto.WebAuthnCredentialInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Repository for WebAuthn credential queries and management. + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class WebAuthnCredentialQueryRepository { + + private final JdbcTemplate jdbcTemplate; + + /** + * Get all credentials for a user. + * + * @param userId the user ID + * @return list of credential + */ + public List findCredentialsByUserId(Long userId) { + String sql = """ + SELECT c.credential_id, c.label, c.created, c.last_used, + c.authenticator_transports, c.backup_eligible, c.backup_state + FROM user_credentials c + JOIN user_entities ue ON c.user_entity_user_id = ue.id + WHERE ue.user_account_id = ? + ORDER BY c.created DESC + """; + + return jdbcTemplate.query(sql, this::mapCredentialInfo, userId); + } + + /** + * Check if user has any passkeys. + * + * @param userId the user ID + * @return true if user has at least one passkey + */ + public boolean hasCredentials(Long userId) { + String sql = """ + SELECT COUNT(*) + FROM user_credentials c + JOIN user_entities ue ON c.user_entity_user_id = ue.id + WHERE ue.user_account_id = ? + """; + + Integer count = jdbcTemplate.queryForObject(sql, Integer.class, userId); + return count != null && count > 0; + } + + /** + * Count credentials (used for last-credential protection). + * + * @param userId the user ID + * @return count of credentials + */ + public long countCredentials(Long userId) { + String sql = """ + SELECT COUNT(*) + FROM user_credentials c + JOIN user_entities ue ON c.user_entity_user_id = ue.id + WHERE ue.user_account_id = ? + """; + + Long count = jdbcTemplate.queryForObject(sql, Long.class, userId); + return count != null ? count : 0L; + } + + /** + * Rename a credential. + * + * @param credentialId the credential ID (base64url-encoded) + * @param newLabel the new label + * @param userId the user ID + * @return number of rows updated (0 if not found or access denied) + */ + @Transactional + public int renameCredential(String credentialId, String newLabel, Long userId) { + byte[] credIdBytes = Base64.getUrlDecoder().decode(credentialId); + + String sql = """ + UPDATE user_credentials c + SET c.label = ? + WHERE c.credential_id = ? + AND EXISTS ( + SELECT 1 FROM user_entities ue + WHERE ue.id = c.user_entity_user_id + AND ue.user_account_id = ? + ) + """; + + int updated = jdbcTemplate.update(sql, newLabel, credIdBytes, userId); + if (updated > 0) { + log.info("Renamed credential {} to '{}' for user {}", credentialId, newLabel, userId); + } + return updated; + } + + /** + * Delete a credential. + * + * @param credentialId the credential ID (base64url-encoded) + * @param userId the user ID (for security check) + * @return number of rows deleted (0 if not found or access denied) + */ + @Transactional + public int deleteCredential(String credentialId, Long userId) { + byte[] credIdBytes = Base64.getUrlDecoder().decode(credentialId); + + String sql = """ + DELETE FROM user_credentials + WHERE credential_id = ? + AND EXISTS ( + SELECT 1 FROM user_entities ue + WHERE ue.id = user_entity_user_id + AND ue.user_account_id = ? + ) + """; + + int deleted = jdbcTemplate.update(sql, credIdBytes, userId); + if (deleted > 0) { + log.info("Deleted credential {} for user {}", credentialId, userId); + } + return deleted; + } + + /** + * Map ResultSet to WebAuthnCredentialInfo. + * + * @param rs the ResultSet + * @param rowNum the row number + * @return the WebAuthnCredentialInfo + * @throws SQLException if a database access error occurs + */ + private WebAuthnCredentialInfo mapCredentialInfo(ResultSet rs, int rowNum) throws SQLException { + byte[] credId = rs.getBytes("credential_id"); + String credIdStr = Base64.getUrlEncoder().withoutPadding().encodeToString(credId); + + return WebAuthnCredentialInfo.builder().id(credIdStr).label(rs.getString("label")) + .created(rs.getTimestamp("created").toInstant()) + .lastUsed(rs.getTimestamp("last_used") != null ? rs.getTimestamp("last_used").toInstant() : null) + .transports(rs.getString("authenticator_transports")).backupEligible(rs.getBoolean("backup_eligible")) + .backupState(rs.getBoolean("backup_state")).build(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java new file mode 100644 index 0000000..85c0241 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridge.java @@ -0,0 +1,133 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import java.nio.ByteBuffer; +import java.util.Optional; +import org.springframework.context.annotation.Primary; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.management.JdbcPublicKeyCredentialUserEntityRepository; +import org.springframework.security.web.webauthn.management.PublicKeyCredentialUserEntityRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.extern.slf4j.Slf4j; + +/** + *

+ * Primary {@link PublicKeyCredentialUserEntityRepository} that bridges Spring Security's WebAuthn system with the Spring User Framework's User + * entity. It handles edge cases like anonymousUser and null usernames, and automatically creates WebAuthn user entities for existing application + * users. + *

+ * + *

+ * Marked as {@code @Primary} so Spring Security's WebAuthn filters use this bridge instead of the bare JDBC repository. + *

+ */ +@Repository +@Primary +@Slf4j +public class WebAuthnUserEntityBridge implements PublicKeyCredentialUserEntityRepository { + + private final JdbcPublicKeyCredentialUserEntityRepository delegate; + private final JdbcTemplate jdbcTemplate; + private final UserRepository userRepository; + + /** + * Constructor creates the JDBC delegate internally to avoid circular bean dependency. + * + * @param jdbcTemplate the JDBC template + * @param userRepository the user repository + */ + public WebAuthnUserEntityBridge(JdbcTemplate jdbcTemplate, UserRepository userRepository) { + this.jdbcTemplate = jdbcTemplate; + this.userRepository = userRepository; + this.delegate = new JdbcPublicKeyCredentialUserEntityRepository(jdbcTemplate); + } + + @Override + public PublicKeyCredentialUserEntity findById(Bytes id) { + return delegate.findById(id); + } + + @Override + public PublicKeyCredentialUserEntity findByUsername(String username) { + // Handle edge cases that can occur during login + if (username == null || username.isEmpty() || "anonymousUser".equals(username)) { + log.debug("Ignoring invalid username: {}", username); + return null; + } + + // Check if user entity already exists + PublicKeyCredentialUserEntity existing = delegate.findByUsername(username); + if (existing != null) { + return existing; + } + + // User entity doesn't exist yet - check if application user exists + User user = userRepository.findByEmail(username); + if (user == null) { + log.debug("No application user found for username: {}", username); + return null; + } + + // Create WebAuthn user entity for this application user + return createUserEntity(user); + } + + @Override + public void save(PublicKeyCredentialUserEntity userEntity) { + delegate.save(userEntity); + } + + @Override + public void delete(Bytes id) { + delegate.delete(id); + } + + /** + * Create user entity from User model and link to user_account via user_account_id. + * + * @param user the User entity + * @return the created PublicKeyCredentialUserEntity + */ + @Transactional + public PublicKeyCredentialUserEntity createUserEntity(User user) { + Bytes userId = new Bytes(longToBytes(user.getId())); + String displayName = user.getFullName(); + + PublicKeyCredentialUserEntity entity = ImmutablePublicKeyCredentialUserEntity.builder().name(user.getEmail()).id(userId) + .displayName(displayName).build(); + + // Let Spring Security's JDBC repository do the standard INSERT + delegate.save(entity); + + // Set our custom user_account_id column to link to the app user + jdbcTemplate.update("UPDATE user_entities SET user_account_id = ? WHERE name = ?", user.getId(), user.getEmail()); + + log.info("Created WebAuthn user entity for user: {}", user.getEmail()); + return entity; + } + + /** + * Find by username, returning Optional for internal use. + * + * @param username the username (email) to look up + * @return Optional containing the PublicKeyCredentialUserEntity, or empty if not found + */ + public Optional findOptionalByUsername(String username) { + PublicKeyCredentialUserEntity entity = findByUsername(username); + return Optional.ofNullable(entity); + } + + /** + * Convert Long ID to byte array for WebAuthn user ID. + * + * @param value the Long value to convert + * @return byte array representation of the Long + */ + private byte[] longToBytes(Long value) { + return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java new file mode 100644 index 0000000..26d3e83 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandler.java @@ -0,0 +1,93 @@ +package com.digitalsanctuary.spring.user.security; + +import java.io.IOException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.HttpMessageConverterAuthenticationSuccessHandler; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * Authentication success handler for WebAuthn (Passkey) login that converts the {@link WebAuthnAuthentication} principal from + * {@link org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity} to the application's {@code DSUserDetails}. + * + *

+ * Spring Security's {@code WebAuthnAuthenticationProvider} creates a {@code WebAuthnAuthentication} with {@code PublicKeyCredentialUserEntity} as the + * principal, discarding the {@code UserDetails} it loaded. This handler restores the full {@code DSUserDetails} as the principal so the rest of the + * application works identically regardless of login method (form login, OAuth2, or passkey). + *

+ * + *

+ * The handler delegates to {@link HttpMessageConverterAuthenticationSuccessHandler} to write the JSON response expected by the WebAuthn JavaScript + * client ({@code {"authenticated": true, "redirectUrl": "..."}}). + *

+ */ +@Slf4j +public class WebAuthnAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private final UserDetailsService userDetailsService; + private final AuthenticationSuccessHandler delegate; + private final SecurityContextRepository securityContextRepository; + private final SecurityContextHolderStrategy securityContextHolderStrategy; + + /** + * Creates a new handler with the given {@code UserDetailsService} and a default {@link HttpMessageConverterAuthenticationSuccessHandler} delegate. + * + * @param userDetailsService the service to load the full user details + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService) { + this(userDetailsService, new HttpMessageConverterAuthenticationSuccessHandler()); + } + + /** + * Creates a new handler with the given {@code UserDetailsService} and delegate handler. + * + * @param userDetailsService the service to load the full user details + * @param delegate the handler to delegate to after principal conversion + */ + public WebAuthnAuthenticationSuccessHandler(UserDetailsService userDetailsService, AuthenticationSuccessHandler delegate) { + this.userDetailsService = userDetailsService; + this.delegate = delegate; + this.securityContextRepository = new HttpSessionSecurityContextRepository(); + this.securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy(); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + if (authentication instanceof WebAuthnAuthentication) { + String username = authentication.getName(); + log.debug("Converting WebAuthn authentication principal to DSUserDetails for user: {}", username); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + // Create new authentication with DSUserDetails as principal, preserving authorities + Authentication convertedAuth = UsernamePasswordAuthenticationToken.authenticated(userDetails, null, + authentication.getAuthorities()); + + // Update SecurityContext with the converted authentication + SecurityContext context = securityContextHolderStrategy.getContext(); + context.setAuthentication(convertedAuth); + securityContextHolderStrategy.setContext(context); + + // Re-save to session (AbstractAuthenticationProcessingFilter already saved the old context) + securityContextRepository.saveContext(context, request, response); + + log.info("WebAuthn authentication principal converted to DSUserDetails for user: {}", username); + delegate.onAuthenticationSuccess(request, response, convertedAuth); + } else { + delegate.onAuthenticationSuccess(request, response, authentication); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java new file mode 100644 index 0000000..01ca429 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java @@ -0,0 +1,33 @@ +package com.digitalsanctuary.spring.user.security; + +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; +import lombok.Data; + +/** + * Configuration properties for WebAuthn (Passkey) authentication. + */ +@Data +@ConfigurationProperties(prefix = "user.webauthn") +public class WebAuthnConfigProperties { + + /** + * Relying Party ID. + */ + private String rpId = "localhost"; + + /** + * Relying Party Name. + */ + private String rpName = "Spring User Framework"; + + /** + * Allowed origins for WebAuthn operations. + */ + private Set allowedOrigins = Set.of("https://localhost:8443"); + + /** + * Whether Passkey support is enabled. + */ + private boolean enabled = true; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java new file mode 100644 index 0000000..0933218 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java @@ -0,0 +1,42 @@ +package com.digitalsanctuary.spring.user.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.web.webauthn.management.JdbcUserCredentialRepository; +import org.springframework.security.web.webauthn.management.UserCredentialRepository; +import lombok.extern.slf4j.Slf4j; + +/** + * Configuration for WebAuthn repositories. + * + *

+ * Note: The {@code PublicKeyCredentialUserEntityRepository} bean is provided by + * {@link com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityBridge} which is marked + * as {@code @Primary} and bridges Spring Security's WebAuthn user entities with the application's User model. + *

+ */ +@Slf4j +@Configuration +public class WebAuthnRepositoryConfig { + + /** + *

+ * This repository handles credential CRUD operations including: + *

+ *
    + *
  • save() - Store new credentials after registration to user_credentials table
  • + *
  • findByCredentialId() - Look up credentials during authentication
  • + *
  • findByUserId() - Get all credentials for a user
  • + *
  • delete() - Remove credentials from database
  • + *
+ * + * @param jdbcTemplate for database operations + * @return the UserCredentialRepository instance + */ + @Bean + public UserCredentialRepository userCredentialRepository(JdbcTemplate jdbcTemplate) { + log.info("Initializing WebAuthn UserCredentialRepository"); + return new JdbcUserCredentialRepository(jdbcTemplate); + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index b201d1b..565d326 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; @@ -111,6 +113,18 @@ public class WebSecurityConfig { @Value("${user.security.rememberMe.key:#{null}}") private String rememberMeKey; + @Value("${user.webauthn.enabled:true}") + private boolean webAuthnEnabled; + + @Value("${user.webauthn.rpId:localhost}") + private String webAuthnRpId; + + @Value("${user.webauthn.rpName:Spring User Framework}") + private String webAuthnRpName; + + @Value("${user.webauthn.allowedOrigins:https://localhost:8443}") + private String webAuthnAllowedOriginsProperty; + private final UserDetailsService userDetailsService; private final LoginSuccessService loginSuccessService; @@ -158,6 +172,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti setupOAuth2(http); } + // Configure WebAuthn (Passkey) if enabled + if (webAuthnEnabled) { + setupWebAuthn(http); + } + // Configure authorization rules based on the default action if (DEFAULT_ACTION_DENY.equals(getDefaultAction())) { // Allow access to unprotected URIs and require authentication for all other requests @@ -198,6 +217,31 @@ private void setupOAuth2(HttpSecurity http) throws Exception { })); } + /** + * Setup WebAuthn (Passkey) specific configuration. + * + * @param http the http security object to configure + * @throws Exception the exception + */ + private void setupWebAuthn(HttpSecurity http) throws Exception { + // Parse comma-separated origins into Set + Set allowedOrigins = Arrays.stream(webAuthnAllowedOriginsProperty.split(",")).map(String::trim) + .collect(java.util.stream.Collectors.toSet()); + + log.debug("WebSecurityConfig.setupWebAuthn: rpId={}, rpName={}, allowedOrigins={}", webAuthnRpId, webAuthnRpName, allowedOrigins); + + http.webAuthn(webAuthn -> webAuthn.rpName(webAuthnRpName).rpId(webAuthnRpId).allowedOrigins(allowedOrigins) + .withObjectPostProcessor( + new org.springframework.security.config.ObjectPostProcessor() { + @Override + public O postProcess( + O filter) { + filter.setAuthenticationSuccessHandler(new WebAuthnAuthenticationSuccessHandler(userDetailsService)); + return filter; + } + })); + } + // Commenting this out to try adding /error to the unprotected URIs list instead // @Bean // public WebSecurityCustomizer webSecurityCustomizer() { diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java new file mode 100644 index 0000000..893beca --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementService.java @@ -0,0 +1,142 @@ +package com.digitalsanctuary.spring.user.service; + +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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.persistence.repository.WebAuthnCredentialQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for managing WebAuthn credentials. + * + *

+ * Handles credential listing, renaming, and deletion. It includes important safety features like last-credential + * protection to prevent users from being locked out of their accounts. + *

+ * + *

+ * Last-Credential Protection: The service prevents deletion of the last passkey if the user has no password, + * ensuring users always have a way to authenticate. + *

+ * + * @see WebAuthnCredentialQueryRepository + * @see WebAuthnException + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class WebAuthnCredentialManagementService { + + private final WebAuthnCredentialQueryRepository credentialQueryRepository; + + /** + * Get all credentials for a user. + * + * @param user the user to get credentials for + * @return list of credential information. + */ + public List getUserCredentials(User user) { + return credentialQueryRepository.findCredentialsByUserId(user.getId()); + } + + /** + * Check if user has any passkeys. + * + * @param user the user to check + * @return true if user has at least one enabled passkey, false otherwise + */ + public boolean hasCredentials(User user) { + return credentialQueryRepository.hasCredentials(user.getId()); + } + + /** + * Rename a credential label. + * + *

+ * Help users identify their passkeys (e.g., "My iPhone", "Work Laptop"). The label must be non-empty and no more than 255 characters. + *

+ * + * @param credentialId the credential ID to rename + * @param newLabel the new label + * @param user the user performing the operation + * @throws WebAuthnException if the credential is not found, access is denied, or the label is invalid + */ + @Transactional + public void renameCredential(String credentialId, String newLabel, User user) throws WebAuthnException { + validateLabel(newLabel); + + int updated = credentialQueryRepository.renameCredential(credentialId, newLabel, user.getId()); + + if (updated == 0) { + throw new WebAuthnException("Credential not found or access denied"); + } + + log.info("User {} renamed credential {}", user.getEmail(), credentialId); + } + + /** + * Delete a credential with last-credential protection. + * + *

+ * Deletes a credential. This operation includes important safety logic: + *

+ *
    + *
  • If this is the user's last passkey AND the user has no password, deletion is blocked
  • + *
  • This prevents users from being locked out of their accounts
  • + *
  • Users must either add a password or register another passkey before deleting their last one
  • + *
+ * + *

+ * Security: This method verifies that the credential belongs to the specified user before allowing deletion. + *

+ * + * @param credentialId the credential ID to delete + * @param user the user performing the operation + * @throws WebAuthnException if the credential is not found, access is denied, or deletion would lock out the user + */ + @Transactional + public void deleteCredential(String credentialId, User user) throws WebAuthnException { + // Check if this is the last credential and user has no password + long enabledCount = credentialQueryRepository.countCredentials(user.getId()); + + if (enabledCount == 1 && (user.getPassword() == null || user.getPassword().isEmpty())) { + throw new WebAuthnException( + "Cannot delete last passkey. User would be locked out. " + "Please add a password or another passkey first."); + } + + int updated = credentialQueryRepository.deleteCredential(credentialId, user.getId()); + + if (updated == 0) { + throw new WebAuthnException("Credential not found or access denied"); + } + + log.info("User {} deleted credential {}", user.getEmail(), credentialId); + } + + /** + * Validate credential label. + * + *

+ * Ensures the label meets the following requirements: + *

+ *
    + *
  • Not null or empty (after trimming)
  • + *
  • No more than 255 characters
  • + *
+ * + * @param label the label to validate + * @throws WebAuthnException if the label is invalid + */ + private void validateLabel(String label) throws WebAuthnException { + if (label == null || label.trim().isEmpty()) { + throw new WebAuthnException("Credential label cannot be empty"); + } + if (label.length() > 255) { + throw new WebAuthnException("Credential label too long (max 255 characters)"); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java new file mode 100644 index 0000000..d3f6d34 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java @@ -0,0 +1,222 @@ +package com.digitalsanctuary.spring.user.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.userdetails.UserDetails; +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.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; +import com.digitalsanctuary.spring.user.util.GenericResponse; + +@ServiceTest +@DisplayName("WebAuthnManagementAPI Tests") +class WebAuthnManagementAPITest { + + @Mock + private WebAuthnCredentialManagementService credentialManagementService; + + @Mock + private UserService userService; + + @Mock + private UserDetails userDetails; + + @InjectMocks + private WebAuthnManagementAPI api; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = TestFixtures.Users.standardUser(); + when(userDetails.getUsername()).thenReturn(testUser.getEmail()); + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(testUser); + } + + @Nested + @DisplayName("GET /user/webauthn/credentials") + class GetCredentialsTests { + + @Test + @DisplayName("should return credentials for authenticated user") + void shouldReturnCredentials() { + // Given + WebAuthnCredentialInfo cred = WebAuthnCredentialInfo.builder().id("cred-1").label("My iPhone").created(Instant.now()) + .build(); + + when(credentialManagementService.getUserCredentials(testUser)).thenReturn(List.of(cred)); + + // When + ResponseEntity> response = api.getCredentials(userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).hasSize(1); + assertThat(response.getBody().get(0).getLabel()).isEqualTo("My iPhone"); + } + + @Test + @DisplayName("should return empty list when no credentials") + void shouldReturnEmptyList() { + // Given + when(credentialManagementService.getUserCredentials(testUser)).thenReturn(Collections.emptyList()); + + // When + ResponseEntity> response = api.getCredentials(userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEmpty(); + } + + @Test + @DisplayName("should throw when user not found") + void shouldThrowWhenUserNotFound() { + // Given + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null); + + // When/Then + try { + api.getCredentials(userDetails); + } catch (RuntimeException e) { + assertThat(e.getMessage()).isEqualTo("User not found"); + } + } + } + + @Nested + @DisplayName("GET /user/webauthn/has-credentials") + class HasCredentialsTests { + + @Test + @DisplayName("should return true when user has credentials") + void shouldReturnTrue() { + // Given + when(credentialManagementService.hasCredentials(testUser)).thenReturn(true); + + // When + ResponseEntity response = api.hasCredentials(userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isTrue(); + } + + @Test + @DisplayName("should return false when user has no credentials") + void shouldReturnFalse() { + // Given + when(credentialManagementService.hasCredentials(testUser)).thenReturn(false); + + // When + ResponseEntity response = api.hasCredentials(userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isFalse(); + } + } + + @Nested + @DisplayName("PUT /user/webauthn/credentials/{id}/label") + class RenameCredentialTests { + + @Test + @DisplayName("should rename credential successfully") + void shouldRenameSuccessfully() throws WebAuthnException { + // Given + WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("Work Laptop"); + + // When + ResponseEntity response = api.renameCredential("cred-1", request, userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getMessage()).contains("renamed successfully"); + verify(credentialManagementService).renameCredential("cred-1", "Work Laptop", testUser); + } + + @Test + @DisplayName("should return bad request when rename fails") + void shouldReturnBadRequestOnFailure() throws WebAuthnException { + // Given + WebAuthnManagementAPI.RenameCredentialRequest request = new WebAuthnManagementAPI.RenameCredentialRequest("New Name"); + doThrow(new WebAuthnException("Credential not found or access denied")).when(credentialManagementService) + .renameCredential(eq("cred-999"), eq("New Name"), any(User.class)); + + // When + ResponseEntity response = api.renameCredential("cred-999", request, userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getMessage()).contains("not found"); + } + } + + @Nested + @DisplayName("DELETE /user/webauthn/credentials/{id}") + class DeleteCredentialTests { + + @Test + @DisplayName("should delete credential successfully") + void shouldDeleteSuccessfully() throws WebAuthnException { + // When + ResponseEntity response = api.deleteCredential("cred-1", userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getMessage()).contains("deleted successfully"); + verify(credentialManagementService).deleteCredential("cred-1", testUser); + } + + @Test + @DisplayName("should return bad request when delete fails") + void shouldReturnBadRequestOnFailure() throws WebAuthnException { + // Given + doThrow(new WebAuthnException("Cannot delete last passkey")).when(credentialManagementService).deleteCredential(eq("cred-1"), + any(User.class)); + + // When + ResponseEntity response = api.deleteCredential("cred-1", userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getMessage()).contains("Cannot delete last passkey"); + } + + @Test + @DisplayName("should return bad request when user not found") + void shouldReturnBadRequestWhenUserNotFound() throws WebAuthnException { + // Given + when(userService.findUserByEmail(testUser.getEmail())).thenReturn(null); + + // When + ResponseEntity response = api.deleteCredential("cred-1", userDetails); + + // Then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().getMessage()).contains("User not found"); + verify(credentialManagementService, never()).deleteCredential(any(), any()); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java new file mode 100644 index 0000000..a4ac68f --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/persistence/repository/WebAuthnUserEntityBridgeTest.java @@ -0,0 +1,108 @@ +package com.digitalsanctuary.spring.user.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; + +@ServiceTest +@DisplayName("WebAuthnUserEntityBridge Tests") +class WebAuthnUserEntityBridgeTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private UserRepository userRepository; + + @Mock + private PublicKeyCredentialUserEntity existingEntity; + + private WebAuthnUserEntityBridge bridge; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = TestFixtures.Users.standardUser(); + bridge = new WebAuthnUserEntityBridge(jdbcTemplate, userRepository); + } + + @Nested + @DisplayName("Find By Username") + class FindByUsernameTests { + + @Test + @DisplayName("should return null for null username") + void shouldReturnNullForNull() { + // When + PublicKeyCredentialUserEntity result = bridge.findByUsername(null); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("should return null for empty username") + void shouldReturnNullForEmpty() { + // When + PublicKeyCredentialUserEntity result = bridge.findByUsername(""); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("should return null for anonymousUser") + void shouldReturnNullForAnonymousUser() { + // When + PublicKeyCredentialUserEntity result = bridge.findByUsername("anonymousUser"); + + // Then + assertThat(result).isNull(); + } + + @Test + @DisplayName("should return empty when no application user found") + void shouldReturnEmptyWhenNoUser() { + // Given + when(userRepository.findByEmail("unknown@test.com")).thenReturn(null); + + // When + Optional result = bridge.findOptionalByUsername("unknown@test.com"); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("Create User Entity") + class CreateUserEntityTests { + + @Test + @DisplayName("should create entity with correct name and display name") + void shouldCreateEntityWithCorrectFields() { + // When + PublicKeyCredentialUserEntity entity = bridge.createUserEntity(testUser); + + // Then + assertThat(entity.getName()).isEqualTo(testUser.getEmail()); + assertThat(entity.getDisplayName()).isEqualTo(testUser.getFullName()); + assertThat(entity.getId()).isNotNull(); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java new file mode 100644 index 0000000..e25ad6b --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java @@ -0,0 +1,158 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Collection; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity; +import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.service.DSUserDetails; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; + +@ServiceTest +@DisplayName("WebAuthnAuthenticationSuccessHandler Tests") +class WebAuthnAuthenticationSuccessHandlerTest { + + @Mock + private UserDetailsService userDetailsService; + + @Mock + private AuthenticationSuccessHandler delegate; + + private WebAuthnAuthenticationSuccessHandler handler; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private User testUser; + + @BeforeEach + void setUp() { + testUser = TestFixtures.Users.standardUser(); + handler = new WebAuthnAuthenticationSuccessHandler(userDetailsService, delegate); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("WebAuthn Authentication Conversion") + class WebAuthnConversionTests { + + @Test + @DisplayName("should convert WebAuthn principal to DSUserDetails") + void shouldConvertWebAuthnPrincipalToDSUserDetails() throws Exception { + // Given + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When + handler.onAuthenticationSuccess(request, response, webAuthnAuth); + + // Then - delegate should be called with converted authentication + ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authentication.class); + verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.eq(request), org.mockito.ArgumentMatchers.eq(response), + authCaptor.capture()); + + Authentication convertedAuth = authCaptor.getValue(); + assertThat(convertedAuth).isInstanceOf(UsernamePasswordAuthenticationToken.class); + assertThat(convertedAuth.getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) convertedAuth.getPrincipal()).getUser()).isEqualTo(testUser); + } + + @Test + @DisplayName("should update SecurityContext with converted authentication") + void shouldUpdateSecurityContext() throws Exception { + // Given + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When + handler.onAuthenticationSuccess(request, response, webAuthnAuth); + + // Then - SecurityContext should have DSUserDetails as principal + Authentication contextAuth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(contextAuth.getPrincipal()).isInstanceOf(DSUserDetails.class); + assertThat(((DSUserDetails) contextAuth.getPrincipal()).getUser().getEmail()).isEqualTo(testUser.getEmail()); + } + + @Test + @DisplayName("should preserve authorities from WebAuthn authentication") + void shouldPreserveAuthorities() throws Exception { + // Given + Collection authorities = Set.of(new SimpleGrantedAuthority("ROLE_USER"), + new SimpleGrantedAuthority("ROLE_ADMIN")); + PublicKeyCredentialUserEntity userEntity = ImmutablePublicKeyCredentialUserEntity.builder() + .name(testUser.getEmail()).id(new Bytes(new byte[] {1, 2, 3})).displayName(testUser.getFullName()).build(); + + WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities); + + DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities); + when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails); + + // When + handler.onAuthenticationSuccess(request, response, webAuthnAuth); + + // Then + ArgumentCaptor authCaptor = ArgumentCaptor.forClass(Authentication.class); + verify(delegate).onAuthenticationSuccess(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), authCaptor.capture()); + + Authentication convertedAuth = authCaptor.getValue(); + assertThat(convertedAuth.getAuthorities()).hasSize(authorities.size()); + assertThat(convertedAuth.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()) + .containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN"); + } + } + + @Nested + @DisplayName("Non-WebAuthn Authentication") + class NonWebAuthnTests { + + @Test + @DisplayName("should pass through non-WebAuthn authentication unchanged") + void shouldPassThroughNonWebAuthnAuth() throws Exception { + // Given + DSUserDetails dsUserDetails = new DSUserDetails(testUser); + UsernamePasswordAuthenticationToken formAuth = UsernamePasswordAuthenticationToken.authenticated(dsUserDetails, null, + Set.of(new SimpleGrantedAuthority("ROLE_USER"))); + + // When + handler.onAuthenticationSuccess(request, response, formAuth); + + // Then - delegate should be called with original authentication + verify(delegate).onAuthenticationSuccess(request, response, formAuth); + } + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java new file mode 100644 index 0000000..717bc31 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/service/WebAuthnCredentialManagementServiceTest.java @@ -0,0 +1,253 @@ +package com.digitalsanctuary.spring.user.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +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.persistence.repository.WebAuthnCredentialQueryRepository; +import com.digitalsanctuary.spring.user.test.annotations.ServiceTest; +import com.digitalsanctuary.spring.user.test.fixtures.TestFixtures; + +@ServiceTest +@DisplayName("WebAuthnCredentialManagementService Tests") +class WebAuthnCredentialManagementServiceTest { + + @Mock + private WebAuthnCredentialQueryRepository credentialQueryRepository; + + @InjectMocks + private WebAuthnCredentialManagementService service; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = TestFixtures.Users.standardUser(); + } + + @Nested + @DisplayName("Get User Credentials") + class GetUserCredentialsTests { + + @Test + @DisplayName("should return credentials for user") + void shouldReturnCredentialsForUser() { + // Given + WebAuthnCredentialInfo cred = WebAuthnCredentialInfo.builder().id("cred-123").label("My iPhone") + .created(Instant.now()).build(); + + when(credentialQueryRepository.findCredentialsByUserId(testUser.getId())).thenReturn(List.of(cred)); + + // When + List credentials = service.getUserCredentials(testUser); + + // Then + assertThat(credentials).hasSize(1); + assertThat(credentials.get(0).getLabel()).isEqualTo("My iPhone"); + assertThat(credentials.get(0).getId()).isEqualTo("cred-123"); + verify(credentialQueryRepository).findCredentialsByUserId(testUser.getId()); + } + + @Test + @DisplayName("should return empty list when user has no credentials") + void shouldReturnEmptyListWhenNoCredentials() { + // Given + when(credentialQueryRepository.findCredentialsByUserId(testUser.getId())).thenReturn(Collections.emptyList()); + + // When + List credentials = service.getUserCredentials(testUser); + + // Then + assertThat(credentials).isEmpty(); + } + } + + @Nested + @DisplayName("Has Credentials") + class HasCredentialsTests { + + @Test + @DisplayName("should return true when user has credentials") + void shouldReturnTrueWhenHasCredentials() { + // Given + when(credentialQueryRepository.hasCredentials(testUser.getId())).thenReturn(true); + + // When + boolean result = service.hasCredentials(testUser); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("should return false when user has no credentials") + void shouldReturnFalseWhenNoCredentials() { + // Given + when(credentialQueryRepository.hasCredentials(testUser.getId())).thenReturn(false); + + // When + boolean result = service.hasCredentials(testUser); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("Rename Credential") + class RenameCredentialTests { + + @Test + @DisplayName("should rename credential successfully") + void shouldRenameCredentialSuccessfully() throws WebAuthnException { + // Given + when(credentialQueryRepository.renameCredential("cred-123", "Work Laptop", testUser.getId())).thenReturn(1); + + // When + service.renameCredential("cred-123", "Work Laptop", testUser); + + // Then + verify(credentialQueryRepository).renameCredential("cred-123", "Work Laptop", testUser.getId()); + } + + @Test + @DisplayName("should throw when credential not found") + void shouldThrowWhenCredentialNotFound() { + // Given + when(credentialQueryRepository.renameCredential("cred-999", "New Name", testUser.getId())).thenReturn(0); + + // When/Then + assertThatThrownBy(() -> service.renameCredential("cred-999", "New Name", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("should throw when label is null") + void shouldThrowWhenLabelIsNull() { + // When/Then + assertThatThrownBy(() -> service.renameCredential("cred-123", null, testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("cannot be empty"); + + verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong()); + } + + @Test + @DisplayName("should throw when label is empty") + void shouldThrowWhenLabelIsEmpty() { + // When/Then + assertThatThrownBy(() -> service.renameCredential("cred-123", "", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("cannot be empty"); + + verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong()); + } + + @Test + @DisplayName("should throw when label is blank") + void shouldThrowWhenLabelIsBlank() { + // When/Then + assertThatThrownBy(() -> service.renameCredential("cred-123", " ", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("cannot be empty"); + } + + @Test + @DisplayName("should throw when label exceeds 255 characters") + void shouldThrowWhenLabelTooLong() { + // Given + String longLabel = "a".repeat(256); + + // When/Then + assertThatThrownBy(() -> service.renameCredential("cred-123", longLabel, testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("too long"); + + verify(credentialQueryRepository, never()).renameCredential(anyString(), anyString(), anyLong()); + } + } + + @Nested + @DisplayName("Delete Credential") + class DeleteCredentialTests { + + @Test + @DisplayName("should delete credential when user has multiple passkeys") + void shouldDeleteWhenMultiplePasskeys() throws WebAuthnException { + // Given + when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(2L); + when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1); + + // When + service.deleteCredential("cred-123", testUser); + + // Then + verify(credentialQueryRepository).deleteCredential("cred-123", testUser.getId()); + } + + @Test + @DisplayName("should delete last credential when user has a password") + void shouldDeleteLastCredentialWhenUserHasPassword() throws WebAuthnException { + // Given - user has password set (from TestFixtures) + when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L); + when(credentialQueryRepository.deleteCredential("cred-123", testUser.getId())).thenReturn(1); + + // When + service.deleteCredential("cred-123", testUser); + + // Then + verify(credentialQueryRepository).deleteCredential("cred-123", testUser.getId()); + } + + @Test + @DisplayName("should block deletion of last passkey when user has no password") + void shouldBlockDeletionOfLastPasskeyWithoutPassword() { + // Given + testUser.setPassword(null); + when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L); + + // When/Then + assertThatThrownBy(() -> service.deleteCredential("cred-123", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("Cannot delete last passkey"); + + verify(credentialQueryRepository, never()).deleteCredential(anyString(), anyLong()); + } + + @Test + @DisplayName("should block deletion of last passkey when user has empty password") + void shouldBlockDeletionOfLastPasskeyWithEmptyPassword() { + // Given + testUser.setPassword(""); + when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(1L); + + // When/Then + assertThatThrownBy(() -> service.deleteCredential("cred-123", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("Cannot delete last passkey"); + + verify(credentialQueryRepository, never()).deleteCredential(anyString(), anyLong()); + } + + @Test + @DisplayName("should throw when credential not found") + void shouldThrowWhenCredentialNotFound() { + // Given + when(credentialQueryRepository.countCredentials(testUser.getId())).thenReturn(2L); + when(credentialQueryRepository.deleteCredential("cred-999", testUser.getId())).thenReturn(0); + + // When/Then + assertThatThrownBy(() -> service.deleteCredential("cred-999", testUser)).isInstanceOf(WebAuthnException.class) + .hasMessageContaining("not found"); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index c3f0334..9c6e6f7 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -20,6 +20,9 @@ user.tokenService.expirationInMinutes=60 user.roles.default=ROLE_USER user.audit.enabled=false +# WebAuthn (Passkey) - disabled by default for tests +user.webauthn.enabled=false + spring.datasource.driver-class-name=org.h2.Driver spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL