Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*/
@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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
* </p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WebAuthnCredentialRegistrationListener implements ApplicationListener<WebAuthnCredentialRegisteredEvent> {

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());
Comment on lines +42 to +44

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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -65,19 +69,25 @@ 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
@Transactional
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<WebAuthnCredential> existing = credentialRepository.findById(credIdStr);
boolean newCredential = existing.isEmpty();
WebAuthnCredential entity = existing.orElseGet(WebAuthnCredential::new);
entity.setCredentialId(credIdStr);

// Look up the user entity
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* 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.
* </p>
*
* @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<String, Object> 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<String, Object> createEmailVariables(final User user, final String appUrl, final String token, final String confirmationPath) {
Map<String, Object> variables = new HashMap<>();
variables.put("token", token);
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/config/dsspringuserconfig.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/main/resources/messages/dsspringusermessages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="{0}/user/request-new-verification-email.html">request a new verification email</a>.

email.passkey-registered.intro=A new passkey (<strong>{0}</strong>) 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 <em>and</em> remove the unrecognized passkey from your account settings, then contact support.

email.signature=Best regards, <br /><em>The DigitalSanctuary Team</em>


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">

<head>
<title th:remove="all">New passkey added to your account</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>

<body>
<div>
<span th:text="${user.firstName}"></span>, <br />
<p th:utext="#{email.passkey-registered.intro(${label})}"></p>
<br />
<p th:utext="#{email.passkey-registered.warning}"></p>
<br /><br />
</div>
<p th:utext="#{email.signature}"></p>
</body>

</html>
Original file line number Diff line number Diff line change
@@ -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<AuditEvent> 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));
}
}
Loading
Loading