Skip to content

Commit 59b65bd

Browse files
committed
feat: Add RegistrationGuard SPI for pre-registration hook
Add a Service Provider Interface that gates all four registration paths (form, passwordless, OAuth2, OIDC) with a single evaluate() call. The guard uses bean-presence activation — consumers implement RegistrationGuard and the default permit-all fallback is replaced via @ConditionalOnMissingBean. Core types: RegistrationGuard (interface), RegistrationContext (record), RegistrationDecision (record with allow/deny factories), RegistrationSource (enum), DefaultRegistrationGuard (permit-all fallback). Includes REGISTRATION-GUARD.md documentation and updates CLAUDE.md related docs section. Closes #271
1 parent 5117b11 commit 59b65bd

19 files changed

Lines changed: 920 additions & 2 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ Integration tests use `TestApplication` (in `test.app` package) as their Spring
192192

193193
- `TESTING.md` - Comprehensive testing guide with patterns and troubleshooting
194194
- `PROFILE.md` - User profile extension framework
195+
- `REGISTRATION-GUARD.md` - Registration Guard SPI for pre-registration hooks
195196
- `CONFIG.md` - Configuration reference
196197
- `MIGRATION.md` - Version migration guide
197198
- `CONTRIBUTING.md` - Contributor guidelines (fork/branch/PR workflow)

REGISTRATION-GUARD.md

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
# Registration Guard SPI
2+
3+
This guide explains how to use the Registration Guard SPI in Spring User Framework to control who can register in your application.
4+
5+
## Table of Contents
6+
- [Registration Guard SPI](#registration-guard-spi)
7+
- [Table of Contents](#table-of-contents)
8+
- [Overview](#overview)
9+
- [When to Use](#when-to-use)
10+
- [Core Components](#core-components)
11+
- [Implementation Guide](#implementation-guide)
12+
- [Usage Examples](#usage-examples)
13+
- [Domain Restriction](#domain-restriction)
14+
- [Invite-Only with OAuth2 Bypass](#invite-only-with-oauth2-bypass)
15+
- [Beta Access / Waitlist](#beta-access--waitlist)
16+
- [Denial Behavior](#denial-behavior)
17+
- [Key Constraints](#key-constraints)
18+
- [Troubleshooting](#troubleshooting)
19+
20+
## Overview
21+
22+
The Registration Guard is a pre-registration hook that gates all four registration paths: form, passwordless, OAuth2, and OIDC. It allows you to accept or reject registration attempts before a user account is created.
23+
24+
The guard requires zero configuration — it activates by bean presence alone. When no custom guard is defined, a built-in permit-all default is used automatically.
25+
26+
## When to Use
27+
28+
Consider implementing a Registration Guard when you need to:
29+
30+
- Restrict registration to specific email domains (e.g., corporate apps)
31+
- Implement invite-only or beta access registration
32+
- Enforce waitlist-based onboarding
33+
- Apply compliance or legal gates before account creation
34+
- Allow social login but restrict form-based registration (or vice versa)
35+
36+
If your application allows open registration with no restrictions, you do not need to implement a guard.
37+
38+
## Core Components
39+
40+
The Registration Guard SPI consists of these types in the `com.digitalsanctuary.spring.user.registration` package:
41+
42+
1. **`RegistrationGuard`** — The interface you implement. Has a single method: `evaluate(RegistrationContext)` returning a `RegistrationDecision`.
43+
44+
2. **`RegistrationContext`** — An immutable record describing the registration attempt:
45+
- `email` — the email address of the user attempting to register
46+
- `source` — the registration path (`FORM`, `PASSWORDLESS`, `OAUTH2`, or `OIDC`)
47+
- `providerName` — the OAuth2/OIDC provider registration ID (e.g. `"google"`, `"keycloak"`), or `null` for form/passwordless
48+
49+
3. **`RegistrationDecision`** — An immutable record with the guard's verdict:
50+
- `allowed` — whether the registration is permitted
51+
- `reason` — a human-readable denial reason (may be `null` when allowed)
52+
- `allow()` — static factory for an allowing decision
53+
- `deny(String reason)` — static factory for a denying decision
54+
55+
4. **`RegistrationSource`** — Enum identifying the registration path: `FORM`, `PASSWORDLESS`, `OAUTH2`, `OIDC`
56+
57+
5. **`DefaultRegistrationGuard`** — The built-in permit-all fallback. Automatically registered via `@ConditionalOnMissingBean` when no custom guard bean exists.
58+
59+
## Implementation Guide
60+
61+
Create a `@Component` that implements `RegistrationGuard`. That's it — the default guard is automatically replaced.
62+
63+
```java
64+
@Component
65+
public class MyRegistrationGuard implements RegistrationGuard {
66+
67+
@Override
68+
public RegistrationDecision evaluate(RegistrationContext context) {
69+
// Your logic here
70+
return RegistrationDecision.allow();
71+
}
72+
}
73+
```
74+
75+
No additional configuration, properties, or wiring is needed. The library detects your bean and uses it in place of the default.
76+
77+
## Usage Examples
78+
79+
### Domain Restriction
80+
81+
Allow only users with a specific email domain:
82+
83+
```java
84+
@Component
85+
public class DomainGuard implements RegistrationGuard {
86+
87+
@Override
88+
public RegistrationDecision evaluate(RegistrationContext context) {
89+
if (context.email().endsWith("@mycompany.com")) {
90+
return RegistrationDecision.allow();
91+
}
92+
return RegistrationDecision.deny("Registration is restricted to @mycompany.com email addresses.");
93+
}
94+
}
95+
```
96+
97+
### Invite-Only with OAuth2 Bypass
98+
99+
Require an invite for form/passwordless registration but allow all OAuth2/OIDC users:
100+
101+
```java
102+
@Component
103+
@RequiredArgsConstructor
104+
public class InviteOnlyGuard implements RegistrationGuard {
105+
106+
private final InviteCodeRepository inviteCodeRepository;
107+
108+
@Override
109+
public RegistrationDecision evaluate(RegistrationContext context) {
110+
// Allow all OAuth2/OIDC registrations
111+
if (context.source() == RegistrationSource.OAUTH2
112+
|| context.source() == RegistrationSource.OIDC) {
113+
return RegistrationDecision.allow();
114+
}
115+
// For form/passwordless, check invite list
116+
if (inviteCodeRepository.existsByEmail(context.email())) {
117+
return RegistrationDecision.allow();
118+
}
119+
return RegistrationDecision.deny("Registration is by invitation only.");
120+
}
121+
}
122+
```
123+
124+
### Beta Access / Waitlist
125+
126+
Check a beta-users table before allowing registration:
127+
128+
```java
129+
@Component
130+
@RequiredArgsConstructor
131+
public class BetaAccessGuard implements RegistrationGuard {
132+
133+
private final BetaUserRepository betaUserRepository;
134+
135+
@Override
136+
public RegistrationDecision evaluate(RegistrationContext context) {
137+
if (betaUserRepository.existsByEmail(context.email())) {
138+
return RegistrationDecision.allow();
139+
}
140+
return RegistrationDecision.deny("Registration is currently limited to beta users. "
141+
+ "Please join the waitlist.");
142+
}
143+
}
144+
```
145+
146+
## Denial Behavior
147+
148+
When a guard denies a registration, the behavior depends on the registration path:
149+
150+
| Registration Path | Denial Response |
151+
|---|---|
152+
| **Form** | HTTP 403 Forbidden with JSON: `{"success": false, "code": 6, "messages": ["<reason>"]}` |
153+
| **Passwordless** | HTTP 403 Forbidden with JSON: `{"success": false, "code": 6, "messages": ["<reason>"]}` |
154+
| **OAuth2** | `OAuth2AuthenticationException` with error code `"registration_denied"` — handled by Spring Security's OAuth2 failure handler |
155+
| **OIDC** | `OAuth2AuthenticationException` with error code `"registration_denied"` — handled by Spring Security's OAuth2 failure handler |
156+
157+
For OAuth2/OIDC denials, customize the user experience by configuring Spring Security's OAuth2 login failure handler to inspect the error code and display an appropriate message.
158+
159+
## Key Constraints
160+
161+
- **Single-bean SPI** — Only one `RegistrationGuard` bean may be active at a time. This is not a chain or filter pattern; define exactly one guard.
162+
- **Thread safety required** — The guard may be invoked concurrently from multiple request threads. Ensure your implementation is thread-safe.
163+
- **No configuration properties** — The guard is activated entirely by bean presence. There are no `user.*` properties involved.
164+
- **Existing users unaffected** — The guard only runs for new registrations. Existing users logging in via OAuth2/OIDC are not evaluated.
165+
166+
## Troubleshooting
167+
168+
**Guard Not Activating**
169+
- Ensure your guard class is annotated with `@Component` (or otherwise registered as a Spring bean)
170+
- Verify the class is within a package that is component-scanned by your application
171+
- Check that `DefaultRegistrationGuard` is being replaced — enable debug logging:
172+
```yaml
173+
logging:
174+
level:
175+
com.digitalsanctuary.spring.user.registration: DEBUG
176+
```
177+
178+
**Multiple Guards Defined**
179+
- Only one `RegistrationGuard` bean is allowed. If multiple beans are defined, Spring will throw a `NoUniqueBeanDefinitionException` at startup.
180+
- If you need to compose multiple rules, implement a single guard that delegates internally.
181+
182+
**OAuth2/OIDC Denial UX**
183+
- By default, OAuth2/OIDC denials redirect to Spring Security's default failure URL with a generic error.
184+
- To show a custom message, configure an `AuthenticationFailureHandler` on your OAuth2 login that checks for the `"registration_denied"` error code:
185+
```java
186+
http.oauth2Login(oauth2 -> oauth2
187+
.failureHandler((request, response, exception) -> {
188+
if (exception instanceof OAuth2AuthenticationException oauthEx
189+
&& "registration_denied".equals(oauthEx.getError().getErrorCode())) {
190+
response.sendRedirect("/registration-denied");
191+
} else {
192+
response.sendRedirect("/login?error");
193+
}
194+
})
195+
);
196+
```
197+
198+
---
199+
200+
This SPI provides a clean extension point for controlling registration without modifying framework internals. Implement a single bean, return allow or deny, and the framework handles the rest across all registration paths.
201+
202+
For a complete working example, refer to the [Spring User Framework Demo Application](https://github.com/devondragon/SpringUserFrameworkDemoApp).

src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
import com.digitalsanctuary.spring.user.exceptions.InvalidOldPasswordException;
3232
import com.digitalsanctuary.spring.user.exceptions.UserAlreadyExistException;
3333
import com.digitalsanctuary.spring.user.persistence.model.User;
34+
import com.digitalsanctuary.spring.user.registration.RegistrationContext;
35+
import com.digitalsanctuary.spring.user.registration.RegistrationDecision;
36+
import com.digitalsanctuary.spring.user.registration.RegistrationGuard;
37+
import com.digitalsanctuary.spring.user.registration.RegistrationSource;
3438
import com.digitalsanctuary.spring.user.service.DSUserDetails;
3539
import com.digitalsanctuary.spring.user.service.PasswordPolicyService;
3640
import com.digitalsanctuary.spring.user.service.UserEmailService;
@@ -67,6 +71,7 @@ public class UserAPI {
6771
private final ApplicationEventPublisher eventPublisher;
6872
private final PasswordPolicyService passwordPolicyService;
6973
private final ObjectProvider<WebAuthnCredentialManagementService> webAuthnCredentialManagementServiceProvider;
74+
private final RegistrationGuard registrationGuard;
7075

7176
@Value("${user.security.registrationPendingURI}")
7277
private String registrationPendingURI;
@@ -104,6 +109,12 @@ public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody User
104109
return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST);
105110
}
106111

112+
RegistrationDecision decision = registrationGuard.evaluate(
113+
new RegistrationContext(userDto.getEmail(), RegistrationSource.FORM, null));
114+
if (!decision.allowed()) {
115+
return buildErrorResponse(decision.reason(), 6, HttpStatus.FORBIDDEN);
116+
}
117+
107118
User registeredUser = userService.registerNewUserAccount(userDto);
108119
publishRegistrationEvent(registeredUser, request);
109120
logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request);
@@ -395,6 +406,12 @@ public ResponseEntity<JSONResponse> registerPasswordlessAccount(@Valid @RequestB
395406
return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST);
396407
}
397408
try {
409+
RegistrationDecision decision = registrationGuard.evaluate(
410+
new RegistrationContext(dto.getEmail(), RegistrationSource.PASSWORDLESS, null));
411+
if (!decision.allowed()) {
412+
return buildErrorResponse(decision.reason(), 6, HttpStatus.FORBIDDEN);
413+
}
414+
398415
User registeredUser = userService.registerPasswordlessAccount(dto);
399416
publishRegistrationEvent(registeredUser, request);
400417
logAuditEvent("PasswordlessRegistration", "Success", "Passwordless registration successful", registeredUser, request);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
/**
4+
* Default {@link RegistrationGuard} that permits all registrations.
5+
*
6+
* <p>This implementation is automatically registered when the consuming application does not
7+
* define its own {@link RegistrationGuard} bean. To restrict registration, declare
8+
* a custom {@link RegistrationGuard} bean in your application context.</p>
9+
*
10+
* @see RegistrationGuard
11+
* @see RegistrationGuardConfiguration
12+
*/
13+
public class DefaultRegistrationGuard implements RegistrationGuard {
14+
15+
@Override
16+
public RegistrationDecision evaluate(RegistrationContext context) {
17+
return RegistrationDecision.allow();
18+
}
19+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
/**
4+
* Immutable context passed to {@link RegistrationGuard#evaluate(RegistrationContext)}
5+
* describing the registration attempt being evaluated.
6+
*
7+
* @param email the email address of the user attempting to register
8+
* @param source the registration path (form, passwordless, OAuth2, or OIDC)
9+
* @param providerName the OAuth2/OIDC provider registration ID (e.g. {@code "google"},
10+
* {@code "keycloak"}), or {@code null} for form/passwordless registrations
11+
*/
12+
public record RegistrationContext(String email, RegistrationSource source, String providerName) {
13+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
/**
4+
* The result of a {@link RegistrationGuard} evaluation indicating whether
5+
* a registration attempt should be allowed or denied.
6+
*
7+
* @param allowed whether the registration is permitted
8+
* @param reason a human-readable denial reason (may be {@code null} when allowed)
9+
*/
10+
public record RegistrationDecision(boolean allowed, String reason) {
11+
12+
/**
13+
* Creates a decision that allows the registration to proceed.
14+
*
15+
* @return an allowing decision with no reason
16+
*/
17+
public static RegistrationDecision allow() {
18+
return new RegistrationDecision(true, null);
19+
}
20+
21+
/**
22+
* Creates a decision that denies the registration.
23+
*
24+
* @param reason a human-readable explanation for the denial
25+
* @return a denying decision with the given reason
26+
*/
27+
public static RegistrationDecision deny(String reason) {
28+
return new RegistrationDecision(false, reason);
29+
}
30+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
/**
4+
* Service Provider Interface (SPI) for gating user registration.
5+
*
6+
* <p>Implementations are called before a new user account is created across all
7+
* registration paths (form, passwordless, OAuth2, OIDC). If the guard denies a
8+
* registration, the user is not created and an appropriate error is returned.</p>
9+
*
10+
* <p>To activate a custom guard, declare a Spring bean that implements this interface.
11+
* The library's default (permit-all) guard is automatically replaced via
12+
* {@link org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean}.</p>
13+
*
14+
* <h2>Usage Example</h2>
15+
* <pre>{@code
16+
* @Component
17+
* public class InviteOnlyGuard implements RegistrationGuard {
18+
* private final InviteCodeRepository inviteCodeRepository;
19+
*
20+
* public InviteOnlyGuard(InviteCodeRepository inviteCodeRepository) {
21+
* this.inviteCodeRepository = inviteCodeRepository;
22+
* }
23+
*
24+
* @Override
25+
* public RegistrationDecision evaluate(RegistrationContext context) {
26+
* // Allow all OAuth2/OIDC registrations
27+
* if (context.source() == RegistrationSource.OAUTH2 || context.source() == RegistrationSource.OIDC) {
28+
* return RegistrationDecision.allow();
29+
* }
30+
* // Require invite code for form/passwordless
31+
* return RegistrationDecision.deny("Registration is by invitation only.");
32+
* }
33+
* }
34+
* }</pre>
35+
*
36+
* <p><strong>Thread Safety:</strong> Implementations must be thread-safe as they may
37+
* be invoked concurrently from multiple request threads.</p>
38+
*
39+
* @see RegistrationContext
40+
* @see RegistrationDecision
41+
* @see DefaultRegistrationGuard
42+
*/
43+
public interface RegistrationGuard {
44+
45+
/**
46+
* Evaluates whether a registration attempt should be allowed.
47+
*
48+
* @param context the registration context describing the attempt
49+
* @return a {@link RegistrationDecision} indicating whether registration is permitted
50+
*/
51+
RegistrationDecision evaluate(RegistrationContext context);
52+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
7+
/**
8+
* Auto-configuration for the {@link RegistrationGuard} SPI.
9+
*
10+
* <p>Registers a {@link DefaultRegistrationGuard} (permit-all) when no custom
11+
* {@link RegistrationGuard} bean is defined by the consuming application.</p>
12+
*/
13+
@Configuration
14+
public class RegistrationGuardConfiguration {
15+
16+
@Bean
17+
@ConditionalOnMissingBean(RegistrationGuard.class)
18+
public RegistrationGuard registrationGuard() {
19+
return new DefaultRegistrationGuard();
20+
}
21+
}

0 commit comments

Comments
 (0)