diff --git a/CONFIG.md b/CONFIG.md index 2fcb84df..b6bcecff 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -178,6 +178,7 @@ Provides passwordless login using biometrics, security keys, or device authentic - **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`. +- **Registration notification (`user.webauthn.notifyOnRegistration`)**: Email the account owner when a passkey is registered on their account. Defaults to `true`. Enrolling a passkey grants a durable new way into the account that survives a password change, since session invalidation ends sessions rather than credentials, so an enrollment the owner did not perform is worth surfacing. A `PasskeyRegistration` audit event is recorded either way. Set to `false` only if your application sends its own equivalent notification. **Development Example:** ```properties diff --git a/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java new file mode 100644 index 00000000..1b75a511 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/event/WebAuthnCredentialRegisteredEvent.java @@ -0,0 +1,52 @@ +package com.digitalsanctuary.spring.user.event; + +import com.digitalsanctuary.spring.user.persistence.model.User; +import lombok.Getter; +import lombok.ToString; +import org.springframework.context.ApplicationEvent; + +/** + * Published when a user registers a new WebAuthn credential (passkey). + * + *

+ * Enrolling a passkey grants a durable new way into the account, and it survives a password change, since session + * invalidation ends sessions rather than credentials. Spring Security owns the endpoint that performs it + * ({@code POST /webauthn/register}), so the framework observes enrollment where it writes through: the JPA + * {@code UserCredentialRepository}. That catches every enrollment regardless of which endpoint triggered it. + *

+ * + *

+ * The event fires only for a genuinely new credential. Spring Security also saves through the same repository on + * every successful assertion, to persist the updated signature count, and that is not a registration. + *

+ */ +@Getter +@ToString(callSuper = false) +public class WebAuthnCredentialRegisteredEvent extends ApplicationEvent { + + private static final long serialVersionUID = 1L; + + /** The user who registered the credential. */ + private final transient User user; + + /** The base64url credential id, useful for correlating with the audit log. */ + private final String credentialId; + + /** The user-supplied label for the credential, or {@code "Passkey"} when none was given. */ + private final String label; + + /** + * Creates the event. + * + * @param source the component publishing the event + * @param user the user who registered the credential + * @param credentialId the base64url credential id + * @param label the credential label + */ + public WebAuthnCredentialRegisteredEvent(Object source, User user, String credentialId, String label) { + super(source); + this.user = user; + this.credentialId = credentialId; + this.label = label; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java new file mode 100644 index 00000000..c7c78625 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListener.java @@ -0,0 +1,57 @@ +package com.digitalsanctuary.spring.user.listener; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties; +import com.digitalsanctuary.spring.user.service.UserEmailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Records and announces passkey enrollment. + * + *

+ * A newly enrolled credential is a durable new way into the account, and it outlives a password change, since + * session invalidation ends sessions rather than credentials. An attacker who reaches an authenticated session can + * therefore leave themselves a way back in. Preventing that is the job of step-up + * ({@code user.security.stepUp.enabled}); this listener is the detective half, so the enrollment is at least + * recorded and visible to the account owner whether or not step-up is switched on. + *

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebAuthnCredentialRegistrationListener implements ApplicationListener { + + private final UserEmailService userEmailService; + private final ApplicationEventPublisher eventPublisher; + private final WebAuthnConfigProperties webAuthnConfigProperties; + + /** + * Audits the enrollment and, unless disabled, emails the account owner. + * + * @param event the registration event + */ + @Override + public void onApplicationEvent(WebAuthnCredentialRegisteredEvent event) { + // Audit first and unconditionally: the notification is a courtesy the operator can switch off, and a mail + // outage must not cost us the security-relevant record of the enrollment. + eventPublisher.publishEvent(AuditEvent.builder().source(this).user(event.getUser()) + .action("PasskeyRegistration").actionStatus("Success") + .message("Passkey registered: " + event.getLabel()).build()); + + if (!webAuthnConfigProperties.isNotifyOnRegistration()) { + return; + } + + try { + userEmailService.sendPasskeyRegisteredNotification(event.getUser(), event.getLabel()); + } catch (RuntimeException e) { + // Never let a mail failure propagate into the registration flow, which has already committed. + log.error("Failed to send passkey registration notification to user {}", event.getUser().getId(), e); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java index e30a9780..4488017f 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnConfigProperties.java @@ -30,4 +30,11 @@ public class WebAuthnConfigProperties { * Whether Passkey support is enabled. */ private boolean enabled = false; + + /** + * Whether to email the account owner when a passkey is registered on their account. Enrolling a passkey grants a + * durable new way into the account that survives a password change, so the owner is told by default. Set to + * {@code false} only if your application sends its own equivalent notification. + */ + private boolean notifyOnRegistration = 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 index 00c04c9a..5a7eb9bb 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebAuthnRepositoryConfig.java @@ -2,9 +2,11 @@ import java.util.Base64; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.web.webauthn.api.AuthenticatorTransport; @@ -15,6 +17,7 @@ import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; import org.springframework.security.web.webauthn.management.UserCredentialRepository; import org.springframework.transaction.annotation.Transactional; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; @@ -47,13 +50,14 @@ public class WebAuthnRepositoryConfig { * * @param credentialRepository JPA repository for WebAuthn credentials * @param userEntityRepository JPA repository for WebAuthn user entities + * @param eventPublisher publishes {@link WebAuthnCredentialRegisteredEvent} when a new credential is enrolled * @return the UserCredentialRepository instance */ @Bean public UserCredentialRepository userCredentialRepository(WebAuthnCredentialRepository credentialRepository, - WebAuthnUserEntityRepository userEntityRepository) { + WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) { log.info("Initializing JPA-backed WebAuthn UserCredentialRepository"); - return new JpaUserCredentialRepository(credentialRepository, userEntityRepository); + return new JpaUserCredentialRepository(credentialRepository, userEntityRepository, eventPublisher); } /** @@ -65,11 +69,13 @@ static class JpaUserCredentialRepository implements UserCredentialRepository { private final WebAuthnCredentialRepository credentialRepository; private final WebAuthnUserEntityRepository userEntityRepository; + private final ApplicationEventPublisher eventPublisher; JpaUserCredentialRepository(WebAuthnCredentialRepository credentialRepository, - WebAuthnUserEntityRepository userEntityRepository) { + WebAuthnUserEntityRepository userEntityRepository, ApplicationEventPublisher eventPublisher) { this.credentialRepository = credentialRepository; this.userEntityRepository = userEntityRepository; + this.eventPublisher = eventPublisher; } @Override @@ -77,7 +83,11 @@ static class JpaUserCredentialRepository implements UserCredentialRepository { public void save(CredentialRecord record) { String credIdStr = toBase64Url(record.getCredentialId().getBytes()); - WebAuthnCredential entity = credentialRepository.findById(credIdStr).orElseGet(WebAuthnCredential::new); + // Spring Security also saves through here on every successful assertion, to persist the updated + // signature count. Only an absent row is a registration; anything else is that update. + Optional existing = credentialRepository.findById(credIdStr); + boolean newCredential = existing.isEmpty(); + WebAuthnCredential entity = existing.orElseGet(WebAuthnCredential::new); entity.setCredentialId(credIdStr); // Look up the user entity @@ -104,6 +114,11 @@ public void save(CredentialRecord record) { entity.setLabel(record.getLabel() != null ? record.getLabel() : "Passkey"); credentialRepository.save(entity); + + if (newCredential) { + eventPublisher.publishEvent(new WebAuthnCredentialRegisteredEvent(this, userEntity.getUser(), + credIdStr, entity.getLabel())); + } } @Override diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java index 2593abd0..6d595062 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserEmailService.java @@ -175,6 +175,29 @@ public void sendRegistrationVerificationEmail(final Long userId, final String ap * @return the map * @throws IllegalArgumentException if appUrl is invalid (for admin-initiated resets) */ + /** + * Notifies the account owner that a passkey was registered on their account. + * + *

+ * Sent for every new credential, however it was enrolled. A passkey the owner did not add is a sign someone + * else reached their session, and it would otherwise be invisible until they went looking. + *

+ * + * @param user the account owner + * @param label the label of the newly registered passkey + */ + public void sendPasskeyRegisteredNotification(final User user, final String label) { + if (user == null || user.getEmail() == null) { + log.warn("UserEmailService.sendPasskeyRegisteredNotification: no recipient, skipping"); + return; + } + Map variables = new HashMap<>(); + variables.put("user", user); + variables.put("label", label != null ? label : "Passkey"); + mailService.sendTemplateMessage(user.getEmail(), "New passkey added to your account", variables, + "mail/webauthn-credential-registered.html"); + } + private Map createEmailVariables(final User user, final String appUrl, final String token, final String confirmationPath) { Map variables = new HashMap<>(); variables.put("token", token); diff --git a/src/main/resources/config/dsspringuserconfig.properties b/src/main/resources/config/dsspringuserconfig.properties index 563f4092..17cb77cc 100644 --- a/src/main/resources/config/dsspringuserconfig.properties +++ b/src/main/resources/config/dsspringuserconfig.properties @@ -194,6 +194,9 @@ user.webauthn.enabled=false user.webauthn.rpId=localhost user.webauthn.rpName=Spring User Framework user.webauthn.allowedOrigins=https://localhost:8443 +# Email the account owner when a passkey is registered. A passkey survives a password change, so an +# unrecognized one is worth telling the owner about. The audit event is recorded regardless of this setting. +user.webauthn.notifyOnRegistration=true # MFA (Multi-Factor Authentication) Configuration (disabled by default; opt-in feature) # When enabled, all authenticated endpoints require all configured factors to be satisfied. diff --git a/src/main/resources/messages/dsspringusermessages.properties b/src/main/resources/messages/dsspringusermessages.properties index 1c80803a..1a7b32b8 100644 --- a/src/main/resources/messages/dsspringusermessages.properties +++ b/src/main/resources/messages/dsspringusermessages.properties @@ -7,6 +7,9 @@ email.registration-confirmation.intro=Thank you for registering with the Spring email.registration-confirmation.link-instructions=You’ve successfully registered. To confirm your account, click the link below. email.registration-confirmation.link-expiration=This link will be valid for 24 hours. If it expires, you can request a new verification email. +email.passkey-registered.intro=A new passkey ({0}) was just added to your account. If you added it, no action is needed. +email.passkey-registered.warning=If you did not add this passkey, someone else may have access to your account. A passkey remains valid even after a password change, so change your password and remove the unrecognized passkey from your account settings, then contact support. + email.signature=Best regards,
The DigitalSanctuary Team diff --git a/src/main/resources/templates/mail/webauthn-credential-registered.html b/src/main/resources/templates/mail/webauthn-credential-registered.html new file mode 100644 index 00000000..63110f7f --- /dev/null +++ b/src/main/resources/templates/mail/webauthn-credential-registered.html @@ -0,0 +1,20 @@ + + + + + New passkey added to your account + + + + +
+ ,
+

+
+

+

+
+

+ + + diff --git a/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java new file mode 100644 index 00000000..a8bcaa24 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/listener/WebAuthnCredentialRegistrationListenerTest.java @@ -0,0 +1,92 @@ +package com.digitalsanctuary.spring.user.listener; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import com.digitalsanctuary.spring.user.audit.AuditEvent; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.security.WebAuthnConfigProperties; +import com.digitalsanctuary.spring.user.service.UserEmailService; + +/** + * A newly enrolled passkey is a durable new way into the account that outlives a password change, so the owner is + * told about it and the enrollment is recorded in the audit log. + */ +@DisplayName("WebAuthn Credential Registration Listener Tests") +class WebAuthnCredentialRegistrationListenerTest { + + private UserEmailService userEmailService; + private ApplicationEventPublisher eventPublisher; + private WebAuthnConfigProperties config; + private User user; + + @BeforeEach + void setUp() { + userEmailService = mock(UserEmailService.class); + eventPublisher = mock(ApplicationEventPublisher.class); + config = new WebAuthnConfigProperties(); + user = new User(); + user.setId(3L); + user.setEmail("passkey-user@test.com"); + } + + private WebAuthnCredentialRegisteredEvent event() { + return new WebAuthnCredentialRegisteredEvent(this, user, "AQIDBA", "Work Laptop"); + } + + private WebAuthnCredentialRegistrationListener listener() { + return new WebAuthnCredentialRegistrationListener(userEmailService, eventPublisher, config); + } + + @Test + @DisplayName("should record an audit event when a passkey is registered") + void shouldRecordAuditEventWhenPasskeyRegistered() { + listener().onApplicationEvent(event()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AuditEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getAction()).isEqualTo("PasskeyRegistration"); + assertThat(captor.getValue().getActionStatus()).isEqualTo("Success"); + assertThat(captor.getValue().getUser()).isEqualTo(user); + } + + @Test + @DisplayName("should notify the account owner by email when a passkey is registered") + void shouldNotifyOwnerWhenPasskeyRegistered() { + listener().onApplicationEvent(event()); + + verify(userEmailService).sendPasskeyRegisteredNotification(user, "Work Laptop"); + } + + @Test + @DisplayName("should still record the audit event when notification email is disabled") + void shouldStillAuditWhenNotificationDisabled() { + // The email is a courtesy the operator may not want; the audit trail is not optional. + config.setNotifyOnRegistration(false); + + listener().onApplicationEvent(event()); + + verify(userEmailService, never()).sendPasskeyRegisteredNotification(any(), any()); + verify(eventPublisher).publishEvent(any(AuditEvent.class)); + } + + @Test + @DisplayName("should record the audit event even when sending the notification fails") + void shouldAuditEvenWhenNotificationFails() { + // A mail outage must not lose the security-relevant record of the enrollment. + org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(userEmailService) + .sendPasskeyRegisteredNotification(any(), any()); + + listener().onApplicationEvent(event()); + + verify(eventPublisher).publishEvent(any(AuditEvent.class)); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java new file mode 100644 index 00000000..b4ec74b6 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnCredentialRegistrationEventTest.java @@ -0,0 +1,105 @@ +package com.digitalsanctuary.spring.user.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +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.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.web.webauthn.api.Bytes; +import org.springframework.security.web.webauthn.api.CredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutableCredentialRecord; +import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCose; +import org.springframework.security.web.webauthn.api.PublicKeyCredentialType; +import com.digitalsanctuary.spring.user.event.WebAuthnCredentialRegisteredEvent; +import com.digitalsanctuary.spring.user.persistence.model.User; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnCredential; +import com.digitalsanctuary.spring.user.persistence.model.WebAuthnUserEntity; +import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnCredentialRepository; +import com.digitalsanctuary.spring.user.persistence.repository.WebAuthnUserEntityRepository; + +/** + * Enrolling a passkey is a credential-altering event the account owner should hear about, and the framework does not + * own the endpoint that does it: Spring Security's {@code POST /webauthn/register} writes through + * {@code JpaUserCredentialRepository}, which is the framework's. Publishing from there catches every enrollment + * regardless of which endpoint triggered it. + *

+ * The trap this pins: {@code UserCredentialRepository.save} is also called on every successful assertion, to persist + * the updated signature count. Publishing on every save would email the user on each login. + *

+ */ +@DisplayName("WebAuthn Credential Registration Event Tests") +class WebAuthnCredentialRegistrationEventTest { + + private static final String CREDENTIAL_ID_B64 = "AQIDBA"; + + private WebAuthnCredentialRepository credentialRepository; + private WebAuthnUserEntityRepository userEntityRepository; + private ApplicationEventPublisher eventPublisher; + private WebAuthnRepositoryConfig.JpaUserCredentialRepository repository; + private User user; + + @BeforeEach + void setUp() { + credentialRepository = mock(WebAuthnCredentialRepository.class); + userEntityRepository = mock(WebAuthnUserEntityRepository.class); + eventPublisher = mock(ApplicationEventPublisher.class); + + user = new User(); + user.setId(7L); + user.setEmail("passkey-user@test.com"); + + WebAuthnUserEntity userEntity = new WebAuthnUserEntity(); + userEntity.setId("dXNlcg"); + userEntity.setName(user.getEmail()); + userEntity.setUser(user); + when(userEntityRepository.findById(any())).thenReturn(Optional.of(userEntity)); + + repository = new WebAuthnRepositoryConfig.JpaUserCredentialRepository(credentialRepository, + userEntityRepository, eventPublisher); + } + + @Test + @DisplayName("should publish a registration event when the credential is new") + void shouldPublishEventWhenCredentialIsNew() { + when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.empty()); + + repository.save(credentialRecord("Work Laptop")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WebAuthnCredentialRegisteredEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().getUser()).isEqualTo(user); + assertThat(captor.getValue().getLabel()).isEqualTo("Work Laptop"); + } + + @Test + @DisplayName("should publish no event when an existing credential is updated") + void shouldPublishNoEventWhenCredentialIsUpdated() { + // Spring Security calls save() inside authenticate() to persist the signature count. Treating that as a + // registration would email the user on every passkey login. + WebAuthnCredential existing = new WebAuthnCredential(); + existing.setCredentialId(CREDENTIAL_ID_B64); + when(credentialRepository.findById(CREDENTIAL_ID_B64)).thenReturn(Optional.of(existing)); + + repository.save(credentialRecord("Work Laptop")); + + verify(eventPublisher, never()).publishEvent(any(WebAuthnCredentialRegisteredEvent.class)); + } + + private static CredentialRecord credentialRecord(String label) { + return ImmutableCredentialRecord.builder().credentialType(PublicKeyCredentialType.PUBLIC_KEY) + .credentialId(new Bytes(new byte[] {1, 2, 3, 4})) + .userEntityUserId(new Bytes("user".getBytes())) + .publicKey(new ImmutablePublicKeyCose(new byte[] {9, 9})) + .signatureCount(0).uvInitialized(true).backupEligible(false).backupState(false) + .created(Instant.now()).lastUsed(Instant.now()).label(label).build(); + } +}