From 5ba86c4a1a40b46462a9e6a93276ff04e28b437e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 12 Mar 2026 09:29:53 -0600 Subject: [PATCH 1/4] feat: Add sample RegistrationGuard for domain-restricted registration Add a profile-gated DomainRegistrationGuard that restricts form/passwordless registration to @example.com emails while allowing all OAuth2/OIDC registrations. Activated only with the `registration-guard` Spring profile. Also bump library dependency to 4.2.3-SNAPSHOT and fix UserServiceTest for the new SessionInvalidationService constructor parameter. Closes #61 Ref: devondragon/SpringUserFramework#271 --- build.gradle | 2 +- .../registration/DomainRegistrationGuard.java | 61 +++++++++++++++++++ .../spring/user/service/UserServiceTest.java | 5 +- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java diff --git a/build.gradle b/build.gradle index d497fae..a9eaaf1 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.1' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.3-SNAPSHOT' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' diff --git a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java new file mode 100644 index 0000000..abb30a5 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java @@ -0,0 +1,61 @@ +package com.digitalsanctuary.spring.demo.registration; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.digitalsanctuary.spring.user.registration.RegistrationContext; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationGuard; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +import lombok.extern.slf4j.Slf4j; + +/** + * Sample {@link RegistrationGuard} that restricts form and passwordless registration to + * {@code @example.com} email addresses while allowing all OAuth2/OIDC registrations. + * + *

This guard is only active when the {@code registration-guard} Spring profile is enabled. + * To try it out, add {@code registration-guard} to your active profiles:

+ * + *
+ * ./gradlew bootRun --args='--spring.profiles.active=local,registration-guard'
+ * 
+ * + *

See the + * + * Registration Guard documentation for the full SPI reference.

+ * + * @see RegistrationGuard + * @see RegistrationContext + * @see RegistrationDecision + */ +@Slf4j +@Component +@Profile("registration-guard") +public class DomainRegistrationGuard implements RegistrationGuard { + + private static final String ALLOWED_DOMAIN = "@example.com"; + + @Override + public RegistrationDecision evaluate(RegistrationContext context) { + log.debug("Evaluating registration for email: {}, source: {}, provider: {}", + context.email(), context.source(), context.providerName()); + + // Allow all OAuth2/OIDC registrations regardless of email domain + if (context.source() == RegistrationSource.OAUTH2 + || context.source() == RegistrationSource.OIDC) { + log.debug("Allowing {} registration for: {}", context.source(), context.email()); + return RegistrationDecision.allow(); + } + + // For form/passwordless, restrict to the allowed domain + if (context.email() != null && context.email().toLowerCase().endsWith(ALLOWED_DOMAIN)) { + log.debug("Allowing registration for approved domain: {}", context.email()); + return RegistrationDecision.allow(); + } + + log.info("Denied registration for: {} (domain not allowed)", context.email()); + return RegistrationDecision.deny( + "Registration is restricted to " + ALLOWED_DOMAIN + " email addresses."); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java index d4e8b73..9a5ad1d 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/service/UserServiceTest.java @@ -55,6 +55,9 @@ public class UserServiceTest { @Mock private PasswordHistoryRepository passwordHistoryRepository; + @Mock + private SessionInvalidationService sessionInvalidationService; + private UserService userService; private User testUser; private UserDto testUserDto; @@ -78,7 +81,7 @@ void setUp() { userService = new UserService(userRepository, tokenRepository, passwordTokenRepository, passwordEncoder, roleRepository, sessionRegistry, userEmailService, userVerificationService, authorityService, - dsUserDetailsService, eventPublisher, passwordHistoryRepository); + dsUserDetailsService, eventPublisher, passwordHistoryRepository, sessionInvalidationService); } @Test From f0cefd6fbde744a4a301edc538192f121e0641d5 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 12 Mar 2026 10:23:55 -0600 Subject: [PATCH 2/4] fix: Use Locale.ROOT for toLowerCase() in domain comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review feedback — locale-independent case folding prevents unexpected behavior in non-English locales (e.g., Turkish). --- .../spring/demo/registration/DomainRegistrationGuard.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java index abb30a5..f6c1947 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java @@ -1,5 +1,7 @@ package com.digitalsanctuary.spring.demo.registration; +import java.util.Locale; + import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -49,7 +51,7 @@ public RegistrationDecision evaluate(RegistrationContext context) { } // For form/passwordless, restrict to the allowed domain - if (context.email() != null && context.email().toLowerCase().endsWith(ALLOWED_DOMAIN)) { + if (context.email() != null && context.email().toLowerCase(Locale.ROOT).endsWith(ALLOWED_DOMAIN)) { log.debug("Allowing registration for approved domain: {}", context.email()); return RegistrationDecision.allow(); } From 87b420121fccd2699289095f197bedb32181c38e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 12 Mar 2026 16:45:45 -0600 Subject: [PATCH 3/4] chore(deps): update ds-spring-user-framework to 4.3.0 release --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a9eaaf1..18e3f61 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.3-SNAPSHOT' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.3.0' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' From 41594608dbef331604e56b19ac663715ba2e5401 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Thu, 12 Mar 2026 16:49:36 -0600 Subject: [PATCH 4/4] feat: Make allowed domain configurable and add unit tests Replace hardcoded ALLOWED_DOMAIN constant with @Value-injected property (registration.guard.allowed-domain, defaults to @example.com). Add 10 unit tests covering all code paths for DomainRegistrationGuard. --- .../registration/DomainRegistrationGuard.java | 19 ++-- .../DomainRegistrationGuardTest.java | 89 +++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuardTest.java diff --git a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java index f6c1947..9d8b6dc 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuard.java @@ -2,6 +2,7 @@ import java.util.Locale; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -13,8 +14,8 @@ import lombok.extern.slf4j.Slf4j; /** - * Sample {@link RegistrationGuard} that restricts form and passwordless registration to - * {@code @example.com} email addresses while allowing all OAuth2/OIDC registrations. + * Sample {@link RegistrationGuard} that restricts form and passwordless registration to a + * configurable email domain while allowing all OAuth2/OIDC registrations. * *

This guard is only active when the {@code registration-guard} Spring profile is enabled. * To try it out, add {@code registration-guard} to your active profiles:

@@ -23,6 +24,9 @@ * ./gradlew bootRun --args='--spring.profiles.active=local,registration-guard' * * + *

The allowed domain can be configured via the {@code registration.guard.allowed-domain} + * property (defaults to {@code @example.com}).

+ * *

See the * * Registration Guard documentation for the full SPI reference.

@@ -36,7 +40,12 @@ @Profile("registration-guard") public class DomainRegistrationGuard implements RegistrationGuard { - private static final String ALLOWED_DOMAIN = "@example.com"; + private final String allowedDomain; + + public DomainRegistrationGuard( + @Value("${registration.guard.allowed-domain:@example.com}") String allowedDomain) { + this.allowedDomain = allowedDomain.toLowerCase(Locale.ROOT); + } @Override public RegistrationDecision evaluate(RegistrationContext context) { @@ -51,13 +60,13 @@ public RegistrationDecision evaluate(RegistrationContext context) { } // For form/passwordless, restrict to the allowed domain - if (context.email() != null && context.email().toLowerCase(Locale.ROOT).endsWith(ALLOWED_DOMAIN)) { + if (context.email() != null && context.email().toLowerCase(Locale.ROOT).endsWith(allowedDomain)) { log.debug("Allowing registration for approved domain: {}", context.email()); return RegistrationDecision.allow(); } log.info("Denied registration for: {} (domain not allowed)", context.email()); return RegistrationDecision.deny( - "Registration is restricted to " + ALLOWED_DOMAIN + " email addresses."); + "Registration is restricted to " + allowedDomain + " email addresses."); } } diff --git a/src/test/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuardTest.java b/src/test/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuardTest.java new file mode 100644 index 0000000..d9ee6d5 --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/demo/registration/DomainRegistrationGuardTest.java @@ -0,0 +1,89 @@ +package com.digitalsanctuary.spring.demo.registration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.digitalsanctuary.spring.user.registration.RegistrationContext; +import com.digitalsanctuary.spring.user.registration.RegistrationDecision; +import com.digitalsanctuary.spring.user.registration.RegistrationSource; + +class DomainRegistrationGuardTest { + + private final DomainRegistrationGuard guard = new DomainRegistrationGuard("@example.com"); + + @Test + void formRegistrationWithAllowedDomainIsAllowed() { + RegistrationContext context = new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + RegistrationDecision decision = guard.evaluate(context); + assertTrue(decision.allowed()); + } + + @Test + void formRegistrationWithDisallowedDomainIsDenied() { + RegistrationContext context = new RegistrationContext("user@other.com", RegistrationSource.FORM, null); + RegistrationDecision decision = guard.evaluate(context); + assertFalse(decision.allowed()); + assertTrue(decision.reason().contains("@example.com")); + } + + @Test + void passwordlessRegistrationWithAllowedDomainIsAllowed() { + RegistrationContext context = new RegistrationContext("user@example.com", RegistrationSource.PASSWORDLESS, null); + RegistrationDecision decision = guard.evaluate(context); + assertTrue(decision.allowed()); + } + + @Test + void passwordlessRegistrationWithDisallowedDomainIsDenied() { + RegistrationContext context = new RegistrationContext("user@other.com", RegistrationSource.PASSWORDLESS, null); + RegistrationDecision decision = guard.evaluate(context); + assertFalse(decision.allowed()); + } + + @Test + void oauth2RegistrationIsAlwaysAllowed() { + RegistrationContext context = new RegistrationContext("user@other.com", RegistrationSource.OAUTH2, "google"); + RegistrationDecision decision = guard.evaluate(context); + assertTrue(decision.allowed()); + } + + @Test + void oidcRegistrationIsAlwaysAllowed() { + RegistrationContext context = new RegistrationContext("user@other.com", RegistrationSource.OIDC, "keycloak"); + RegistrationDecision decision = guard.evaluate(context); + assertTrue(decision.allowed()); + } + + @Test + void nullEmailIsDenied() { + RegistrationContext context = new RegistrationContext(null, RegistrationSource.FORM, null); + RegistrationDecision decision = guard.evaluate(context); + assertFalse(decision.allowed()); + } + + @Test + void domainCheckIsCaseInsensitive() { + RegistrationContext context = new RegistrationContext("user@EXAMPLE.COM", RegistrationSource.FORM, null); + RegistrationDecision decision = guard.evaluate(context); + assertTrue(decision.allowed()); + } + + @Test + void customDomainIsRespected() { + DomainRegistrationGuard customGuard = new DomainRegistrationGuard("@mycompany.org"); + RegistrationContext allowed = new RegistrationContext("user@mycompany.org", RegistrationSource.FORM, null); + RegistrationContext denied = new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + assertTrue(customGuard.evaluate(allowed).allowed()); + assertFalse(customGuard.evaluate(denied).allowed()); + } + + @Test + void configuredDomainIsCaseInsensitive() { + DomainRegistrationGuard upperGuard = new DomainRegistrationGuard("@EXAMPLE.COM"); + RegistrationContext context = new RegistrationContext("user@example.com", RegistrationSource.FORM, null); + assertTrue(upperGuard.evaluate(context).allowed()); + } +}