Skip to content

Commit 1f1c917

Browse files
authored
Merge pull request #277 from devondragon/issue-271-feat-Add-RegistrationGuard-SPI-for-pre-registration-hook
feat: Add RegistrationGuard SPI for pre-registration hook
2 parents 5117b11 + 25a89c8 commit 1f1c917

19 files changed

Lines changed: 956 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+
The JSON error code `6` identifies a registration guard denial specifically, distinguishing it from other registration errors (e.g., code `1` for validation failures, code `2` for duplicate accounts). Client-side code can check this code to display targeted messaging.
158+
159+
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.
160+
161+
All denied registrations are logged at INFO level with the email, source, and denial reason.
162+
163+
## Key Constraints
164+
165+
- **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.
166+
- **Thread safety required** — The guard may be invoked concurrently from multiple request threads. Ensure your implementation is thread-safe.
167+
- **No configuration properties** — The guard is activated entirely by bean presence. There are no `user.*` properties involved.
168+
- **Existing users unaffected** — The guard only runs for new registrations. Existing users logging in via OAuth2/OIDC are not evaluated.
169+
170+
## Troubleshooting
171+
172+
**Guard Not Activating**
173+
- Ensure your guard class is annotated with `@Component` (or otherwise registered as a Spring bean)
174+
- Verify the class is within a package that is component-scanned by your application
175+
- At startup, the library logs `"No custom RegistrationGuard bean found — using DefaultRegistrationGuard (permit-all)"` at INFO level. If you see this message, your custom guard bean is not being detected.
176+
- You can also check the active guard via `/actuator/beans` (if enabled) or your IDE's Spring tooling.
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: 22 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;
@@ -61,12 +65,16 @@
6165
@RequestMapping(path = "/user", produces = "application/json")
6266
public class UserAPI {
6367

68+
/** Error code returned when the {@link RegistrationGuard} denies a registration attempt. */
69+
private static final int ERROR_CODE_REGISTRATION_DENIED = 6;
70+
6471
private final UserService userService;
6572
private final UserEmailService userEmailService;
6673
private final MessageSource messages;
6774
private final ApplicationEventPublisher eventPublisher;
6875
private final PasswordPolicyService passwordPolicyService;
6976
private final ObjectProvider<WebAuthnCredentialManagementService> webAuthnCredentialManagementServiceProvider;
77+
private final RegistrationGuard registrationGuard;
7078

7179
@Value("${user.security.registrationPendingURI}")
7280
private String registrationPendingURI;
@@ -104,6 +112,13 @@ public ResponseEntity<JSONResponse> registerUserAccount(@Valid @RequestBody User
104112
return buildErrorResponse(String.join(" ", errors), 1, HttpStatus.BAD_REQUEST);
105113
}
106114

115+
RegistrationDecision decision = registrationGuard.evaluate(
116+
new RegistrationContext(userDto.getEmail(), RegistrationSource.FORM, null));
117+
if (!decision.allowed()) {
118+
log.info("Registration denied for email: {} source: FORM reason: {}", userDto.getEmail(), decision.reason());
119+
return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN);
120+
}
121+
107122
User registeredUser = userService.registerNewUserAccount(userDto);
108123
publishRegistrationEvent(registeredUser, request);
109124
logAuditEvent("Registration", "Success", "Registration Successful", registeredUser, request);
@@ -395,6 +410,13 @@ public ResponseEntity<JSONResponse> registerPasswordlessAccount(@Valid @RequestB
395410
return buildErrorResponse("Passwordless registration is not available", 1, HttpStatus.BAD_REQUEST);
396411
}
397412
try {
413+
RegistrationDecision decision = registrationGuard.evaluate(
414+
new RegistrationContext(dto.getEmail(), RegistrationSource.PASSWORDLESS, null));
415+
if (!decision.allowed()) {
416+
log.info("Registration denied for email: {} source: PASSWORDLESS reason: {}", dto.getEmail(), decision.reason());
417+
return buildErrorResponse(decision.reason(), ERROR_CODE_REGISTRATION_DENIED, HttpStatus.FORBIDDEN);
418+
}
419+
398420
User registeredUser = userService.registerPasswordlessAccount(dto);
399421
publishRegistrationEvent(registeredUser, request);
400422
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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.digitalsanctuary.spring.user.registration;
2+
3+
import java.util.Objects;
4+
5+
/**
6+
* Immutable context passed to {@link RegistrationGuard#evaluate(RegistrationContext)}
7+
* describing the registration attempt being evaluated.
8+
*
9+
* @param email the email address of the user attempting to register; may be {@code null}
10+
* if the OAuth2/OIDC provider does not expose the user's email
11+
* @param source the registration path (form, passwordless, OAuth2, or OIDC); never {@code null}
12+
* @param providerName the OAuth2/OIDC provider registration ID (e.g. {@code "google"},
13+
* {@code "keycloak"}), or {@code null} for form/passwordless registrations
14+
*/
15+
public record RegistrationContext(String email, RegistrationSource source, String providerName) {
16+
17+
public RegistrationContext {
18+
Objects.requireNonNull(source, "source must not be null");
19+
}
20+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
if (reason == null || reason.trim().isEmpty()) {
29+
reason = "Registration denied.";
30+
}
31+
return new RegistrationDecision(false, reason);
32+
}
33+
}
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+
}

0 commit comments

Comments
 (0)