Skip to content

feat: Add RegistrationGuard SPI for pre-registration hook #271

@devondragon

Description

@devondragon

Summary

Add a RegistrationGuard Service Provider Interface (SPI) that allows consuming applications to control who is allowed to register. The guard is called before a new user account is created at all registration paths (form, passwordless, OAuth2, OIDC).

Motivation

Consumer applications need the ability to restrict registration (invite-only mode, beta access, domain restrictions, etc.). Currently, the library has no pre-registration hook:

  • Form registration (UserAPI.registerUserAccount()) calls UserService.registerNewUserAccount() directly
  • OAuth2/OIDC auto-registration (DSOAuth2UserService/DSOidcUserService) call registerNewOAuthUser()/registerNewOidcUser() inside loadUser() — consumer apps cannot intercept this path without fragile bean overrides

A library-level SPI is the only clean way to gate registration across all paths.

Design

New Types (package: com.digitalsanctuary.spring.user.registration)

RegistrationSource enum

public enum RegistrationSource {
    FORM, PASSWORDLESS, OAUTH2, OIDC
}

RegistrationContext record

public record RegistrationContext(
    String email,
    RegistrationSource source,
    String providerName  // null for FORM/PASSWORDLESS, "google"/"facebook" for OAuth
) {}

RegistrationDecision record

public record RegistrationDecision(boolean allowed, String reason) {
    public static RegistrationDecision allow() { ... }
    public static RegistrationDecision deny(String reason) { ... }
}

RegistrationGuard interface

public interface RegistrationGuard {
    RegistrationDecision evaluate(RegistrationContext context);
}

DefaultRegistrationGuard (backward-compatible default)

@Component
@ConditionalOnMissingBean(RegistrationGuard.class)
public class DefaultRegistrationGuard implements RegistrationGuard {
    @Override
    public RegistrationDecision evaluate(RegistrationContext context) {
        return RegistrationDecision.allow();
    }
}

Integration Points (4 locations)

Path File Where On Denial
Form UserAPI.java ~L107 After password validation, before registerNewUserAccount() Return 403 JSONResponse
Passwordless UserAPI.java ~L398 After WebAuthn check, before registerPasswordlessAccount() Return 403 JSONResponse
OAuth2 DSOAuth2UserService.java ~L103 In handleOAuthLoginSuccess() else branch, before registerNewOAuthUser() Throw OAuth2AuthenticationException
OIDC DSOidcUserService.java ~L91 In handleOidcLoginSuccess() else branch, before registerNewOidcUser() Throw OAuth2AuthenticationException

Key Details

  • Guard is only called for new user registration, NOT existing user logins (the OAuth/OIDC services already branch on existingUser != null)
  • OAuth error handling works automatically: existing WebSecurityConfig failure handler catches OAuth2AuthenticationException, stores message in session, redirects to login page — no security config changes needed
  • Backward compatible: @ConditionalOnMissingBean on DefaultRegistrationGuard means existing apps see zero behavioral change
  • Thread-safe: Guard implementations must be thread-safe (document in Javadoc)
  • No configuration properties needed: feature activates purely by bean presence

Files to Modify

  • UserAPI.java — add RegistrationGuard constructor param, add guard checks
  • DSOAuth2UserService.java — add RegistrationGuard constructor param, add guard check
  • DSOidcUserService.java — add RegistrationGuard constructor param, add guard check

Files to Create

  • src/main/java/.../registration/RegistrationSource.java
  • src/main/java/.../registration/RegistrationContext.java
  • src/main/java/.../registration/RegistrationDecision.java
  • src/main/java/.../registration/RegistrationGuard.java
  • src/main/java/.../registration/DefaultRegistrationGuard.java

Testing

  • Unit: RegistrationDecision factory methods, DefaultRegistrationGuard always allows
  • Integration: Mock guard → deny → verify 403 for form paths, OAuth2AuthenticationException for OAuth/OIDC paths
  • Integration: Verify existing users can still log in via OAuth when guard denies new registrations
  • Backward compat: No custom guard bean → all registrations succeed as before

Consumer App Example

@Component
public class InviteOnlyGuard implements RegistrationGuard {

    private final InviteService inviteService;
    private final WhitelistService whitelistService;

    @Override
    public RegistrationDecision evaluate(RegistrationContext context) {
        if (whitelistService.isWhitelisted(context.email())) {
            return RegistrationDecision.allow();
        }
        // Read invite code from HTTP session (stored when user visited registration page)
        HttpServletRequest request = ((ServletRequestAttributes)
            RequestContextHolder.getRequestAttributes()).getRequest();
        String code = (String) request.getSession().getAttribute("inviteCode");
        if (inviteService.isValidCode(code, context.email())) {
            return RegistrationDecision.allow();
        }
        return RegistrationDecision.deny("Registration is invite-only.");
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions