|
| 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). |
0 commit comments